Files
flynn/docs/plans/2026-02-10-setup-wizard-implementation.md
William Valentin 48fab11066 docs: add setup wizard implementation plan
9 tasks with TDD approach: prompt helpers, config builder, provider/channel
flows, menu sections, orchestrator, CLI wiring, integration tests. ~29 new
tests, 13 new files, 0 new dependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:19:21 -08:00

53 KiB

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

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

import type { Interface as ReadlineInterface } from 'readline/promises';

export interface ChoiceOption<T = string> {
  label: string;
  value: T;
}

export interface Prompter {
  ask(question: string, defaultValue?: string): Promise<string>;
  confirm(question: string, defaultYes?: boolean): Promise<boolean>;
  choose<T = string>(question: string, options: ChoiceOption<T>[]): Promise<T>;
  password(question: string): Promise<string>;
  println(msg?: string): void;
}

export function createPrompter(rl: ReadlineInterface): Prompter {
  return {
    async ask(question: string, defaultValue?: string): Promise<string> {
      const suffix = defaultValue !== undefined ? ` [${defaultValue}]` : '';
      const answer = await rl.question(`${question}${suffix}: `);
      return answer.trim() || defaultValue || '';
    },

    async confirm(question: string, defaultYes = true): Promise<boolean> {
      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<T = string>(question: string, options: ChoiceOption<T>[]): Promise<T> {
      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<string> {
      // 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

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

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

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<string, unknown>;

  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<string, unknown>): 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<string, unknown>;
    const entry: Record<string, unknown> = { 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<string, unknown>;
    server.port = port;
    this.config.server = server;
  }

  setGatewayToken(token: string): void {
    const server = (this.config.server ?? {}) as Record<string, unknown>;
    server.token = token;
    this.config.server = server;
  }

  setGatewayLock(enabled: boolean): void {
    const server = (this.config.server ?? {}) as Record<string, unknown>;
    server.lock = enabled;
    this.config.server = server;
  }

  setTailscaleServe(enabled: boolean): void {
    const server = (this.config.server ?? {}) as Record<string, unknown>;
    const tailscale = (server.tailscale ?? {}) as Record<string, unknown>;
    tailscale.serve = enabled;
    server.tailscale = tailscale;
    this.config.server = server;
  }

  setMemoryEmbedding(cfg: EmbeddingConfig): void {
    const memory = (this.config.memory ?? {}) as Record<string, unknown>;
    const embedding: Record<string, unknown> = {
      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<string, unknown>;
    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<string, unknown>;
    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<string, unknown>;
    if (!automation.cron) automation.cron = [];
    this.config.automation = automation;
  }

  build(): Record<string, any> {
    return structuredClone(this.config) as Record<string, any>;
  }

  toYaml(): string {
    return stringify(this.config, { lineWidth: 120 });
  }
}

Step 4: Write the summary renderer

// src/cli/setup/summary.ts

export function renderSummary(config: Record<string, any>): 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

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

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

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<string, string> = {
    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<void> {
  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

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

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

import type { Prompter } from './prompts.js';
import type { ConfigBuilder } from './config.js';

async function setupTelegram(p: Prompter, builder: ConfigBuilder): Promise<void> {
  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<void> {
  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<void> {
  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<void> {
  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<string, (p: Prompter, b: ConfigBuilder) => Promise<void>> = {
  telegram: setupTelegram,
  discord: setupDiscord,
  slack: setupSlack,
  whatsapp: setupWhatsApp,
};

export async function setupChannels(p: Prompter, builder: ConfigBuilder): Promise<void> {
  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

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

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

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<void> {
  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<string, any>, 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

import type { Prompter } from './prompts.js';
import type { ConfigBuilder } from './config.js';

export async function setupAutomation(p: Prompter, builder: ConfigBuilder): Promise<void> {
  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

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<void> {
  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

import type { Prompter } from './prompts.js';
import type { ConfigBuilder } from './config.js';

export async function setupGateway(p: Prompter, builder: ConfigBuilder): Promise<void> {
  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

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

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

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<string, (p: Prompter, b: ConfigBuilder) => Promise<void>> = {
  providers: setupProviders,
  channels: setupChannels,
  memory: setupMemory,
  automation: setupAutomation,
  security: setupSecurity,
  gateway: setupGateway,
};

export async function runMenu(p: Prompter, builder: ConfigBuilder): Promise<void> {
  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<ConfigBuilder> {
  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)

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<void> {
  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<void>(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 <path>', '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

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:

telegram: telegramSchema,

to:

telegram: telegramSchema.optional(),

Step 2: Guard telegram usage in start.ts

In src/cli/start.ts, replace lines 13-16 with:

      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):

      console.log(`Allowed Telegram chat IDs: ${config.telegram.allowed_chat_ids.join(', ')}`);

Replace with:

      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:

import { registerSetupCommand } from './setup.js';

Add registration after registerCompletionCommand(program):

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

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

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

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

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)