48fab11066
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>
1776 lines
53 KiB
Markdown
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`)
|