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