diff --git a/src/cli/setup/config.test.ts b/src/cli/setup/config.test.ts new file mode 100644 index 0000000..33870b8 --- /dev/null +++ b/src/cli/setup/config.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { ConfigBuilder } from './config.js'; + +describe('ConfigBuilder', () => { + it('creates minimal config with anthropic + webchat', () => { + const builder = new ConfigBuilder(); + builder.setProvider('default', { provider: 'anthropic', model: 'claude-sonnet-4-20250514', api_key: 'sk-ant-test' }); + builder.setGatewayPort(3777); + const obj = builder.build(); + expect(obj.models.default.provider).toBe('anthropic'); + expect(obj.models.default.api_key).toBe('sk-ant-test'); + expect(obj.server.port).toBe(3777); + }); + + it('adds telegram channel', () => { + const builder = new ConfigBuilder(); + builder.setProvider('default', { provider: 'anthropic', model: 'test', api_key: 'k' }); + builder.setTelegram('123:ABC', [12345]); + const obj = builder.build(); + expect(obj.telegram.bot_token).toBe('123:ABC'); + expect(obj.telegram.allowed_chat_ids).toEqual([12345]); + }); + + it('adds discord channel', () => { + const builder = new ConfigBuilder(); + builder.setProvider('default', { provider: 'anthropic', model: 'test', api_key: 'k' }); + builder.setDiscord('MTIz.test', ['guild1']); + const obj = builder.build(); + expect(obj.discord.bot_token).toBe('MTIz.test'); + expect(obj.discord.allowed_guild_ids).toEqual(['guild1']); + }); + + it('adds fast tier', () => { + const builder = new ConfigBuilder(); + builder.setProvider('default', { provider: 'anthropic', model: 'test', api_key: 'k' }); + builder.setProvider('fast', { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', api_key: 'k' }); + const obj = builder.build(); + expect(obj.models.fast.provider).toBe('anthropic'); + }); + + it('serializes to valid YAML string', () => { + const builder = new ConfigBuilder(); + builder.setProvider('default', { provider: 'ollama', model: 'llama3.3', endpoint: 'http://localhost:11434' }); + builder.setGatewayPort(3777); + const yaml = builder.toYaml(); + expect(yaml).toContain('provider: ollama'); + expect(yaml).toContain('model: llama3.3'); + expect(yaml).toContain('port: 3777'); + }); + + it('loads from existing config object', () => { + const existing = { + models: { default: { provider: 'openai', model: 'gpt-4.1', api_key: 'sk-test' } }, + server: { port: 9999 }, + telegram: { bot_token: '123:ABC', allowed_chat_ids: [111] }, + }; + const builder = ConfigBuilder.fromObject(existing); + const obj = builder.build(); + expect(obj.models.default.provider).toBe('openai'); + expect(obj.server.port).toBe(9999); + expect(obj.telegram.bot_token).toBe('123:ABC'); + }); + + it('sets memory embedding config', () => { + const builder = new ConfigBuilder(); + builder.setProvider('default', { provider: 'anthropic', model: 'test', api_key: 'k' }); + builder.setMemoryEmbedding({ provider: 'openai', api_key: 'sk-emb' }); + const obj = builder.build(); + expect(obj.memory.embedding.enabled).toBe(true); + expect(obj.memory.embedding.provider).toBe('openai'); + expect(obj.memory.embedding.api_key).toBe('sk-emb'); + }); + + it('sets sandbox enabled', () => { + const builder = new ConfigBuilder(); + builder.setSandboxEnabled(true); + const obj = builder.build(); + expect(obj.sandbox.enabled).toBe(true); + }); + + it('sets gateway auth token', () => { + const builder = new ConfigBuilder(); + builder.setGatewayToken('my-secret-token'); + const obj = builder.build(); + expect(obj.server.token).toBe('my-secret-token'); + }); +}); diff --git a/src/cli/setup/config.ts b/src/cli/setup/config.ts new file mode 100644 index 0000000..2a41a1c --- /dev/null +++ b/src/cli/setup/config.ts @@ -0,0 +1,141 @@ +import { stringify } from 'yaml'; + +interface ProviderConfig { + provider: string; + model: string; + api_key?: string; + auth_token?: string; + endpoint?: string; +} + +interface EmbeddingConfig { + provider: string; + api_key?: string; + endpoint?: string; +} + +export class ConfigBuilder { + private config: Record; + + constructor() { + this.config = { + log_level: 'info', + models: {}, + server: { port: 18800, localhost: true }, + hooks: { + confirm: ['shell.*', 'file.write', 'file.patch'], + log: ['web.*', 'file.read'], + silent: ['notify'], + }, + }; + } + + static fromObject(obj: Record): ConfigBuilder { + const builder = new ConfigBuilder(); + builder.config = structuredClone(obj); + return builder; + } + + setProvider(tier: 'default' | 'fast' | 'complex' | 'local', cfg: ProviderConfig): void { + const models = (this.config.models ?? {}) as Record; + const entry: Record = { provider: cfg.provider, model: cfg.model }; + if (cfg.api_key) entry.api_key = cfg.api_key; + if (cfg.auth_token) entry.auth_token = cfg.auth_token; + if (cfg.endpoint) entry.endpoint = cfg.endpoint; + models[tier] = entry; + this.config.models = models; + } + + setTelegram(botToken: string, chatIds: number[]): void { + this.config.telegram = { bot_token: botToken, allowed_chat_ids: chatIds }; + } + + setDiscord(botToken: string, guildIds: string[]): void { + this.config.discord = { bot_token: botToken, allowed_guild_ids: guildIds }; + } + + setSlack(botToken: string, appToken: string, signingSecret: string, channelIds: string[]): void { + this.config.slack = { bot_token: botToken, app_token: appToken, signing_secret: signingSecret, allowed_channel_ids: channelIds }; + } + + setWhatsApp(allowedNumbers: string[]): void { + this.config.whatsapp = { allowed_numbers: allowedNumbers }; + } + + setGatewayPort(port: number): void { + const server = (this.config.server ?? {}) as Record; + server.port = port; + this.config.server = server; + } + + setGatewayToken(token: string): void { + const server = (this.config.server ?? {}) as Record; + server.token = token; + this.config.server = server; + } + + setGatewayLock(enabled: boolean): void { + const server = (this.config.server ?? {}) as Record; + server.lock = enabled; + this.config.server = server; + } + + setTailscaleServe(enabled: boolean): void { + const server = (this.config.server ?? {}) as Record; + const tailscale = (server.tailscale ?? {}) as Record; + tailscale.serve = enabled; + server.tailscale = tailscale; + this.config.server = server; + } + + setMemoryEmbedding(cfg: EmbeddingConfig): void { + const memory = (this.config.memory ?? {}) as Record; + const embedding: Record = { enabled: true, provider: cfg.provider }; + if (cfg.api_key) embedding.api_key = cfg.api_key; + if (cfg.endpoint) embedding.endpoint = cfg.endpoint; + memory.embedding = embedding; + this.config.memory = memory; + } + + setSandboxEnabled(enabled: boolean): void { + this.config.sandbox = { enabled }; + } + + setPairingEnabled(enabled: boolean): void { + this.config.pairing = { enabled }; + } + + setToolProfile(profile: string): void { + this.config.tools = { profile }; + } + + setWebhooksEnabled(secret?: string): void { + const automation = (this.config.automation ?? {}) as Record; + if (secret) { + automation.webhooks = [{ name: 'default', secret, message: '{{body}}', output: { channel: 'webchat', peer: 'webhook' }, enabled: true }]; + } else { + automation.webhooks = automation.webhooks ?? []; + } + this.config.automation = automation; + } + + setGmailEnabled(credentialsFile: string, outputChannel: string, outputPeer: string): void { + const automation = (this.config.automation ?? {}) as Record; + automation.gmail = { enabled: true, credentials_file: credentialsFile, output: { channel: outputChannel, peer: outputPeer } }; + this.config.automation = automation; + } + + setCronEnabled(): void { + const automation = (this.config.automation ?? {}) as Record; + if (!automation.cron) automation.cron = []; + this.config.automation = automation; + } + + build(): Record { + return structuredClone(this.config) as Record; + } + + toYaml(): string { + return stringify(this.config, { lineWidth: 120 }); + } +} diff --git a/src/cli/setup/summary.ts b/src/cli/setup/summary.ts new file mode 100644 index 0000000..3fb92c7 --- /dev/null +++ b/src/cli/setup/summary.ts @@ -0,0 +1,45 @@ +export function renderSummary(config: Record): string { + const lines: string[] = []; + + const models = config.models ?? {}; + const tiers = ['default', 'fast', 'complex', 'local'] + .filter(t => models[t]) + .map(t => `${t}:${models[t].provider}`) + .join(', '); + lines.push(` Models: ${tiers || 'none configured'}`); + + const channels: string[] = []; + if (config.server?.port) channels.push('webchat'); + if (config.telegram) channels.push('telegram'); + if (config.discord) channels.push('discord'); + if (config.slack) channels.push('slack'); + if (config.whatsapp) channels.push('whatsapp'); + lines.push(` Channels: ${channels.join(', ') || 'none'}`); + + const embedding = config.memory?.embedding; + const memoryStatus = embedding?.enabled ? `vector search (${embedding.provider})` : 'keyword search (no embeddings)'; + lines.push(` Memory: ${memoryStatus}`); + + const auto = config.automation ?? {}; + const autoFeatures: string[] = []; + if (auto.cron?.length > 0) autoFeatures.push(`${auto.cron.length} cron jobs`); + if (auto.webhooks?.length > 0) autoFeatures.push('webhooks'); + if (auto.gmail?.enabled) autoFeatures.push('gmail'); + if (auto.heartbeat?.enabled) autoFeatures.push('heartbeat'); + lines.push(` Automation: ${autoFeatures.join(', ') || 'none'}`); + + const secFeatures: string[] = []; + secFeatures.push(`tools:${config.tools?.profile ?? 'full'}`); + if (config.sandbox?.enabled) secFeatures.push('sandbox'); + if (config.pairing?.enabled) secFeatures.push('pairing'); + lines.push(` Security: ${secFeatures.join(', ')}`); + + const gw: string[] = []; + gw.push(`port ${config.server?.port ?? 18800}`); + if (config.server?.token) gw.push('auth'); + if (config.server?.lock) gw.push('locked'); + if (config.server?.tailscale?.serve) gw.push('tailscale'); + lines.push(` Gateway: ${gw.join(', ')}`); + + return lines.join('\n'); +}