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

1776 lines
53 KiB
Markdown

# 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<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**
```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<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**
```typescript
// 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**
```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<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**
```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<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**
```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<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**
```typescript
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**
```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<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**
```typescript
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**
```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<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)**
```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<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**
```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`)