diff --git a/docs/plans/2026-02-10-setup-wizard-implementation.md b/docs/plans/2026-02-10-setup-wizard-implementation.md new file mode 100644 index 0000000..0a3cd63 --- /dev/null +++ b/docs/plans/2026-02-10-setup-wizard-implementation.md @@ -0,0 +1,1775 @@ +# Setup Wizard Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Interactive setup wizard (`flynn setup` + auto-trigger on first run) that produces a working `config.yaml` with minimal friction. + +**Architecture:** A `src/cli/setup.ts` command orchestrator delegates to sub-modules for each config section. All mutations buffer in a plain object, then serialize to YAML on save. Uses Node's built-in `readline` for prompts — no new dependencies. + +**Tech Stack:** TypeScript, Commander.js (existing), `yaml` package (already in deps, v2.7+), Node `readline/promises` + +--- + +### Prerequisite: Make telegram optional in config schema + +The current `configSchema` has `telegram: telegramSchema` as **required**. A user who only wants WebChat should not need a telegram section. This must be made optional before the wizard can work correctly. + +**Files:** +- Modify: `src/config/schema.ts:352` — change `telegram: telegramSchema` to `telegram: telegramSchema.optional()` +- Modify: `src/config/schema.ts:381` — update `TelegramConfig` type export +- Modify: `src/cli/start.ts:32` — guard `config.telegram` access (currently assumes it exists) +- Modify: `src/daemon/index.ts` — guard telegram adapter creation behind `if (config.telegram)` + +This is a one-line schema change + a few guard clauses. **Do this first** or the wizard cannot produce telegram-free configs. + +--- + +### Task 1: Prompt Helpers (`src/cli/setup/prompts.ts`) + +**Files:** +- Create: `src/cli/setup/prompts.ts` +- Create: `src/cli/setup/prompts.test.ts` + +**Step 1: Write the failing tests** + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createPrompter } from './prompts.js'; +import { createInterface } from 'readline/promises'; +import { Readable, Writable } from 'stream'; + +function mockReadline(inputs: string[]) { + let idx = 0; + const input = new Readable({ + read() { + if (idx < inputs.length) { + this.push(inputs[idx++] + '\n'); + } else { + this.push(null); + } + }, + }); + const output = new Writable({ write(_, __, cb) { cb(); } }); + return createInterface({ input, output }); +} + +describe('prompts', () => { + describe('ask', () => { + it('returns user input', async () => { + const rl = mockReadline(['hello']); + const p = createPrompter(rl); + expect(await p.ask('Name?')).toBe('hello'); + }); + + it('returns default on empty input', async () => { + const rl = mockReadline(['']); + const p = createPrompter(rl); + expect(await p.ask('Name?', 'world')).toBe('world'); + }); + }); + + describe('confirm', () => { + it('returns true for Y', async () => { + const rl = mockReadline(['Y']); + const p = createPrompter(rl); + expect(await p.confirm('Ok?')).toBe(true); + }); + + it('returns false for n', async () => { + const rl = mockReadline(['n']); + const p = createPrompter(rl); + expect(await p.confirm('Ok?')).toBe(false); + }); + + it('defaults to true when defaultYes=true and input empty', async () => { + const rl = mockReadline(['']); + const p = createPrompter(rl); + expect(await p.confirm('Ok?', true)).toBe(true); + }); + + it('defaults to false when defaultYes=false and input empty', async () => { + const rl = mockReadline(['']); + const p = createPrompter(rl); + expect(await p.confirm('Ok?', false)).toBe(false); + }); + }); + + describe('choose', () => { + it('returns selected option by number', async () => { + const rl = mockReadline(['2']); + const p = createPrompter(rl); + const result = await p.choose('Pick:', [ + { label: 'A', value: 'a' }, + { label: 'B', value: 'b' }, + { label: 'C', value: 'c' }, + ]); + expect(result).toBe('b'); + }); + + it('returns first option on empty input', async () => { + const rl = mockReadline(['']); + const p = createPrompter(rl); + const result = await p.choose('Pick:', [ + { label: 'A', value: 'a' }, + { label: 'B', value: 'b' }, + ]); + expect(result).toBe('a'); + }); + }); + + describe('password', () => { + it('returns input (masking is a UX detail)', async () => { + const rl = mockReadline(['secret123']); + const p = createPrompter(rl); + expect(await p.password('Key:')).toBe('secret123'); + }); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test:run src/cli/setup/prompts.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the implementation** + +```typescript +import type { Interface as ReadlineInterface } from 'readline/promises'; + +export interface ChoiceOption { + label: string; + value: T; +} + +export interface Prompter { + ask(question: string, defaultValue?: string): Promise; + confirm(question: string, defaultYes?: boolean): Promise; + choose(question: string, options: ChoiceOption[]): Promise; + password(question: string): Promise; + println(msg?: string): void; +} + +export function createPrompter(rl: ReadlineInterface): Prompter { + return { + async ask(question: string, defaultValue?: string): Promise { + const suffix = defaultValue !== undefined ? ` [${defaultValue}]` : ''; + const answer = await rl.question(`${question}${suffix}: `); + return answer.trim() || defaultValue || ''; + }, + + async confirm(question: string, defaultYes = true): Promise { + const hint = defaultYes ? '[Y/n]' : '[y/N]'; + const answer = await rl.question(`${question} ${hint} `); + const trimmed = answer.trim().toLowerCase(); + if (trimmed === '') return defaultYes; + return trimmed === 'y' || trimmed === 'yes'; + }, + + async choose(question: string, options: ChoiceOption[]): Promise { + this.println(question); + for (let i = 0; i < options.length; i++) { + this.println(` ${i + 1}. ${options[i].label}`); + } + const answer = await rl.question(`> `); + const idx = parseInt(answer.trim(), 10) - 1; + if (idx >= 0 && idx < options.length) return options[idx].value; + return options[0].value; + }, + + async password(question: string): Promise { + // readline/promises doesn't support input masking natively. + // We accept plaintext input — the terminal handles echo. + const answer = await rl.question(`${question}: `); + return answer.trim(); + }, + + println(msg = ''): void { + process.stdout.write(msg + '\n'); + }, + }; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm test:run src/cli/setup/prompts.test.ts` +Expected: All 7 tests PASS + +**Step 5: Commit** + +```bash +git add src/cli/setup/prompts.ts src/cli/setup/prompts.test.ts +git commit -m "feat(setup): add prompt helpers for setup wizard" +``` + +--- + +### Task 2: Config Builder + Summary Renderer + +**Files:** +- Create: `src/cli/setup/config.ts` +- Create: `src/cli/setup/config.test.ts` +- Create: `src/cli/setup/summary.ts` + +The config builder holds a plain object that accumulates wizard answers, then serializes to YAML. The summary renderer formats current config state for the menu display. + +**Step 1: Write the failing tests** + +```typescript +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: 'claude-sonnet-4-20250514', + api_key: 'sk-ant-test', + }); + 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: 'claude-sonnet-4-20250514', + api_key: 'sk-ant-test', + }); + 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: 'claude-sonnet-4-20250514', + api_key: 'sk-ant-test', + }); + builder.setProvider('fast', { + provider: 'anthropic', + model: 'claude-haiku-4-5-20251001', + api_key: 'sk-ant-test', + }); + + 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: 'claude-sonnet-4-20250514', + api_key: 'sk-test', + }); + 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.setProvider('default', { + provider: 'anthropic', + model: 'claude-sonnet-4-20250514', + api_key: 'sk-test', + }); + builder.setSandboxEnabled(true); + + const obj = builder.build(); + expect(obj.sandbox.enabled).toBe(true); + }); + + it('sets gateway auth token', () => { + const builder = new ConfigBuilder(); + builder.setProvider('default', { + provider: 'anthropic', + model: 'claude-sonnet-4-20250514', + api_key: 'sk-test', + }); + builder.setGatewayToken('my-secret-token'); + + const obj = builder.build(); + expect(obj.server.token).toBe('my-secret-token'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test:run src/cli/setup/config.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the config builder** + +```typescript +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 { + // Webhooks are enabled by adding entries; this sets up a placeholder + const automation = (this.config.automation ?? {}) as Record; + const webhooks = (automation.webhooks ?? []) as unknown[]; + // Just ensure the automation block exists + automation.webhooks = webhooks; + if (secret) { + // Store a default webhook template + automation.webhooks = [{ + name: 'default', + secret, + message: '{{body}}', + output: { channel: 'webchat', peer: 'webhook' }, + enabled: true, + }]; + } + 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 }); + } +} +``` + +**Step 4: Write the summary renderer** + +```typescript +// src/cli/setup/summary.ts + +export function renderSummary(config: Record): string { + const lines: string[] = []; + + // Models + const models = config.models ?? {}; + const defaultProvider = models.default?.provider ?? 'none'; + const tiers = ['default', 'fast', 'complex', 'local'] + .filter(t => models[t]) + .map(t => `${t}:${models[t].provider}`) + .join(', '); + lines.push(` Models: ${tiers || 'none configured'}`); + + // Channels + 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'}`); + + // Memory + const embedding = config.memory?.embedding; + const memoryStatus = embedding?.enabled + ? `vector search (${embedding.provider})` + : 'keyword search (no embeddings)'; + lines.push(` Memory: ${memoryStatus}`); + + // Automation + 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'}`); + + // Security + const secFeatures: string[] = []; + const toolProfile = config.tools?.profile ?? 'full'; + secFeatures.push(`tools:${toolProfile}`); + if (config.sandbox?.enabled) secFeatures.push('sandbox'); + if (config.pairing?.enabled) secFeatures.push('pairing'); + lines.push(` Security: ${secFeatures.join(', ')}`); + + // Gateway + 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'); +} +``` + +**Step 5: Run tests** + +Run: `pnpm test:run src/cli/setup/config.test.ts` +Expected: All 9 tests PASS + +**Step 6: Commit** + +```bash +git add src/cli/setup/config.ts src/cli/setup/config.test.ts src/cli/setup/summary.ts +git commit -m "feat(setup): add config builder and summary renderer" +``` + +--- + +### Task 3: Provider Setup Flows (`src/cli/setup/providers.ts`) + +**Files:** +- Create: `src/cli/setup/providers.ts` +- Create: `src/cli/setup/providers.test.ts` + +**Step 1: Write the failing tests** + +```typescript +import { describe, it, expect } from 'vitest'; +import { createInterface } from 'readline/promises'; +import { Readable, Writable } from 'stream'; +import { createPrompter } from './prompts.js'; +import { ConfigBuilder } from './config.js'; +import { setupProviders } from './providers.js'; + +function mockReadline(inputs: string[]) { + let idx = 0; + const input = new Readable({ + read() { + if (idx < inputs.length) { + this.push(inputs[idx++] + '\n'); + } else { + this.push(null); + } + }, + }); + const output = new Writable({ write(_, __, cb) { cb(); } }); + return createInterface({ input, output }); +} + +describe('setupProviders', () => { + it('configures anthropic as default provider', async () => { + // Choose Anthropic (1), enter API key, accept default model, decline fast tier + const rl = mockReadline(['1', 'sk-ant-test123', '', 'n']); + const p = createPrompter(rl); + const builder = new ConfigBuilder(); + + await setupProviders(p, builder); + + const config = builder.build(); + expect(config.models.default.provider).toBe('anthropic'); + expect(config.models.default.api_key).toBe('sk-ant-test123'); + expect(config.models.default.model).toBe('claude-sonnet-4-20250514'); + }); + + it('configures ollama as default provider', async () => { + // Choose Ollama (3), accept default host, accept default model, decline fast tier + const rl = mockReadline(['3', '', '', 'n']); + const p = createPrompter(rl); + const builder = new ConfigBuilder(); + + await setupProviders(p, builder); + + const config = builder.build(); + expect(config.models.default.provider).toBe('ollama'); + expect(config.models.default.endpoint).toBe('http://localhost:11434'); + }); + + it('configures anthropic with fast tier', async () => { + // Choose Anthropic (1), enter API key, accept default model, accept fast tier, accept fast model + const rl = mockReadline(['1', 'sk-ant-test123', '', 'y', '']); + const p = createPrompter(rl); + const builder = new ConfigBuilder(); + + await setupProviders(p, builder); + + const config = builder.build(); + expect(config.models.default.provider).toBe('anthropic'); + expect(config.models.fast).toBeDefined(); + expect(config.models.fast.provider).toBe('anthropic'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test:run src/cli/setup/providers.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the implementation** + +```typescript +import type { Prompter } from './prompts.js'; +import type { ConfigBuilder } from './config.js'; + +interface ProviderDef { + name: string; + provider: string; + defaultModel: string; + fastModel?: string; + needsApiKey: boolean; + needsEndpoint: boolean; + defaultEndpoint?: string; + apiKeyLabel?: string; +} + +const TOP_TIER: ProviderDef[] = [ + { + name: 'Anthropic', + provider: 'anthropic', + defaultModel: 'claude-sonnet-4-20250514', + fastModel: 'claude-haiku-4-5-20251001', + needsApiKey: true, + needsEndpoint: false, + apiKeyLabel: 'Anthropic API key', + }, + { + name: 'OpenAI', + provider: 'openai', + defaultModel: 'gpt-4.1', + fastModel: 'gpt-4.1-mini', + needsApiKey: true, + needsEndpoint: false, + apiKeyLabel: 'OpenAI API key', + }, + { + name: 'Ollama (local)', + provider: 'ollama', + defaultModel: 'llama3.3', + fastModel: 'llama3.2:3b', + needsApiKey: false, + needsEndpoint: true, + defaultEndpoint: 'http://localhost:11434', + }, +]; + +const SECOND_TIER: ProviderDef[] = [ + { + name: 'Gemini', + provider: 'gemini', + defaultModel: 'gemini-2.5-flash', + fastModel: 'gemini-2.0-flash-lite', + needsApiKey: true, + needsEndpoint: false, + apiKeyLabel: 'Gemini API key', + }, + { + name: 'OpenRouter', + provider: 'openrouter', + defaultModel: 'anthropic/claude-sonnet-4', + needsApiKey: true, + needsEndpoint: false, + apiKeyLabel: 'OpenRouter API key', + }, + { + name: 'xAI (Grok)', + provider: 'xai', + defaultModel: 'grok-3', + fastModel: 'grok-3-mini', + needsApiKey: true, + needsEndpoint: false, + apiKeyLabel: 'xAI API key', + }, + { + name: 'Amazon Bedrock', + provider: 'bedrock', + defaultModel: 'anthropic.claude-sonnet-4-20250514-v1:0', + needsApiKey: false, + needsEndpoint: false, + }, + { + name: 'GitHub Models', + provider: 'github', + defaultModel: 'claude-sonnet-4-20250514', + needsApiKey: false, + needsEndpoint: false, + }, +]; + +async function configureProvider(p: Prompter, def: ProviderDef): Promise<{ + provider: string; + model: string; + api_key?: string; + endpoint?: string; +}> { + const config: Record = { + provider: def.provider, + }; + + if (def.needsApiKey) { + config.api_key = await p.password(def.apiKeyLabel ?? 'API key'); + } + + if (def.needsEndpoint) { + config.endpoint = await p.ask('Host', def.defaultEndpoint); + } + + config.model = await p.ask('Model', def.defaultModel); + + return config as { provider: string; model: string; api_key?: string; endpoint?: string }; +} + +export async function setupProviders(p: Prompter, builder: ConfigBuilder): Promise { + const allOptions = [ + ...TOP_TIER.map(d => ({ label: d.name, value: d })), + { label: 'More providers...', value: null as ProviderDef | null }, + ]; + + let chosen: ProviderDef; + const selection = await p.choose('Model provider:', allOptions); + + if (selection === null) { + // Show second tier + const secondOptions = SECOND_TIER.map(d => ({ label: d.name, value: d })); + chosen = await p.choose('Model provider:', secondOptions); + } else { + chosen = selection; + } + + p.println(); + const cfg = await configureProvider(p, chosen); + builder.setProvider('default', cfg); + + // Offer fast tier + if (chosen.fastModel) { + p.println(); + const wantFast = await p.confirm('Configure a fast tier for compaction/delegation?', false); + if (wantFast) { + const fastModel = await p.ask('Fast model', chosen.fastModel); + builder.setProvider('fast', { + ...cfg, + model: fastModel, + }); + } + } +} +``` + +**Step 4: Run tests** + +Run: `pnpm test:run src/cli/setup/providers.test.ts` +Expected: All 3 tests PASS + +**Step 5: Commit** + +```bash +git add src/cli/setup/providers.ts src/cli/setup/providers.test.ts +git commit -m "feat(setup): add model provider setup flows" +``` + +--- + +### Task 4: Channel Setup Flows (`src/cli/setup/channels.ts`) + +**Files:** +- Create: `src/cli/setup/channels.ts` +- Create: `src/cli/setup/channels.test.ts` + +**Step 1: Write the failing tests** + +```typescript +import { describe, it, expect } from 'vitest'; +import { createInterface } from 'readline/promises'; +import { Readable, Writable } from 'stream'; +import { createPrompter } from './prompts.js'; +import { ConfigBuilder } from './config.js'; +import { setupChannels } from './channels.js'; + +function mockReadline(inputs: string[]) { + let idx = 0; + const input = new Readable({ + read() { + if (idx < inputs.length) { + this.push(inputs[idx++] + '\n'); + } else { + this.push(null); + } + }, + }); + const output = new Writable({ write(_, __, cb) { cb(); } }); + return createInterface({ input, output }); +} + +describe('setupChannels', () => { + it('configures webchat only (default)', async () => { + // Accept default port, decline adding more channels + const rl = mockReadline(['', 'n']); + const p = createPrompter(rl); + const builder = new ConfigBuilder(); + builder.setProvider('default', { provider: 'anthropic', model: 'test', api_key: 'k' }); + + await setupChannels(p, builder); + + const config = builder.build(); + expect(config.server.port).toBeDefined(); + expect(config.telegram).toBeUndefined(); + }); + + it('configures telegram channel', async () => { + // Accept default port, add channel (y), pick telegram (1), enter token + ids, decline more + const rl = mockReadline(['', 'y', '1', '123:ABC', '12345, 67890', 'n']); + const p = createPrompter(rl); + const builder = new ConfigBuilder(); + builder.setProvider('default', { provider: 'anthropic', model: 'test', api_key: 'k' }); + + await setupChannels(p, builder); + + const config = builder.build(); + expect(config.telegram.bot_token).toBe('123:ABC'); + expect(config.telegram.allowed_chat_ids).toEqual([12345, 67890]); + }); + + it('configures discord channel', async () => { + // Accept port, add channel (y), pick discord (2), enter token + guild ids, decline more + const rl = mockReadline(['', 'y', '2', 'MTIz.token', 'guild1, guild2', 'n']); + const p = createPrompter(rl); + const builder = new ConfigBuilder(); + builder.setProvider('default', { provider: 'anthropic', model: 'test', api_key: 'k' }); + + await setupChannels(p, builder); + + const config = builder.build(); + expect(config.discord.bot_token).toBe('MTIz.token'); + expect(config.discord.allowed_guild_ids).toEqual(['guild1', 'guild2']); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test:run src/cli/setup/channels.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the implementation** + +```typescript +import type { Prompter } from './prompts.js'; +import type { ConfigBuilder } from './config.js'; + +async function setupTelegram(p: Prompter, builder: ConfigBuilder): Promise { + const botToken = await p.password('Bot token (from @BotFather)'); + const chatIdsRaw = await p.ask('Allowed chat IDs (comma-separated)'); + const chatIds = chatIdsRaw + .split(',') + .map(s => parseInt(s.trim(), 10)) + .filter(n => !isNaN(n)); + + if (chatIds.length === 0) { + p.println('No valid chat IDs entered. Skipping Telegram.'); + return; + } + + builder.setTelegram(botToken, chatIds); + p.println('✓ Telegram configured'); +} + +async function setupDiscord(p: Prompter, builder: ConfigBuilder): Promise { + const botToken = await p.password('Bot token'); + const guildIdsRaw = await p.ask('Allowed guild IDs (comma-separated, or * for all)'); + const guildIds = guildIdsRaw === '*' ? [] : guildIdsRaw.split(',').map(s => s.trim()).filter(Boolean); + + builder.setDiscord(botToken, guildIds); + p.println('✓ Discord configured'); +} + +async function setupSlack(p: Prompter, builder: ConfigBuilder): Promise { + const botToken = await p.password('Bot token (xoxb-...)'); + const appToken = await p.password('App token (xapp-...)'); + const signingSecret = await p.password('Signing secret'); + const channelIdsRaw = await p.ask('Allowed channel IDs (comma-separated, or * for all)'); + const channelIds = channelIdsRaw === '*' ? [] : channelIdsRaw.split(',').map(s => s.trim()).filter(Boolean); + + builder.setSlack(botToken, appToken, signingSecret, channelIds); + p.println('✓ Slack configured'); +} + +async function setupWhatsApp(p: Prompter, builder: ConfigBuilder): Promise { + p.println('⚠ WhatsApp requires QR code authentication on first connect.'); + p.println(' It will appear in the terminal when Flynn starts.'); + const numbersRaw = await p.ask('Allowed phone numbers (comma-separated, or * for all)'); + const numbers = numbersRaw === '*' ? [] : numbersRaw.split(',').map(s => s.trim()).filter(Boolean); + + builder.setWhatsApp(numbers); + p.println('✓ WhatsApp configured'); +} + +const CHANNEL_OPTIONS = [ + { label: 'Telegram', value: 'telegram' as const }, + { label: 'Discord', value: 'discord' as const }, + { label: 'More channels...', value: 'more' as const }, +]; + +const MORE_CHANNEL_OPTIONS = [ + { label: 'Slack', value: 'slack' as const }, + { label: 'WhatsApp', value: 'whatsapp' as const }, +]; + +const CHANNEL_SETUP: Record Promise> = { + telegram: setupTelegram, + discord: setupDiscord, + slack: setupSlack, + whatsapp: setupWhatsApp, +}; + +export async function setupChannels(p: Prompter, builder: ConfigBuilder): Promise { + p.println(); + p.println('WebChat is enabled by default via the gateway.'); + const port = await p.ask('Gateway port', '18800'); + builder.setGatewayPort(parseInt(port, 10) || 18800); + p.println('✓ WebChat enabled — visit http://localhost:' + port + ' after starting'); + + let addMore = await p.confirm('Add a messaging channel?', false); + + while (addMore) { + p.println(); + const choice = await p.choose('Channel:', CHANNEL_OPTIONS); + + if (choice === 'more') { + const moreChoice = await p.choose('Channel:', MORE_CHANNEL_OPTIONS); + const setup = CHANNEL_SETUP[moreChoice]; + if (setup) await setup(p, builder); + } else { + const setup = CHANNEL_SETUP[choice]; + if (setup) await setup(p, builder); + } + + p.println(); + addMore = await p.confirm('Add another channel?', false); + } +} +``` + +**Step 4: Run tests** + +Run: `pnpm test:run src/cli/setup/channels.test.ts` +Expected: All 3 tests PASS + +**Step 5: Commit** + +```bash +git add src/cli/setup/channels.ts src/cli/setup/channels.test.ts +git commit -m "feat(setup): add channel setup flows" +``` + +--- + +### Task 5: Menu Section Flows (Memory, Automation, Security, Gateway) + +**Files:** +- Create: `src/cli/setup/memory.ts` +- Create: `src/cli/setup/automation.ts` +- Create: `src/cli/setup/security.ts` +- Create: `src/cli/setup/gateway.ts` +- Create: `src/cli/setup/sections.test.ts` + +**Step 1: Write the failing tests** + +```typescript +import { describe, it, expect } from 'vitest'; +import { createInterface } from 'readline/promises'; +import { Readable, Writable } from 'stream'; +import { createPrompter } from './prompts.js'; +import { ConfigBuilder } from './config.js'; +import { setupMemory } from './memory.js'; +import { setupSecurity } from './security.js'; +import { setupGateway } from './gateway.js'; + +function mockReadline(inputs: string[]) { + let idx = 0; + const input = new Readable({ + read() { + if (idx < inputs.length) { + this.push(inputs[idx++] + '\n'); + } else { + this.push(null); + } + }, + }); + const output = new Writable({ write(_, __, cb) { cb(); } }); + return createInterface({ input, output }); +} + +describe('setupMemory', () => { + it('enables vector search with openai embeddings', async () => { + // Enable vector search (y), pick OpenAI (1), reuse API key (y) + const rl = mockReadline(['y', '1', 'y']); + const p = createPrompter(rl); + const builder = new ConfigBuilder(); + builder.setProvider('default', { provider: 'openai', model: 'gpt-4.1', api_key: 'sk-test' }); + + await setupMemory(p, builder); + + const config = builder.build(); + expect(config.memory.embedding.enabled).toBe(true); + expect(config.memory.embedding.provider).toBe('openai'); + }); + + it('skips vector search when declined', async () => { + const rl = mockReadline(['n']); + const p = createPrompter(rl); + const builder = new ConfigBuilder(); + builder.setProvider('default', { provider: 'anthropic', model: 'test', api_key: 'k' }); + + await setupMemory(p, builder); + + const config = builder.build(); + expect(config.memory?.embedding?.enabled).toBeFalsy(); + }); +}); + +describe('setupSecurity', () => { + it('enables sandbox and pairing', async () => { + // Enable sandbox (y), enable pairing (y), accept default tool profile + const rl = mockReadline(['y', 'y', '']); + const p = createPrompter(rl); + const builder = new ConfigBuilder(); + + await setupSecurity(p, builder); + + const config = builder.build(); + expect(config.sandbox.enabled).toBe(true); + expect(config.pairing.enabled).toBe(true); + }); +}); + +describe('setupGateway', () => { + it('configures port and auth token', async () => { + // Port (9999), set auth token (y), enter token, tailscale (n), lock (n) + const rl = mockReadline(['9999', 'y', 'my-token', 'n', 'n']); + const p = createPrompter(rl); + const builder = new ConfigBuilder(); + + await setupGateway(p, builder); + + const config = builder.build(); + expect(config.server.port).toBe(9999); + expect(config.server.token).toBe('my-token'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test:run src/cli/setup/sections.test.ts` +Expected: FAIL — modules not found + +**Step 3: Write memory.ts** + +```typescript +import type { Prompter } from './prompts.js'; +import type { ConfigBuilder } from './config.js'; + +const EMBEDDING_PROVIDERS = [ + { label: 'OpenAI (recommended)', value: 'openai' }, + { label: 'Gemini', value: 'gemini' }, + { label: 'Ollama (local)', value: 'ollama' }, + { label: 'Voyage AI', value: 'voyage' }, +]; + +// Providers that can share API keys with model providers +const REUSABLE_PROVIDERS = ['openai', 'gemini']; + +export async function setupMemory(p: Prompter, builder: ConfigBuilder): Promise { + const enable = await p.confirm('Enable vector search for semantic memory?', false); + if (!enable) return; + + const provider = await p.choose('Embedding provider:', EMBEDDING_PROVIDERS); + + const config = builder.build(); + const modelApiKey = findReusableApiKey(config, provider); + + let apiKey: string | undefined; + if (REUSABLE_PROVIDERS.includes(provider) && modelApiKey) { + const reuse = await p.confirm('Reuse API key from model provider?', true); + apiKey = reuse ? modelApiKey : await p.password('API key'); + } else if (provider !== 'ollama') { + apiKey = await p.password('API key'); + } + + builder.setMemoryEmbedding({ + provider, + api_key: apiKey, + endpoint: provider === 'ollama' ? 'http://localhost:11434' : undefined, + }); + p.println('✓ Vector search enabled'); +} + +function findReusableApiKey(config: Record, embeddingProvider: string): string | undefined { + const models = config.models ?? {}; + for (const tier of ['default', 'fast', 'complex', 'local']) { + const m = models[tier]; + if (m?.provider === embeddingProvider && m?.api_key) return m.api_key; + } + return undefined; +} +``` + +**Step 4: Write automation.ts** + +```typescript +import type { Prompter } from './prompts.js'; +import type { ConfigBuilder } from './config.js'; + +export async function setupAutomation(p: Prompter, builder: ConfigBuilder): Promise { + const cron = await p.confirm('Enable cron scheduler?', false); + if (cron) { + builder.setCronEnabled(); + p.println('✓ Cron enabled — add jobs to config.yaml later'); + } + + const webhooks = await p.confirm('Enable webhook receiver?', false); + if (webhooks) { + const secret = await p.ask('Webhook shared secret (optional)', ''); + builder.setWebhooksEnabled(secret || undefined); + p.println('✓ Webhooks enabled'); + } + + const gmail = await p.confirm('Enable Gmail watcher?', false); + if (gmail) { + const creds = await p.ask('OAuth credentials file', '~/.config/flynn/gmail-credentials.json'); + builder.setGmailEnabled(creds, 'webchat', 'gmail'); + p.println('✓ Gmail watcher enabled'); + } +} +``` + +**Step 5: Write security.ts** + +```typescript +import type { Prompter } from './prompts.js'; +import type { ConfigBuilder } from './config.js'; + +const TOOL_PROFILES = [ + { label: 'full (unrestricted)', value: 'full' }, + { label: 'coding (fs + runtime + sessions + memory)', value: 'coding' }, + { label: 'messaging (send only)', value: 'messaging' }, + { label: 'minimal (status only)', value: 'minimal' }, +]; + +export async function setupSecurity(p: Prompter, builder: ConfigBuilder): Promise { + const sandbox = await p.confirm('Enable Docker sandboxing?', false); + if (sandbox) { + builder.setSandboxEnabled(true); + p.println('✓ Docker sandboxing enabled'); + } + + const pairing = await p.confirm('Enable DM pairing for unknown senders?', false); + if (pairing) { + builder.setPairingEnabled(true); + p.println('✓ DM pairing enabled'); + } + + const profile = await p.choose('Tool policy profile:', TOOL_PROFILES); + builder.setToolProfile(profile); +} +``` + +**Step 6: Write gateway.ts** + +```typescript +import type { Prompter } from './prompts.js'; +import type { ConfigBuilder } from './config.js'; + +export async function setupGateway(p: Prompter, builder: ConfigBuilder): Promise { + const port = await p.ask('Gateway port', '18800'); + builder.setGatewayPort(parseInt(port, 10) || 18800); + + const wantAuth = await p.confirm('Set auth token?', false); + if (wantAuth) { + const token = await p.password('Auth token'); + builder.setGatewayToken(token); + p.println('✓ Gateway auth token set'); + } + + const tailscale = await p.confirm('Enable Tailscale Serve?', false); + if (tailscale) { + builder.setTailscaleServe(true); + p.println('✓ Tailscale Serve enabled'); + } + + const lock = await p.confirm('Enable gateway lock (single client)?', false); + if (lock) { + builder.setGatewayLock(true); + p.println('✓ Gateway lock enabled'); + } +} +``` + +**Step 7: Run tests** + +Run: `pnpm test:run src/cli/setup/sections.test.ts` +Expected: All 4 tests PASS + +**Step 8: Commit** + +```bash +git add src/cli/setup/memory.ts src/cli/setup/automation.ts src/cli/setup/security.ts src/cli/setup/gateway.ts src/cli/setup/sections.test.ts +git commit -m "feat(setup): add memory, automation, security, and gateway setup flows" +``` + +--- + +### Task 6: Main Orchestrator + Menu (`src/cli/setup.ts`) + +**Files:** +- Create: `src/cli/setup.ts` +- Create: `src/cli/setup/index.ts` (re-exports) +- Create: `src/cli/setup/orchestrator.ts` +- Create: `src/cli/setup/orchestrator.test.ts` + +**Step 1: Write the failing tests** + +```typescript +import { describe, it, expect } from 'vitest'; +import { createInterface } from 'readline/promises'; +import { Readable, Writable } from 'stream'; +import { createPrompter } from './prompts.js'; +import { runMenu } from './orchestrator.js'; +import { ConfigBuilder } from './config.js'; + +function mockReadline(inputs: string[]) { + let idx = 0; + const input = new Readable({ + read() { + if (idx < inputs.length) { + this.push(inputs[idx++] + '\n'); + } else { + this.push(null); + } + }, + }); + const output = new Writable({ write(_, __, cb) { cb(); } }); + return createInterface({ input, output }); +} + +describe('runMenu', () => { + it('exits immediately on 0', async () => { + const rl = mockReadline(['0']); + const p = createPrompter(rl); + const builder = new ConfigBuilder(); + builder.setProvider('default', { provider: 'anthropic', model: 'test', api_key: 'k' }); + + await runMenu(p, builder); + // Should return without error + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test:run src/cli/setup/orchestrator.test.ts` +Expected: FAIL — module not found + +**Step 3: Write orchestrator.ts** + +```typescript +import type { Prompter } from './prompts.js'; +import type { ConfigBuilder } from './config.js'; +import { renderSummary } from './summary.js'; +import { setupProviders } from './providers.js'; +import { setupChannels } from './channels.js'; +import { setupMemory } from './memory.js'; +import { setupAutomation } from './automation.js'; +import { setupSecurity } from './security.js'; +import { setupGateway } from './gateway.js'; + +const MENU_OPTIONS = [ + { label: 'Model Providers', value: 'providers' }, + { label: 'Channels', value: 'channels' }, + { label: 'Memory', value: 'memory' }, + { label: 'Automation', value: 'automation' }, + { label: 'Security', value: 'security' }, + { label: 'Gateway', value: 'gateway' }, +]; + +const SECTION_HANDLERS: Record Promise> = { + providers: setupProviders, + channels: setupChannels, + memory: setupMemory, + automation: setupAutomation, + security: setupSecurity, + gateway: setupGateway, +}; + +export async function runMenu(p: Prompter, builder: ConfigBuilder): Promise { + while (true) { + p.println(); + p.println('Flynn Setup — Current Configuration'); + p.println(renderSummary(builder.build())); + p.println(); + p.println('What would you like to configure?'); + for (let i = 0; i < MENU_OPTIONS.length; i++) { + p.println(` ${i + 1}. ${MENU_OPTIONS[i].label}`); + } + p.println(' 0. Done — save and exit'); + + const answer = await p.ask('>', '0'); + const idx = parseInt(answer, 10); + + if (idx === 0 || isNaN(idx)) break; + if (idx >= 1 && idx <= MENU_OPTIONS.length) { + const section = MENU_OPTIONS[idx - 1].value; + const handler = SECTION_HANDLERS[section]; + if (handler) { + p.println(); + await handler(p, builder); + } + } + } +} + +export async function runFirstRunWizard(p: Prompter): Promise { + p.println(); + p.println("Let's get Flynn running. This takes about 2 minutes."); + p.println(); + + const builder = new ConfigBuilder(); + + // Step 1: Model provider + await setupProviders(p, builder); + + // Step 2: Channels + p.println(); + await setupChannels(p, builder); + + return builder; +} +``` + +**Step 4: Write the CLI command (src/cli/setup.ts)** + +```typescript +import type { Command } from 'commander'; +import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs'; +import { dirname } from 'path'; +import { createInterface } from 'readline/promises'; +import { parse } from 'yaml'; +import { getConfigPath } from './shared.js'; +import { createPrompter } from './setup/prompts.js'; +import { ConfigBuilder } from './setup/config.js'; +import { runFirstRunWizard, runMenu } from './setup/orchestrator.js'; + +export async function runSetup(configPath: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const p = createPrompter(rl); + + try { + if (existsSync(configPath)) { + // Existing config → menu mode + const raw = readFileSync(configPath, 'utf-8'); + const parsed = parse(raw) ?? {}; + const builder = ConfigBuilder.fromObject(parsed); + await runMenu(p, builder); + saveConfig(configPath, builder, p); + } else { + // No config → first-run wizard + const builder = await runFirstRunWizard(p); + saveConfig(configPath, builder, p); + + const shouldStart = await p.confirm('Start Flynn now?', true); + if (shouldStart) { + rl.close(); + const { startDaemon } = await import('../daemon/index.js'); + const { loadConfig } = await import('../config/index.js'); + const config = loadConfig(configPath); + const daemon = await startDaemon(config); + await new Promise(resolve => daemon.lifecycle.onShutdown(async () => resolve())); + return; + } + + const wantMore = await p.confirm('Configure more features?', false); + if (wantMore) { + const raw = readFileSync(configPath, 'utf-8'); + const parsed = parse(raw) ?? {}; + const menuBuilder = ConfigBuilder.fromObject(parsed); + await runMenu(p, menuBuilder); + saveConfig(configPath, menuBuilder, p); + } + } + } finally { + rl.close(); + } +} + +function saveConfig(configPath: string, builder: ConfigBuilder, p: Prompter): void { + const dir = dirname(configPath); + mkdirSync(dir, { recursive: true }); + writeFileSync(configPath, builder.toYaml(), 'utf-8'); + p.println(); + p.println(`✓ Config saved to ${configPath}`); +} + +export function registerSetupCommand(program: Command): void { + program + .command('setup') + .description('Interactive setup wizard') + .option('-c, --config ', 'Config file path') + .action(async (opts: { config?: string }) => { + const configPath = opts.config ?? getConfigPath(); + await runSetup(configPath); + }); +} +``` + +**Step 5: Run tests** + +Run: `pnpm test:run src/cli/setup/orchestrator.test.ts` +Expected: PASS + +**Step 6: Commit** + +```bash +git add src/cli/setup.ts src/cli/setup/orchestrator.ts src/cli/setup/orchestrator.test.ts +git commit -m "feat(setup): add main orchestrator, menu, and CLI command" +``` + +--- + +### Task 7: Wire Into CLI + Start Command Integration + +**Files:** +- Modify: `src/cli/index.ts` — register `setup` command +- Modify: `src/cli/start.ts` — offer wizard when no config found +- Modify: `src/config/schema.ts` — make `telegram` optional + +**Step 1: Make telegram optional in schema** + +In `src/config/schema.ts`, line 352, change: +```typescript +telegram: telegramSchema, +``` +to: +```typescript +telegram: telegramSchema.optional(), +``` + +**Step 2: Guard telegram usage in start.ts** + +In `src/cli/start.ts`, replace lines 13-16 with: +```typescript + if (!existsSync(configPath)) { + // Offer setup wizard + const { createInterface } = await import('readline/promises'); + const { createPrompter } = await import('./setup/prompts.js'); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const p = createPrompter(rl); + const runWizard = await p.confirm( + 'No configuration found. Would you like to run the setup wizard?', + true, + ); + rl.close(); + + if (runWizard) { + const { runSetup } = await import('./setup.js'); + await runSetup(configPath); + return; + } + + console.error(`Config file not found: ${configPath}`); + console.error('Run "flynn setup" to create one, or "flynn doctor" to diagnose.'); + process.exit(1); + } +``` + +Remove the hard-coded telegram log line (line 32): +```typescript + console.log(`Allowed Telegram chat IDs: ${config.telegram.allowed_chat_ids.join(', ')}`); +``` +Replace with: +```typescript + if (config.telegram) { + console.log(`Allowed Telegram chat IDs: ${config.telegram.allowed_chat_ids.join(', ')}`); + } +``` + +**Step 3: Register setup command in index.ts** + +Add import: +```typescript +import { registerSetupCommand } from './setup.js'; +``` + +Add registration after `registerCompletionCommand(program)`: +```typescript +registerSetupCommand(program); +``` + +**Step 4: Guard telegram in daemon/index.ts** + +Find where the Telegram adapter is created and wrap with `if (config.telegram)`. + +**Step 5: Run full test suite** + +Run: `pnpm test:run` +Expected: All existing tests still pass. No regressions from making telegram optional. + +**Step 6: Run typecheck** + +Run: `pnpm typecheck` +Expected: No errors + +**Step 7: Commit** + +```bash +git add src/cli/index.ts src/cli/start.ts src/config/schema.ts src/daemon/index.ts +git commit -m "feat(setup): wire setup command into CLI and make telegram optional" +``` + +--- + +### Task 8: Integration Test + Final Polish + +**Files:** +- Create: `src/cli/setup/integration.test.ts` +- Modify: `src/cli/completion.ts` — add `setup` to SUBCOMMANDS list + +**Step 1: Write integration test** + +```typescript +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createInterface } from 'readline/promises'; +import { Readable, Writable } from 'stream'; +import { createPrompter } from './prompts.js'; +import { runFirstRunWizard } from './orchestrator.js'; +import { parse } from 'yaml'; + +function mockReadline(inputs: string[]) { + let idx = 0; + const input = new Readable({ + read() { + if (idx < inputs.length) { + this.push(inputs[idx++] + '\n'); + } else { + this.push(null); + } + }, + }); + const output = new Writable({ write(_, __, cb) { cb(); } }); + return createInterface({ input, output }); +} + +describe('first-run wizard integration', () => { + it('produces valid config with anthropic + webchat only', async () => { + // Provider: Anthropic (1), API key, accept default model, decline fast tier + // Channels: accept default port, decline extra channels + const rl = mockReadline([ + '1', // Anthropic + 'sk-ant-key', // API key + '', // default model + 'n', // no fast tier + '', // default port + 'n', // no extra channels + ]); + const p = createPrompter(rl); + + const builder = await runFirstRunWizard(p); + const config = builder.build(); + const yaml = builder.toYaml(); + + // Verify structure + expect(config.models.default.provider).toBe('anthropic'); + expect(config.models.default.api_key).toBe('sk-ant-key'); + expect(config.server.port).toBeDefined(); + + // Verify YAML round-trips + const reparsed = parse(yaml); + expect(reparsed.models.default.provider).toBe('anthropic'); + }); + + it('produces valid config with ollama + telegram', async () => { + // Provider: Ollama (3), accept defaults, decline fast tier + // Channels: default port, add channel (y), telegram (1), token, chat IDs, no more + const rl = mockReadline([ + '3', // Ollama + '', // default host + '', // default model + 'n', // no fast tier + '', // default port + 'y', // add channel + '1', // Telegram + '123:ABCdef', // bot token + '12345678', // chat IDs + 'n', // no more channels + ]); + const p = createPrompter(rl); + + const builder = await runFirstRunWizard(p); + const config = builder.build(); + + expect(config.models.default.provider).toBe('ollama'); + expect(config.telegram.bot_token).toBe('123:ABCdef'); + expect(config.telegram.allowed_chat_ids).toEqual([12345678]); + }); +}); +``` + +**Step 2: Add `setup` to shell completion** + +In `src/cli/completion.ts`: +- Add `'setup'` to the `SUBCOMMANDS` array +- Add `setup: ['-c', '--config']` to `SUBCOMMAND_OPTIONS` + +**Step 3: Run all tests** + +Run: `pnpm test:run` +Expected: All tests pass including new integration tests + +**Step 4: Run typecheck and lint** + +Run: `pnpm typecheck && pnpm lint` +Expected: Clean + +**Step 5: Commit** + +```bash +git add src/cli/setup/integration.test.ts src/cli/completion.ts +git commit -m "test(setup): add integration tests and update shell completion" +``` + +--- + +### Task 9: Update State + Gap Analysis + +**Files:** +- Modify: `docs/plans/2026-02-06-openclaw-feature-gap-analysis.md` — mark "onboard wizard" as MATCH +- Modify: `docs/plans/state.json` — add setup wizard plan entry, update test count and scorecard + +**Step 1: Update gap analysis** + +In the Gateway/Infra table, change: +``` +| `onboard` wizard | Full guided setup | -- | **MISSING** | +``` +to: +``` +| `onboard` wizard | Full guided setup | Full (`flynn setup` + first-run auto-trigger) | **MATCH** | +``` + +Update scorecard: Gateway/Infra goes from 7 match → 8 match, 6 missing → 5 missing. Total: 99 → 100 match, 29 → 28 missing. + +**Step 2: Update state.json** + +Add plan entry for setup wizard. Update test count and feature gap scorecard. + +**Step 3: Commit** + +```bash +git add docs/plans/2026-02-06-openclaw-feature-gap-analysis.md docs/plans/state.json +git commit -m "docs: update gap analysis and state for setup wizard" +``` + +--- + +## Summary + +| Task | Description | New Tests | +|------|-------------|-----------| +| Prereq | Make telegram optional in config schema | 0 (existing tests verify) | +| 1 | Prompt helpers (ask/confirm/choose/password) | ~7 | +| 2 | Config builder + summary renderer | ~9 | +| 3 | Provider setup flows | ~3 | +| 4 | Channel setup flows | ~3 | +| 5 | Menu section flows (memory/automation/security/gateway) | ~4 | +| 6 | Main orchestrator + menu | ~1 | +| 7 | CLI wiring + start command integration | 0 (existing test suite) | +| 8 | Integration tests + polish | ~2 | +| 9 | Docs/state update | 0 | +| **Total** | | **~29 new tests** | + +**Files created:** 13 +**Files modified:** ~7 +**New dependencies:** 0 (uses existing `yaml` package + Node built-in `readline/promises`)