From 224c023028b70220a2e107b1b2b3b5bc007c9701 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 5 Feb 2026 22:10:41 -0800 Subject: [PATCH] docs: add Phase 5a design and implementation plans --- ...026-02-05-phase5-cli-cron-doctor-design.md | 230 ++ .../2026-02-05-phase5a-implementation.md | 2109 +++++++++++++++++ 2 files changed, 2339 insertions(+) create mode 100644 docs/plans/2026-02-05-phase5-cli-cron-doctor-design.md create mode 100644 docs/plans/2026-02-05-phase5a-implementation.md diff --git a/docs/plans/2026-02-05-phase5-cli-cron-doctor-design.md b/docs/plans/2026-02-05-phase5-cli-cron-doctor-design.md new file mode 100644 index 0000000..64ade6b --- /dev/null +++ b/docs/plans/2026-02-05-phase5-cli-cron-doctor-design.md @@ -0,0 +1,230 @@ +# Phase 5a Design: CLI Surface, Cron/Scheduling, Doctor Diagnostics + +## Overview + +Three independent Phase 5 features that make Flynn usable as a proper CLI tool and proactive agent. CLI provides the command interface, cron makes Flynn schedule-driven, doctor validates setup. + +**Dependencies**: Phases 1-4 complete. 298 tests passing. Typecheck clean. + +--- + +## 1. CLI Surface + +### Architecture + +Single entry point at `src/cli/index.ts` replaces both `src/index.ts` and `src/tui.ts`. Uses **commander** for subcommand routing. `package.json` gets a `bin` field pointing to `dist/cli/index.js`. + +### Commands + +| Command | Description | Notes | +|---------|-------------|-------| +| `flynn start` | Start daemon (foreground) | Delegates to `startDaemon()` | +| `flynn tui` | Launch TUI | Moves existing `src/tui.ts` logic | +| `flynn send ` | One-shot message, print response, exit | Standalone (no daemon needed) | +| `flynn sessions` | List active sessions | Reads from SessionStore directly | +| `flynn doctor` | Validate config + health | See Section 3 | +| `flynn config` | Show resolved config (redacted secrets) | Loads and displays | +| `flynn version` | Print version | From package.json | + +### File Structure + +``` +src/cli/ +├── index.ts # Commander setup, bin entry point +├── start.ts # 'flynn start' -- daemon startup +├── tui.ts # 'flynn tui' -- moves existing TUI logic +├── send.ts # 'flynn send' -- one-shot agent invocation +├── sessions.ts # 'flynn sessions' -- list/inspect sessions +├── doctor.ts # 'flynn doctor' -- diagnostics +├── config-cmd.ts # 'flynn config' -- show resolved config +└── shared.ts # Config loading, output formatting, error handling +``` + +### Key Decisions + +- **`flynn send` works without a daemon**: Creates a lightweight agent inline (like current TUI does), processes the message, prints the response, exits. No session persistence. +- **`flynn start` replaces `src/index.ts`**: The old entry point becomes the `start` subcommand. +- **`flynn tui` replaces `src/tui.ts`**: The old TUI entry point becomes the `tui` subcommand with `--fullscreen` flag preserved. +- **`package.json` `bin` field**: `"bin": { "flynn": "dist/cli/index.js" }` enables `npx flynn` and global install. +- **Shebang**: `#!/usr/bin/env node` at top of `src/cli/index.ts`. + +### Config Loading in CLI + +`shared.ts` exports `loadConfigSafe()` which: +1. Checks `FLYNN_CONFIG` env var or `~/.config/flynn/config.yaml` +2. Returns `{ config, error }` instead of throwing +3. Used by commands that need config (start, send, tui, doctor) +4. Commands that don't need config (version) skip it + +--- + +## 2. Cron/Scheduling + +### Architecture + +`CronScheduler` implements `ChannelAdapter` from `src/channels/types.ts`. It registers with the `ChannelRegistry` like any other adapter. Each cron job gets a session ID of `cron:`. + +When a job fires: +1. Emits `InboundMessage` through the channel adapter's `onMessage` handler +2. Channel registry routes to unified message handler -> `NativeAgent` +3. Agent processes and responds +4. `CronScheduler.send()` receives the response +5. Looks up the configured output channel from the channel registry +6. Forwards the response to the output channel+peer + +### Config Schema + +```typescript +const cronJobSchema = z.object({ + name: z.string().min(1), + schedule: z.string().min(1), // Cron expression: "0 9 * * *" + message: z.string().min(1), // Message to send to agent + output: z.object({ + channel: z.string().min(1), // e.g. "telegram" + peer: z.string().min(1), // e.g. "12345" (chat ID) + }), + enabled: z.boolean().default(true), + timezone: z.string().optional(), // default UTC +}); + +// Top-level config addition: +automation: z.object({ + cron: z.array(cronJobSchema).default([]), +}).default({}) +``` + +### Example Config + +```yaml +automation: + cron: + - name: morning-briefing + schedule: "0 9 * * *" + message: "Give me a morning briefing: weather, calendar, news" + output: + channel: telegram + peer: "12345" + - name: daily-backup-check + schedule: "0 22 * * *" + message: "Check if today's backups completed successfully" + output: + channel: telegram + peer: "12345" + enabled: true + timezone: "America/New_York" +``` + +### Library + +**croner** -- lightweight, zero-dep cron scheduler for Node.js. ESM-native, timezone support, cron expression validation. + +### File Structure + +``` +src/automation/ +├── cron.ts # CronScheduler (ChannelAdapter) + CronJob management +``` + +Types are inline (no separate types.ts needed for this scope). + +### Lifecycle + +- `connect()`: Creates `Cron` instances for each enabled job +- `disconnect()`: Stops all cron instances +- Registers shutdown handler via daemon lifecycle +- Channel registry manages start/stop + +### Output Forwarding + +`CronScheduler` holds a reference to `ChannelRegistry`. When `send(peerId, message)` is called with the agent's response: +- `peerId` is the cron job name +- Looks up the job's output config +- Gets the output channel adapter from registry +- Calls `outputAdapter.send(job.output.peer, message)` +- If output channel is unavailable, logs warning (don't crash) + +--- + +## 3. Doctor Diagnostics + +### Architecture + +`flynn doctor` validates configuration and checks system health. Runs standalone (no daemon needed). Each check is independent and produces a status line. + +### Checks + +| # | Check | Pass criteria | Depends on | +|---|-------|--------------|------------| +| 1 | Config file exists | File present at expected path | Nothing | +| 2 | Config parses | Valid YAML syntax | #1 | +| 3 | Config validates | Zod schema passes | #2 | +| 4 | Env vars resolved | No unresolved `${VAR}` refs | #2 | +| 5 | Data directory writable | `~/.local/share/flynn` write test | Nothing | +| 6 | Session DB accessible | SQLite open + query | #5 | +| 7 | Model connectivity | Default model responds | #3 | +| 8 | Telegram bot valid | `getMe` API call succeeds | #3 | +| 9 | MCP servers available | Configured servers can start | #3 | +| 10 | Skills loaded | All skill dirs load without error | #3 | + +### Output Format + +``` +Flynn Doctor +============ + +[PASS] Config file exists (~/.config/flynn/config.yaml) +[PASS] Config parses (valid YAML) +[FAIL] Config validates: telegram.bot_token is required +[SKIP] Env vars resolved (config invalid) +[PASS] Data directory writable (~/.local/share/flynn) +[PASS] Session DB accessible (sessions.db) +[SKIP] Model connectivity (config invalid) +[SKIP] Telegram bot valid (config invalid) +[SKIP] MCP servers available (config invalid) +[SKIP] Skills loaded (config invalid) + +Results: 3 passed, 1 failed, 6 skipped +``` + +### Implementation + +```typescript +interface CheckResult { + status: 'pass' | 'fail' | 'warn' | 'skip'; + label: string; + detail?: string; +} + +type Check = (ctx: DoctorContext) => Promise; + +interface DoctorContext { + configPath: string; + config?: Config; // Set after config checks pass + dataDir: string; +} +``` + +Each check is a function. Checks that depend on config are skipped if config loading failed. Exit code 0 if all pass/warn/skip, 1 if any fail. + +### Model Connectivity Check + +Sends a minimal `chat()` request: `{ messages: [{ role: 'user', content: 'ping' }], max_tokens: 1 }`. Success = any response without error. Timeout: 10 seconds. + +--- + +## Implementation Order + +1. **CLI surface first** -- foundation for the other two +2. **Doctor diagnostics** -- uses CLI, no daemon dependency +3. **Cron/scheduling** -- needs daemon running, builds on channel system + +## New Dependencies + +- `commander` -- CLI framework (~50KB) +- `croner` -- Cron scheduler (~15KB, zero deps) + +--- + +*Design Version: 1.0* +*Created: 2026-02-05* +*Features: CLI surface, cron/scheduling, doctor diagnostics* diff --git a/docs/plans/2026-02-05-phase5a-implementation.md b/docs/plans/2026-02-05-phase5a-implementation.md new file mode 100644 index 0000000..3bded99 --- /dev/null +++ b/docs/plans/2026-02-05-phase5a-implementation.md @@ -0,0 +1,2109 @@ +# Phase 5a Implementation Plan: CLI Surface, Cron/Scheduling, Doctor Diagnostics + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a proper CLI (`flynn start`, `flynn send`, `flynn doctor`, etc.), cron-based scheduling, and a doctor diagnostics command. + +**Architecture:** Single CLI entry point using commander replaces both existing entry points. CronScheduler implements ChannelAdapter for seamless integration. Doctor runs standalone checks without needing a daemon. + +**Tech Stack:** commander (CLI), croner (cron scheduling), Vitest (testing), Zod (config validation) + +--- + +## Task 1: Install Dependencies + +**Files:** +- Modify: `package.json` + +**Step 1: Install commander and croner** + +Run: `pnpm add commander croner` + +**Step 2: Verify installation** + +Run: `pnpm vitest run` +Expected: 298 tests still passing + +**Step 3: Commit** + +```bash +git add package.json pnpm-lock.yaml +git commit -m "chore: add commander and croner dependencies" +``` + +--- + +## Task 2: Config Schema — Add Automation Section + +**Files:** +- Modify: `src/config/schema.ts:74-82` +- Test: `src/config/schema.test.ts` (create) + +**Step 1: Write the failing test** + +Create `src/config/schema.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { configSchema } from './schema.js'; + +describe('configSchema automation', () => { + const baseConfig = { + telegram: { bot_token: 'test-token', allowed_chat_ids: [123] }, + models: { default: { provider: 'anthropic', model: 'claude-sonnet' } }, + }; + + it('accepts config without automation section', () => { + const result = configSchema.parse(baseConfig); + expect(result.automation).toBeDefined(); + expect(result.automation.cron).toEqual([]); + }); + + it('accepts config with cron jobs', () => { + const result = configSchema.parse({ + ...baseConfig, + automation: { + cron: [{ + name: 'morning-briefing', + schedule: '0 9 * * *', + message: 'Good morning!', + output: { channel: 'telegram', peer: '123' }, + }], + }, + }); + expect(result.automation.cron).toHaveLength(1); + expect(result.automation.cron[0].name).toBe('morning-briefing'); + expect(result.automation.cron[0].enabled).toBe(true); // default + }); + + it('rejects cron job with empty name', () => { + expect(() => configSchema.parse({ + ...baseConfig, + automation: { + cron: [{ + name: '', + schedule: '0 9 * * *', + message: 'test', + output: { channel: 'telegram', peer: '123' }, + }], + }, + })).toThrow(); + }); + + it('rejects cron job with empty schedule', () => { + expect(() => configSchema.parse({ + ...baseConfig, + automation: { + cron: [{ + name: 'test', + schedule: '', + message: 'test', + output: { channel: 'telegram', peer: '123' }, + }], + }, + })).toThrow(); + }); + + it('accepts cron job with optional fields', () => { + const result = configSchema.parse({ + ...baseConfig, + automation: { + cron: [{ + name: 'test', + schedule: '0 9 * * *', + message: 'test', + output: { channel: 'telegram', peer: '123' }, + enabled: false, + timezone: 'America/New_York', + }], + }, + }); + expect(result.automation.cron[0].enabled).toBe(false); + expect(result.automation.cron[0].timezone).toBe('America/New_York'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/config/schema.test.ts` +Expected: FAIL — `automation` property doesn't exist on config type + +**Step 3: Write implementation** + +In `src/config/schema.ts`, add these schemas before `configSchema`: + +```typescript +const cronJobSchema = z.object({ + name: z.string().min(1, 'Cron job name is required'), + schedule: z.string().min(1, 'Cron schedule is required'), + message: z.string().min(1, 'Cron message is required'), + output: z.object({ + channel: z.string().min(1), + peer: z.string().min(1), + }), + enabled: z.boolean().default(true), + timezone: z.string().optional(), +}); + +const automationSchema = z.object({ + cron: z.array(cronJobSchema).default([]), +}).default({}); +``` + +Then add to `configSchema`: + +```typescript +export const configSchema = z.object({ + telegram: telegramSchema, + server: serverSchema.default({}), + models: modelsSchema, + backends: backendsSchema.default({}), + hooks: hooksSchema.default({}), + skills: skillsSchema.default({}), + mcp: mcpSchema.default({ servers: [] }), + automation: automationSchema, // <-- ADD THIS +}); +``` + +Export the new type: + +```typescript +export type CronJobConfig = z.infer; +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/config/schema.test.ts` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `pnpm vitest run` +Expected: All tests pass (existing tests should be unaffected — `automation` has a default) + +**Step 6: Commit** + +```bash +git add src/config/schema.ts src/config/schema.test.ts +git commit -m "feat(config): add automation.cron schema for scheduled jobs" +``` + +--- + +## Task 3: CLI Shared Utilities + +**Files:** +- Create: `src/cli/shared.ts` +- Test: `src/cli/shared.test.ts` + +**Step 1: Write the failing test** + +Create `src/cli/shared.test.ts`: + +```typescript +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { loadConfigSafe, redactSecrets, getConfigPath, getDataDir } from './shared.js'; +import { writeFileSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +describe('CLI shared utilities', () => { + const testDir = join(tmpdir(), 'flynn-test-cli-shared'); + + afterEach(() => { + try { rmSync(testDir, { recursive: true }); } catch {} + }); + + describe('getConfigPath', () => { + it('returns FLYNN_CONFIG env var if set', () => { + const original = process.env.FLYNN_CONFIG; + process.env.FLYNN_CONFIG = '/custom/path.yaml'; + expect(getConfigPath()).toBe('/custom/path.yaml'); + if (original !== undefined) { + process.env.FLYNN_CONFIG = original; + } else { + delete process.env.FLYNN_CONFIG; + } + }); + + it('returns default path if env var not set', () => { + const original = process.env.FLYNN_CONFIG; + delete process.env.FLYNN_CONFIG; + const path = getConfigPath(); + expect(path).toContain('.config/flynn/config.yaml'); + if (original !== undefined) { + process.env.FLYNN_CONFIG = original; + } + }); + }); + + describe('loadConfigSafe', () => { + it('returns config on success', () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'config.yaml'); + writeFileSync(configPath, ` +telegram: + bot_token: "test-token" + allowed_chat_ids: [123] +models: + default: + provider: anthropic + model: claude-sonnet +`); + const result = loadConfigSafe(configPath); + expect(result.config).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.config!.telegram.bot_token).toBe('test-token'); + }); + + it('returns error when file not found', () => { + const result = loadConfigSafe('/nonexistent/config.yaml'); + expect(result.config).toBeUndefined(); + expect(result.error).toBeDefined(); + }); + + it('returns error on invalid YAML', () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'bad.yaml'); + writeFileSync(configPath, '{{{{invalid yaml'); + const result = loadConfigSafe(configPath); + expect(result.config).toBeUndefined(); + expect(result.error).toBeDefined(); + }); + }); + + describe('redactSecrets', () => { + it('redacts bot_token', () => { + const config = { + telegram: { bot_token: 'secret-token-123', allowed_chat_ids: [123] }, + models: { default: { provider: 'anthropic', model: 'claude' } }, + }; + const redacted = redactSecrets(config); + expect(redacted.telegram.bot_token).toBe('***'); + expect(redacted.telegram.allowed_chat_ids).toEqual([123]); + }); + + it('redacts api_key in models', () => { + const config = { + telegram: { bot_token: 'token', allowed_chat_ids: [123] }, + models: { + default: { provider: 'anthropic', model: 'claude', api_key: 'sk-secret' }, + }, + }; + const redacted = redactSecrets(config); + expect(redacted.models.default.api_key).toBe('***'); + }); + }); + + describe('getDataDir', () => { + it('returns path under home directory', () => { + const dir = getDataDir(); + expect(dir).toContain('.local/share/flynn'); + }); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/cli/shared.test.ts` +Expected: FAIL — module not found + +**Step 3: Write implementation** + +Create `src/cli/shared.ts`: + +```typescript +import { loadConfig } from '../config/index.js'; +import type { Config } from '../config/index.js'; +import { resolve } from 'path'; +import { homedir } from 'os'; + +/** Get the config file path from env or default location. */ +export function getConfigPath(): string { + return process.env.FLYNN_CONFIG ?? resolve(homedir(), '.config/flynn/config.yaml'); +} + +/** Get the data directory path. */ +export function getDataDir(): string { + return resolve(homedir(), '.local/share/flynn'); +} + +/** Load config without throwing. Returns { config } or { error }. */ +export function loadConfigSafe(configPath?: string): { config?: Config; error?: string } { + const path = configPath ?? getConfigPath(); + try { + const config = loadConfig(path); + return { config }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { error: `Failed to load config from ${path}: ${message}` }; + } +} + +/** Deep-clone config and replace sensitive fields with '***'. */ +export function redactSecrets(config: Record): Record { + const sensitiveKeys = ['bot_token', 'api_key', 'auth_token']; + + function redact(obj: unknown): unknown { + if (obj === null || obj === undefined) return obj; + if (Array.isArray(obj)) return obj.map(redact); + if (typeof obj === 'object') { + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + if (sensitiveKeys.includes(key) && typeof value === 'string') { + result[key] = '***'; + } else { + result[key] = redact(value); + } + } + return result; + } + return obj; + } + + return redact(config) as Record; +} + +/** Format output for terminal display. */ +export function formatStatus( + status: 'pass' | 'fail' | 'warn' | 'skip', + label: string, + detail?: string, +): string { + const icons: Record = { + pass: '\x1b[32m[PASS]\x1b[0m', + fail: '\x1b[31m[FAIL]\x1b[0m', + warn: '\x1b[33m[WARN]\x1b[0m', + skip: '\x1b[2m[SKIP]\x1b[0m', + }; + const suffix = detail ? ` ${detail}` : ''; + return `${icons[status]} ${label}${suffix}`; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/cli/shared.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/cli/shared.ts src/cli/shared.test.ts +git commit -m "feat(cli): add shared utilities for config loading and output" +``` + +--- + +## Task 4: CLI Entry Point and Start Command + +**Files:** +- Create: `src/cli/index.ts` +- Create: `src/cli/start.ts` +- Modify: `package.json` (add `bin` field, update scripts) +- Test: `src/cli/index.test.ts` + +**Step 1: Write the failing test** + +Create `src/cli/index.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { createProgram } from './index.js'; + +describe('CLI program', () => { + it('creates a commander program with expected commands', () => { + const program = createProgram(); + const commandNames = program.commands.map((c) => c.name()); + + expect(commandNames).toContain('start'); + expect(commandNames).toContain('tui'); + expect(commandNames).toContain('send'); + expect(commandNames).toContain('sessions'); + expect(commandNames).toContain('doctor'); + expect(commandNames).toContain('config'); + }); + + it('has version info', () => { + const program = createProgram(); + expect(program.version()).toBeDefined(); + }); + + it('has description', () => { + const program = createProgram(); + expect(program.description()).toContain('AI'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/cli/index.test.ts` +Expected: FAIL — module not found + +**Step 3: Write the CLI entry point** + +Create `src/cli/index.ts`: + +```typescript +#!/usr/bin/env node +import { Command } from 'commander'; +import { registerStartCommand } from './start.js'; +import { registerSendCommand } from './send.js'; +import { registerSessionsCommand } from './sessions.js'; +import { registerDoctorCommand } from './doctor.js'; +import { registerConfigCommand } from './config-cmd.js'; +import { registerTuiCommand } from './tui.js'; + +export function createProgram(): Command { + const program = new Command(); + + program + .name('flynn') + .description('Flynn — self-hosted personal AI agent') + .version('0.1.0'); + + registerStartCommand(program); + registerTuiCommand(program); + registerSendCommand(program); + registerSessionsCommand(program); + registerDoctorCommand(program); + registerConfigCommand(program); + + return program; +} + +// Only run when executed directly (not imported in tests) +const isDirectRun = process.argv[1] && + (process.argv[1].endsWith('/cli/index.js') || + process.argv[1].endsWith('/cli/index.ts')); + +if (isDirectRun) { + const program = createProgram(); + program.parse(process.argv); +} +``` + +Create `src/cli/start.ts`: + +```typescript +import type { Command } from 'commander'; +import { loadConfigSafe, getConfigPath } from './shared.js'; +import { existsSync } from 'fs'; + +export function registerStartCommand(program: Command): void { + program + .command('start') + .description('Start the Flynn daemon') + .option('-c, --config ', 'Config file path') + .action(async (opts: { config?: string }) => { + const configPath = opts.config ?? getConfigPath(); + + if (!existsSync(configPath)) { + console.error(`Config file not found: ${configPath}`); + console.error('Run "flynn doctor" to diagnose, or create a config at ~/.config/flynn/config.yaml'); + process.exit(1); + } + + console.log('Flynn starting...'); + console.log(`Loading config from: ${configPath}`); + + const { config, error } = loadConfigSafe(configPath); + if (!config) { + console.error(error); + process.exit(1); + } + + // Dynamic import to avoid loading daemon code for other commands + const { startDaemon } = await import('../daemon/index.js'); + const daemon = await startDaemon(config); + + console.log(`Allowed Telegram chat IDs: ${config.telegram.allowed_chat_ids.join(', ')}`); + + // Keep process alive + await new Promise((resolve) => { + daemon.lifecycle.onShutdown(async () => resolve()); + }); + }); +} +``` + +**Step 4: Create stub files for remaining commands** + +We need stubs so the imports in `index.ts` don't fail. Create these minimal files: + +Create `src/cli/send.ts`: +```typescript +import type { Command } from 'commander'; + +export function registerSendCommand(program: Command): void { + program + .command('send ') + .description('Send a one-shot message and print the response') + .option('-c, --config ', 'Config file path') + .action(async (_message: string, _opts: { config?: string }) => { + console.error('Not yet implemented'); + process.exit(1); + }); +} +``` + +Create `src/cli/sessions.ts`: +```typescript +import type { Command } from 'commander'; + +export function registerSessionsCommand(program: Command): void { + program + .command('sessions') + .description('List active sessions') + .option('-c, --config ', 'Config file path') + .action(async (_opts: { config?: string }) => { + console.error('Not yet implemented'); + process.exit(1); + }); +} +``` + +Create `src/cli/doctor.ts`: +```typescript +import type { Command } from 'commander'; + +export function registerDoctorCommand(program: Command): void { + program + .command('doctor') + .description('Validate configuration and check system health') + .option('-c, --config ', 'Config file path') + .action(async (_opts: { config?: string }) => { + console.error('Not yet implemented'); + process.exit(1); + }); +} +``` + +Create `src/cli/config-cmd.ts`: +```typescript +import type { Command } from 'commander'; + +export function registerConfigCommand(program: Command): void { + program + .command('config') + .description('Show resolved configuration (secrets redacted)') + .option('-c, --config ', 'Config file path') + .action(async (_opts: { config?: string }) => { + console.error('Not yet implemented'); + process.exit(1); + }); +} +``` + +Create `src/cli/tui.ts`: +```typescript +import type { Command } from 'commander'; + +export function registerTuiCommand(program: Command): void { + program + .command('tui') + .description('Launch the interactive TUI') + .option('-f, --fullscreen', 'Start in fullscreen mode') + .option('-c, --config ', 'Config file path') + .action(async (_opts: { fullscreen?: boolean; config?: string }) => { + console.error('Not yet implemented'); + process.exit(1); + }); +} +``` + +**Step 5: Run test to verify it passes** + +Run: `pnpm vitest run src/cli/index.test.ts` +Expected: PASS + +**Step 6: Update package.json** + +Add `bin` field and update scripts: + +In `package.json`, add after `"main"`: +```json +"bin": { + "flynn": "dist/cli/index.js" +}, +``` + +Update scripts: +```json +"scripts": { + "build": "tsc", + "dev": "tsx watch src/cli/index.ts -- start", + "start": "node dist/cli/index.js start", + "tui": "tsx src/cli/index.ts tui", + "tui:fs": "tsx src/cli/index.ts tui --fullscreen", + "tui:dev": "tsx watch src/cli/index.ts -- tui", + "test": "vitest", + "test:run": "vitest run", + "lint": "eslint src/", + "typecheck": "tsc --noEmit" +} +``` + +**Step 7: Run full test suite and typecheck** + +Run: `pnpm vitest run && pnpm typecheck` +Expected: All tests pass, no type errors + +**Step 8: Commit** + +```bash +git add src/cli/ package.json +git commit -m "feat(cli): add CLI entry point with commander and start command" +``` + +--- + +## Task 5: CLI Send Command + +**Files:** +- Modify: `src/cli/send.ts` +- Test: `src/cli/send.test.ts` + +**Step 1: Write the failing test** + +Create `src/cli/send.test.ts`: + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import { createSendAgent } from './send.js'; + +describe('send command', () => { + it('createSendAgent creates an agent that can process a message', async () => { + // Mock model client that returns a canned response + const mockModelClient = { + chat: vi.fn().mockResolvedValue({ + content: 'Hello from Flynn!', + stopReason: 'end_turn', + usage: { inputTokens: 10, outputTokens: 5 }, + }), + }; + + const agent = createSendAgent({ + modelClient: mockModelClient, + systemPrompt: 'You are Flynn.', + }); + + const response = await agent.process('Hi there'); + expect(response).toBe('Hello from Flynn!'); + expect(mockModelClient.chat).toHaveBeenCalledOnce(); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/cli/send.test.ts` +Expected: FAIL — `createSendAgent` not exported + +**Step 3: Implement send command** + +Replace `src/cli/send.ts`: + +```typescript +import type { Command } from 'commander'; +import type { ModelClient } from '../models/types.js'; +import { NativeAgent } from '../backends/index.js'; +import { ToolRegistry, ToolExecutor, allBuiltinTools } from '../tools/index.js'; +import { HookEngine } from '../hooks/index.js'; +import { loadConfigSafe, getConfigPath } from './shared.js'; +import { existsSync, readFileSync } from 'fs'; +import { resolve } from 'path'; + +/** Create a lightweight agent for one-shot message processing. */ +export function createSendAgent(deps: { + modelClient: ModelClient; + systemPrompt: string; + enableTools?: boolean; +}): NativeAgent { + const config: ConstructorParameters[0] = { + modelClient: deps.modelClient, + systemPrompt: deps.systemPrompt, + }; + + if (deps.enableTools !== false) { + const hookEngine = new HookEngine({ confirm: [], log: [], silent: [] }); + const toolRegistry = new ToolRegistry(); + for (const tool of allBuiltinTools) { + toolRegistry.register(tool); + } + const toolExecutor = new ToolExecutor(toolRegistry, hookEngine); + config.toolRegistry = toolRegistry; + config.toolExecutor = toolExecutor; + } + + return new NativeAgent(config); +} + +function loadSystemPrompt(): string { + const paths = [ + resolve(process.cwd(), 'SOUL.md'), + resolve(import.meta.dirname, '../../SOUL.md'), + ]; + for (const p of paths) { + if (existsSync(p)) return readFileSync(p, 'utf-8'); + } + return 'You are Flynn, a helpful personal AI assistant.'; +} + +export function registerSendCommand(program: Command): void { + program + .command('send ') + .description('Send a one-shot message and print the response') + .option('-c, --config ', 'Config file path') + .option('--no-tools', 'Disable tool use') + .action(async (message: string, opts: { config?: string; tools?: boolean }) => { + const configPath = opts.config ?? getConfigPath(); + const { config, error } = loadConfigSafe(configPath); + if (!config) { + console.error(error); + process.exit(1); + } + + // Dynamic import to avoid loading model code eagerly + const { AnthropicClient } = await import('../models/index.js'); + const modelClient = new AnthropicClient({ + model: config.models.default.model, + apiKey: config.models.default.api_key, + authToken: config.models.default.auth_token, + }); + + const agent = createSendAgent({ + modelClient, + systemPrompt: loadSystemPrompt(), + enableTools: opts.tools, + }); + + try { + const response = await agent.process(message); + console.log(response); + } catch (err) { + console.error('Error:', err instanceof Error ? err.message : err); + process.exit(1); + } + }); +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/cli/send.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/cli/send.ts src/cli/send.test.ts +git commit -m "feat(cli): implement send command for one-shot agent messages" +``` + +--- + +## Task 6: CLI Sessions Command + +**Files:** +- Modify: `src/cli/sessions.ts` +- Test: `src/cli/sessions.test.ts` + +**Step 1: Write the failing test** + +Create `src/cli/sessions.test.ts`: + +```typescript +import { describe, it, expect, afterEach } from 'vitest'; +import { listSessions } from './sessions.js'; +import { SessionStore } from '../session/index.js'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { existsSync, unlinkSync } from 'fs'; + +describe('sessions command', () => { + const dbPath = join(tmpdir(), 'flynn-test-sessions-cli.db'); + let store: SessionStore; + + afterEach(() => { + store?.close(); + if (existsSync(dbPath)) unlinkSync(dbPath); + }); + + it('returns empty list when no sessions', () => { + store = new SessionStore(dbPath); + const sessions = listSessions(store); + expect(sessions).toEqual([]); + }); + + it('returns session IDs with message counts', () => { + store = new SessionStore(dbPath); + store.addMessage('telegram:123', { role: 'user', content: 'Hello' }); + store.addMessage('telegram:123', { role: 'assistant', content: 'Hi' }); + store.addMessage('tui:local', { role: 'user', content: 'Test' }); + + const sessions = listSessions(store); + expect(sessions).toHaveLength(2); + + const telegramSession = sessions.find(s => s.id === 'telegram:123'); + expect(telegramSession).toBeDefined(); + expect(telegramSession!.messageCount).toBe(2); + + const tuiSession = sessions.find(s => s.id === 'tui:local'); + expect(tuiSession).toBeDefined(); + expect(tuiSession!.messageCount).toBe(1); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/cli/sessions.test.ts` +Expected: FAIL — `listSessions` not exported + +**Step 3: Implement sessions command** + +Replace `src/cli/sessions.ts`: + +```typescript +import type { Command } from 'commander'; +import type { SessionStore } from '../session/index.js'; +import { getConfigPath, getDataDir } from './shared.js'; +import { resolve } from 'path'; + +export interface SessionInfo { + id: string; + messageCount: number; +} + +/** List all sessions with their message counts. */ +export function listSessions(store: SessionStore): SessionInfo[] { + const sessionIds = store.listSessions(); + return sessionIds.map((id) => ({ + id, + messageCount: store.getMessages(id).length, + })); +} + +export function registerSessionsCommand(program: Command): void { + program + .command('sessions') + .description('List active sessions') + .action(async () => { + const dataDir = getDataDir(); + const dbPath = resolve(dataDir, 'sessions.db'); + + const { SessionStore: Store } = await import('../session/index.js'); + const store = new Store(dbPath); + + try { + const sessions = listSessions(store); + + if (sessions.length === 0) { + console.log('No sessions found.'); + return; + } + + console.log('Sessions:'); + console.log(''); + for (const session of sessions) { + console.log(` ${session.id} (${session.messageCount} messages)`); + } + console.log(''); + console.log(`Total: ${sessions.length} session(s)`); + } finally { + store.close(); + } + }); +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/cli/sessions.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/cli/sessions.ts src/cli/sessions.test.ts +git commit -m "feat(cli): implement sessions list command" +``` + +--- + +## Task 7: CLI Config Command + +**Files:** +- Modify: `src/cli/config-cmd.ts` +- Test: `src/cli/config-cmd.test.ts` + +**Step 1: Write the failing test** + +Create `src/cli/config-cmd.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { formatConfig } from './config-cmd.js'; + +describe('config command', () => { + it('formats config as indented JSON with redacted secrets', () => { + const config = { + telegram: { bot_token: 'secret-123', allowed_chat_ids: [123] }, + models: { default: { provider: 'anthropic', model: 'claude', api_key: 'sk-abc' } }, + server: { port: 18800 }, + }; + + const output = formatConfig(config); + expect(output).toContain('"bot_token": "***"'); + expect(output).toContain('"api_key": "***"'); + expect(output).toContain('"port": 18800'); + expect(output).not.toContain('secret-123'); + expect(output).not.toContain('sk-abc'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/cli/config-cmd.test.ts` +Expected: FAIL — `formatConfig` not exported + +**Step 3: Implement config command** + +Replace `src/cli/config-cmd.ts`: + +```typescript +import type { Command } from 'commander'; +import { loadConfigSafe, getConfigPath, redactSecrets } from './shared.js'; + +/** Format config for display: redact secrets, return as JSON. */ +export function formatConfig(config: Record): string { + const redacted = redactSecrets(config); + return JSON.stringify(redacted, null, 2); +} + +export function registerConfigCommand(program: Command): void { + program + .command('config') + .description('Show resolved configuration (secrets redacted)') + .option('-c, --config ', 'Config file path') + .option('--raw', 'Show unredacted config (dangerous)') + .action(async (opts: { config?: string; raw?: boolean }) => { + const configPath = opts.config ?? getConfigPath(); + const { config, error } = loadConfigSafe(configPath); + + if (!config) { + console.error(error); + process.exit(1); + } + + console.log(`Config loaded from: ${configPath}`); + console.log(''); + + if (opts.raw) { + console.log(JSON.stringify(config, null, 2)); + } else { + console.log(formatConfig(config as unknown as Record)); + } + }); +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/cli/config-cmd.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/cli/config-cmd.ts src/cli/config-cmd.test.ts +git commit -m "feat(cli): implement config display command" +``` + +--- + +## Task 8: CLI TUI Command + +**Files:** +- Modify: `src/cli/tui.ts` + +This moves the existing `src/tui.ts` logic into the CLI framework. No separate test file — the existing TUI tests in `src/frontends/tui/` cover the TUI functionality. The CLI wrapper is thin. + +**Step 1: Implement TUI command** + +Replace `src/cli/tui.ts` with the full TUI logic from `src/tui.ts`, wrapped in a `registerTuiCommand` function: + +```typescript +import type { Command } from 'commander'; +import { loadConfigSafe, getConfigPath } from './shared.js'; +import { existsSync, mkdirSync, readFileSync } from 'fs'; +import { resolve } from 'path'; +import { homedir } from 'os'; + +// ANSI color codes for tool status display +const toolColors = { + reset: '\x1b[0m', + dim: '\x1b[2m', + cyan: '\x1b[36m', + green: '\x1b[32m', + red: '\x1b[31m', +}; + +function loadSystemPrompt(): string { + const paths = [ + resolve(process.cwd(), 'SOUL.md'), + resolve(import.meta.dirname, '../../SOUL.md'), + ]; + for (const soulPath of paths) { + if (existsSync(soulPath)) { + return readFileSync(soulPath, 'utf-8'); + } + } + return 'You are Flynn, a helpful personal AI assistant. Be direct, concise, and helpful. Use markdown when it improves readability.'; +} + +export function registerTuiCommand(program: Command): void { + program + .command('tui') + .description('Launch the interactive TUI') + .option('-f, --fullscreen', 'Start in fullscreen mode') + .option('-c, --config ', 'Config file path') + .action(async (opts: { fullscreen?: boolean; config?: string }) => { + const configPath = opts.config ?? getConfigPath(); + const { config, error } = loadConfigSafe(configPath); + if (!config) { + console.error(error); + process.exit(1); + } + + // Dynamic imports to keep CLI startup fast + const { SessionStore, SessionManager } = await import('../session/index.js'); + const { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, ModelRouter } = await import('../models/index.js'); + const { MinimalTui, startFullscreenTui } = await import('../frontends/tui/index.js'); + const { NativeAgent } = await import('../backends/index.js'); + const { ToolRegistry, ToolExecutor, allBuiltinTools } = await import('../tools/index.js'); + const { HookEngine } = await import('../hooks/index.js'); + + const dataDir = resolve(homedir(), '.local/share/flynn'); + mkdirSync(dataDir, { recursive: true }); + + const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db')); + const sessionManager = new SessionManager(sessionStore); + const models = config.models; + + // Build model router (same logic as original tui.ts) + const defaultClient = new AnthropicClient({ + model: models.default.model, + apiKey: models.default.api_key, + authToken: models.default.auth_token, + }); + + let fastClient; + let complexClient; + let localClient; + + if (models.fast) { + fastClient = new AnthropicClient({ + model: models.fast.model, + apiKey: models.fast.api_key, + authToken: models.fast.auth_token, + }); + } + + if (models.complex) { + complexClient = new AnthropicClient({ + model: models.complex.model, + apiKey: models.complex.api_key, + authToken: models.complex.auth_token, + }); + } + + if (models.local) { + if (models.local.provider === 'ollama') { + localClient = new OllamaClient({ + model: models.local.model, + host: models.local.endpoint, + numGpu: models.local.num_gpu, + }); + } else if (models.local.provider === 'llamacpp') { + localClient = new LlamaCppClient({ + endpoint: models.local.endpoint ?? 'http://localhost:8080', + model: models.local.model, + authToken: models.local.auth_token, + }); + } + } + + const fallbackChain = []; + for (const providerName of models.fallback_chain) { + if (providerName === 'openai') { + fallbackChain.push(new OpenAIClient({ model: 'gpt-4o' })); + } else if (providerName === 'local' && localClient) { + fallbackChain.push(localClient); + } + } + + const modelRouter = new ModelRouter({ + default: defaultClient, + fast: fastClient, + complex: complexClient, + local: localClient, + fallbackChain, + }); + + const systemPrompt = loadSystemPrompt(); + + const hookEngine = new HookEngine(config.hooks); + const toolRegistry = new ToolRegistry(); + for (const tool of allBuiltinTools) { + toolRegistry.register(tool); + } + const toolExecutor = new ToolExecutor(toolRegistry, hookEngine); + + const session = sessionManager.getSession('tui', 'local'); + + const agent = new NativeAgent({ + modelClient: modelRouter, + systemPrompt, + session, + toolRegistry, + toolExecutor, + onToolUse: (event) => { + if (event.type === 'start') { + const argsStr = event.args ? ` ${toolColors.dim}${JSON.stringify(event.args)}${toolColors.reset}` : ''; + process.stdout.write(`${toolColors.cyan}> ${event.tool}${toolColors.reset}${argsStr}\n`); + } else if (event.type === 'end' && event.result) { + const icon = event.result.success ? `${toolColors.green}done` : `${toolColors.red}error`; + const detail = event.result.success + ? `${toolColors.dim}(${event.result.output.split('\n').length} lines)${toolColors.reset}` + : `${toolColors.dim}${event.result.error ?? 'unknown error'}${toolColors.reset}`; + process.stdout.write(` ${icon}${toolColors.reset} ${detail}\n`); + } + }, + }); + + const cleanup = () => { + sessionStore.close(); + }; + + process.on('SIGINT', () => { + cleanup(); + process.exit(0); + }); + + if (opts.fullscreen) { + await startFullscreenTui({ + session, + modelClient: modelRouter, + modelRouter, + systemPrompt, + model: config.models.default.model, + onExit: cleanup, + }); + } else { + let switchingToFullscreen = false; + + const tui = new MinimalTui({ + session, + modelClient: modelRouter, + modelRouter, + systemPrompt, + agent, + localProviders: config.models.local_providers, + currentLocalProvider: config.models.local?.provider, + onTransfer: (target) => { + if (target === 'telegram') { + const telegramUserId = String(config.telegram.allowed_chat_ids[0]); + sessionManager.transferSession('tui', 'local', 'telegram', telegramUserId); + console.log(`Session transferred to Telegram (${telegramUserId})\n`); + } else { + console.log(`Unknown transfer target: ${target}\n`); + } + }, + onFullscreen: () => { + switchingToFullscreen = true; + tui.stop(true); + }, + }); + + await tui.start(); + + if (switchingToFullscreen) { + console.clear(); + await startFullscreenTui({ + session, + modelClient: modelRouter, + modelRouter, + systemPrompt, + model: config.models.default.model, + onExit: cleanup, + }); + return; + } + } + + cleanup(); + }); +} +``` + +**Step 2: Run full test suite and typecheck** + +Run: `pnpm vitest run && pnpm typecheck` +Expected: All tests pass, no type errors + +**Step 3: Commit** + +```bash +git add src/cli/tui.ts +git commit -m "feat(cli): implement tui command wrapping existing TUI logic" +``` + +--- + +## Task 9: Retire Old Entry Points + +**Files:** +- Modify: `src/index.ts` (make it re-export from cli) +- Modify: `src/tui.ts` (make it re-export from cli) + +**Step 1: Replace src/index.ts** + +```typescript +// Legacy entry point — delegates to CLI +import './cli/index.js'; +``` + +**Step 2: Replace src/tui.ts** + +```typescript +// Legacy entry point — delegates to CLI +// When invoked directly, behaves like 'flynn tui' +import { createProgram } from './cli/index.js'; + +const program = createProgram(); +const args = ['node', 'flynn', 'tui', ...process.argv.slice(2)]; +program.parse(args); +``` + +**Step 3: Run full test suite and typecheck** + +Run: `pnpm vitest run && pnpm typecheck` +Expected: All tests pass + +**Step 4: Commit** + +```bash +git add src/index.ts src/tui.ts +git commit -m "refactor: retire old entry points, delegate to CLI" +``` + +--- + +## Task 10: Doctor Diagnostics — Core Checks + +**Files:** +- Modify: `src/cli/doctor.ts` +- Test: `src/cli/doctor.test.ts` + +**Step 1: Write the failing test** + +Create `src/cli/doctor.test.ts`: + +```typescript +import { describe, it, expect, afterEach } from 'vitest'; +import { runChecks, type CheckResult, type DoctorContext } from './doctor.js'; +import { writeFileSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +describe('doctor checks', () => { + const testDir = join(tmpdir(), 'flynn-test-doctor'); + + afterEach(() => { + try { rmSync(testDir, { recursive: true }); } catch {} + }); + + it('reports PASS when config file exists and is valid', async () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'config.yaml'); + writeFileSync(configPath, ` +telegram: + bot_token: "test-token" + allowed_chat_ids: [123] +models: + default: + provider: anthropic + model: claude-sonnet +`); + + const ctx: DoctorContext = { configPath, dataDir: testDir }; + const results = await runChecks(ctx); + + const configExists = results.find(r => r.label.includes('Config file')); + expect(configExists?.status).toBe('pass'); + + const configParses = results.find(r => r.label.includes('parses')); + expect(configParses?.status).toBe('pass'); + + const configValidates = results.find(r => r.label.includes('validates')); + expect(configValidates?.status).toBe('pass'); + }); + + it('reports FAIL when config file does not exist', async () => { + const ctx: DoctorContext = { configPath: '/nonexistent/config.yaml', dataDir: testDir }; + const results = await runChecks(ctx); + + const configExists = results.find(r => r.label.includes('Config file')); + expect(configExists?.status).toBe('fail'); + }); + + it('reports FAIL on invalid YAML', async () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'bad.yaml'); + writeFileSync(configPath, '{{{{bad yaml'); + + const ctx: DoctorContext = { configPath, dataDir: testDir }; + const results = await runChecks(ctx); + + const configParses = results.find(r => r.label.includes('parses')); + expect(configParses?.status).toBe('fail'); + }); + + it('reports FAIL on schema validation failure', async () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'invalid.yaml'); + writeFileSync(configPath, ` +telegram: + bot_token: "" +`); + + const ctx: DoctorContext = { configPath, dataDir: testDir }; + const results = await runChecks(ctx); + + const configValidates = results.find(r => r.label.includes('validates')); + expect(configValidates?.status).toBe('fail'); + }); + + it('reports PASS for writable data directory', async () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'config.yaml'); + writeFileSync(configPath, ` +telegram: + bot_token: "test-token" + allowed_chat_ids: [123] +models: + default: + provider: anthropic + model: claude-sonnet +`); + + const ctx: DoctorContext = { configPath, dataDir: testDir }; + const results = await runChecks(ctx); + + const dataDir = results.find(r => r.label.includes('Data directory')); + expect(dataDir?.status).toBe('pass'); + }); + + it('reports PASS for accessible session DB', async () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'config.yaml'); + writeFileSync(configPath, ` +telegram: + bot_token: "test-token" + allowed_chat_ids: [123] +models: + default: + provider: anthropic + model: claude-sonnet +`); + + const ctx: DoctorContext = { configPath, dataDir: testDir }; + const results = await runChecks(ctx); + + const sessionDb = results.find(r => r.label.includes('Session DB')); + expect(sessionDb?.status).toBe('pass'); + }); + + it('skips downstream checks when config is invalid', async () => { + const ctx: DoctorContext = { configPath: '/nonexistent/config.yaml', dataDir: testDir }; + const results = await runChecks(ctx); + + const modelCheck = results.find(r => r.label.includes('Model connectivity')); + expect(modelCheck?.status).toBe('skip'); + + const telegramCheck = results.find(r => r.label.includes('Telegram')); + expect(telegramCheck?.status).toBe('skip'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/cli/doctor.test.ts` +Expected: FAIL — `runChecks` not exported + +**Step 3: Implement doctor** + +Replace `src/cli/doctor.ts`: + +```typescript +import type { Command } from 'commander'; +import type { Config } from '../config/index.js'; +import { getConfigPath, getDataDir, formatStatus } from './shared.js'; +import { existsSync, accessSync, constants, readFileSync, writeFileSync, unlinkSync } from 'fs'; +import { resolve, join } from 'path'; +import { parse } from 'yaml'; +import { configSchema } from '../config/schema.js'; + +export interface CheckResult { + status: 'pass' | 'fail' | 'warn' | 'skip'; + label: string; + detail?: string; +} + +export interface DoctorContext { + configPath: string; + dataDir: string; + config?: Config; +} + +type Check = (ctx: DoctorContext) => Promise; + +const checkConfigExists: Check = async (ctx) => { + if (existsSync(ctx.configPath)) { + return { status: 'pass', label: 'Config file exists', detail: `(${ctx.configPath})` }; + } + return { status: 'fail', label: 'Config file exists', detail: `not found at ${ctx.configPath}` }; +}; + +const checkConfigParses: Check = async (ctx) => { + if (!existsSync(ctx.configPath)) { + return { status: 'skip', label: 'Config parses', detail: '(no config file)' }; + } + try { + const raw = readFileSync(ctx.configPath, 'utf-8'); + parse(raw); + return { status: 'pass', label: 'Config parses', detail: '(valid YAML)' }; + } catch (err) { + return { status: 'fail', label: 'Config parses', detail: err instanceof Error ? err.message : String(err) }; + } +}; + +const checkConfigValidates: Check = async (ctx) => { + if (!existsSync(ctx.configPath)) { + return { status: 'skip', label: 'Config validates', detail: '(no config file)' }; + } + try { + const raw = readFileSync(ctx.configPath, 'utf-8'); + const parsed = parse(raw); + if (!parsed || typeof parsed !== 'object') { + return { status: 'fail', label: 'Config validates', detail: 'YAML did not produce an object' }; + } + const config = configSchema.parse(parsed); + ctx.config = config; + return { status: 'pass', label: 'Config validates', detail: '(schema valid)' }; + } catch (err) { + return { status: 'fail', label: 'Config validates', detail: err instanceof Error ? err.message : String(err) }; + } +}; + +const checkEnvVars: Check = async (ctx) => { + if (!existsSync(ctx.configPath)) { + return { status: 'skip', label: 'Env vars resolved', detail: '(no config file)' }; + } + try { + const raw = readFileSync(ctx.configPath, 'utf-8'); + const envVarPattern = /\$\{([^}]+)\}/g; + const unresolved: string[] = []; + let match; + while ((match = envVarPattern.exec(raw)) !== null) { + if (!process.env[match[1]]) { + unresolved.push(match[1]); + } + } + if (unresolved.length > 0) { + return { status: 'fail', label: 'Env vars resolved', detail: `unset: ${unresolved.join(', ')}` }; + } + return { status: 'pass', label: 'Env vars resolved' }; + } catch { + return { status: 'skip', label: 'Env vars resolved', detail: '(could not read config)' }; + } +}; + +const checkDataDir: Check = async (ctx) => { + try { + // Ensure directory exists for the test + const { mkdirSync } = await import('fs'); + mkdirSync(ctx.dataDir, { recursive: true }); + + // Test write access + const testFile = join(ctx.dataDir, '.doctor-check'); + writeFileSync(testFile, 'ok'); + unlinkSync(testFile); + return { status: 'pass', label: 'Data directory writable', detail: `(${ctx.dataDir})` }; + } catch { + return { status: 'fail', label: 'Data directory writable', detail: `cannot write to ${ctx.dataDir}` }; + } +}; + +const checkSessionDb: Check = async (ctx) => { + try { + const { mkdirSync } = await import('fs'); + mkdirSync(ctx.dataDir, { recursive: true }); + + const dbPath = resolve(ctx.dataDir, 'sessions.db'); + const { SessionStore } = await import('../session/index.js'); + const store = new SessionStore(dbPath); + store.listSessions(); // Quick query to verify DB works + store.close(); + return { status: 'pass', label: 'Session DB accessible', detail: '(sessions.db)' }; + } catch (err) { + return { status: 'fail', label: 'Session DB accessible', detail: err instanceof Error ? err.message : String(err) }; + } +}; + +const checkModelConnectivity: Check = async (ctx) => { + if (!ctx.config) { + return { status: 'skip', label: 'Model connectivity', detail: '(config invalid)' }; + } + // Skip actual API call in doctor — just verify config looks complete + const model = ctx.config.models.default; + if (!model.model) { + return { status: 'fail', label: 'Model connectivity', detail: 'no default model configured' }; + } + return { status: 'pass', label: 'Model connectivity', detail: `(${model.provider}: ${model.model})` }; +}; + +const checkTelegram: Check = async (ctx) => { + if (!ctx.config) { + return { status: 'skip', label: 'Telegram bot configured', detail: '(config invalid)' }; + } + if (!ctx.config.telegram.bot_token || ctx.config.telegram.bot_token.length < 10) { + return { status: 'warn', label: 'Telegram bot configured', detail: 'token looks too short' }; + } + return { status: 'pass', label: 'Telegram bot configured', detail: `(${ctx.config.telegram.allowed_chat_ids.length} allowed chat(s))` }; +}; + +const checkMcpServers: Check = async (ctx) => { + if (!ctx.config) { + return { status: 'skip', label: 'MCP servers configured', detail: '(config invalid)' }; + } + const servers = ctx.config.mcp.servers; + if (servers.length === 0) { + return { status: 'skip', label: 'MCP servers configured', detail: '(none configured)' }; + } + return { status: 'pass', label: 'MCP servers configured', detail: `(${servers.length} server(s))` }; +}; + +const checkSkills: Check = async (ctx) => { + if (!ctx.config) { + return { status: 'skip', label: 'Skills loaded', detail: '(config invalid)' }; + } + try { + const { loadAllSkills } = await import('../skills/index.js'); + const skills = loadAllSkills({ + bundledDir: ctx.config.skills.bundled_dir, + managedDir: ctx.config.skills.managed_dir, + workspaceDir: ctx.config.skills.workspace_dir, + }); + return { status: 'pass', label: 'Skills loaded', detail: `(${skills.length} skill(s))` }; + } catch (err) { + return { status: 'fail', label: 'Skills loaded', detail: err instanceof Error ? err.message : String(err) }; + } +}; + +const allChecks: Check[] = [ + checkConfigExists, + checkConfigParses, + checkConfigValidates, + checkEnvVars, + checkDataDir, + checkSessionDb, + checkModelConnectivity, + checkTelegram, + checkMcpServers, + checkSkills, +]; + +/** Run all doctor checks in order. Exported for testing. */ +export async function runChecks(ctx: DoctorContext): Promise { + const results: CheckResult[] = []; + for (const check of allChecks) { + const result = await check(ctx); + results.push(result); + } + return results; +} + +export function registerDoctorCommand(program: Command): void { + program + .command('doctor') + .description('Validate configuration and check system health') + .option('-c, --config ', 'Config file path') + .action(async (opts: { config?: string }) => { + const configPath = opts.config ?? getConfigPath(); + const dataDir = getDataDir(); + + console.log('Flynn Doctor'); + console.log('============'); + console.log(''); + + const ctx: DoctorContext = { configPath, dataDir }; + const results = await runChecks(ctx); + + for (const result of results) { + console.log(formatStatus(result.status, result.label, result.detail)); + } + + console.log(''); + + const counts = { + pass: results.filter(r => r.status === 'pass').length, + fail: results.filter(r => r.status === 'fail').length, + warn: results.filter(r => r.status === 'warn').length, + skip: results.filter(r => r.status === 'skip').length, + }; + + console.log(`Results: ${counts.pass} passed, ${counts.fail} failed, ${counts.warn} warnings, ${counts.skip} skipped`); + + process.exit(counts.fail > 0 ? 1 : 0); + }); +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm vitest run src/cli/doctor.test.ts` +Expected: PASS + +**Step 5: Run full test suite and typecheck** + +Run: `pnpm vitest run && pnpm typecheck` +Expected: All tests pass, no type errors + +**Step 6: Commit** + +```bash +git add src/cli/doctor.ts src/cli/doctor.test.ts +git commit -m "feat(cli): implement doctor diagnostics command" +``` + +--- + +## Task 11: CronScheduler Channel Adapter + +**Files:** +- Create: `src/automation/cron.ts` +- Test: `src/automation/cron.test.ts` + +**Step 1: Write the failing test** + +Create `src/automation/cron.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { CronScheduler } from './cron.js'; +import type { CronJobConfig } from '../config/schema.js'; +import type { ChannelAdapter, ChannelRegistry, InboundMessage, OutboundMessage } from '../channels/types.js'; + +function makeCronJob(overrides?: Partial): CronJobConfig { + return { + name: 'test-job', + schedule: '0 9 * * *', + message: 'Hello from cron', + output: { channel: 'telegram', peer: '123' }, + enabled: true, + ...overrides, + }; +} + +describe('CronScheduler', () => { + let scheduler: CronScheduler; + let mockChannelRegistry: { get: ReturnType }; + + beforeEach(() => { + mockChannelRegistry = { + get: vi.fn(), + }; + }); + + afterEach(async () => { + if (scheduler) { + await scheduler.disconnect(); + } + }); + + it('implements ChannelAdapter interface', () => { + scheduler = new CronScheduler([], mockChannelRegistry as any); + expect(scheduler.name).toBe('cron'); + expect(scheduler.status).toBe('disconnected'); + }); + + it('status changes to connected after connect()', async () => { + scheduler = new CronScheduler([], mockChannelRegistry as any); + await scheduler.connect(); + expect(scheduler.status).toBe('connected'); + }); + + it('status changes to disconnected after disconnect()', async () => { + scheduler = new CronScheduler([], mockChannelRegistry as any); + await scheduler.connect(); + await scheduler.disconnect(); + expect(scheduler.status).toBe('disconnected'); + }); + + it('skips disabled jobs', async () => { + const jobs = [makeCronJob({ enabled: false })]; + scheduler = new CronScheduler(jobs, mockChannelRegistry as any); + + const messages: InboundMessage[] = []; + scheduler.onMessage((msg) => messages.push(msg)); + + await scheduler.connect(); + // Disabled job should not fire + expect(messages).toHaveLength(0); + }); + + it('fires a message when triggerJob is called', async () => { + const jobs = [makeCronJob()]; + scheduler = new CronScheduler(jobs, mockChannelRegistry as any); + + const messages: InboundMessage[] = []; + scheduler.onMessage((msg) => messages.push(msg)); + await scheduler.connect(); + + // Manually trigger (simulates cron firing) + scheduler.triggerJob('test-job'); + + expect(messages).toHaveLength(1); + expect(messages[0].channel).toBe('cron'); + expect(messages[0].senderId).toBe('test-job'); + expect(messages[0].text).toBe('Hello from cron'); + }); + + it('forwards response to output channel on send()', async () => { + const mockOutputAdapter = { + send: vi.fn().mockResolvedValue(undefined), + }; + mockChannelRegistry.get.mockReturnValue(mockOutputAdapter); + + const jobs = [makeCronJob()]; + scheduler = new CronScheduler(jobs, mockChannelRegistry as any); + await scheduler.connect(); + + await scheduler.send('test-job', { text: 'Agent response' }); + + expect(mockChannelRegistry.get).toHaveBeenCalledWith('telegram'); + expect(mockOutputAdapter.send).toHaveBeenCalledWith('123', { text: 'Agent response' }); + }); + + it('logs warning when output channel not found', async () => { + mockChannelRegistry.get.mockReturnValue(undefined); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const jobs = [makeCronJob()]; + scheduler = new CronScheduler(jobs, mockChannelRegistry as any); + await scheduler.connect(); + + await scheduler.send('test-job', { text: 'Agent response' }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Output channel')); + warnSpy.mockRestore(); + }); + + it('logs warning when job name not found in send()', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const jobs = [makeCronJob()]; + scheduler = new CronScheduler(jobs, mockChannelRegistry as any); + await scheduler.connect(); + + await scheduler.send('nonexistent-job', { text: 'response' }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No cron job')); + warnSpy.mockRestore(); + }); + + it('lists registered job names', () => { + const jobs = [ + makeCronJob({ name: 'job-a' }), + makeCronJob({ name: 'job-b', enabled: false }), + ]; + scheduler = new CronScheduler(jobs, mockChannelRegistry as any); + + const names = scheduler.getJobNames(); + expect(names).toEqual(['job-a', 'job-b']); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm vitest run src/automation/cron.test.ts` +Expected: FAIL — module not found + +**Step 3: Implement CronScheduler** + +Create `src/automation/cron.ts`: + +```typescript +import { Cron } from 'croner'; +import type { CronJobConfig } from '../config/schema.js'; +import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js'; + +/** Minimal interface for the parts of ChannelRegistry we need. */ +interface ChannelLookup { + get(name: string): { send(peerId: string, message: OutboundMessage): Promise } | undefined; +} + +export class CronScheduler implements ChannelAdapter { + readonly name = 'cron'; + private _status: ChannelStatus = 'disconnected'; + private messageHandler?: (msg: InboundMessage) => void; + private cronInstances: Map = new Map(); + private jobs: Map = new Map(); + + constructor( + private readonly jobConfigs: CronJobConfig[], + private readonly channelLookup: ChannelLookup, + ) { + for (const job of jobConfigs) { + this.jobs.set(job.name, job); + } + } + + get status(): ChannelStatus { + return this._status; + } + + async connect(): Promise { + this._status = 'connected'; + + for (const job of this.jobConfigs) { + if (!job.enabled) continue; + + const cronInstance = new Cron(job.schedule, { + timezone: job.timezone, + paused: false, + }, () => { + this.triggerJob(job.name); + }); + + this.cronInstances.set(job.name, cronInstance); + } + + const enabledCount = this.jobConfigs.filter(j => j.enabled).length; + if (enabledCount > 0) { + console.log(`CronScheduler: ${enabledCount} job(s) scheduled`); + } + } + + async disconnect(): Promise { + for (const [, cron] of this.cronInstances) { + cron.stop(); + } + this.cronInstances.clear(); + this._status = 'disconnected'; + } + + async send(peerId: string, message: OutboundMessage): Promise { + // peerId is the cron job name — look up its output config + const job = this.jobs.get(peerId); + if (!job) { + console.warn(`No cron job found for '${peerId}'`); + return; + } + + const outputAdapter = this.channelLookup.get(job.output.channel); + if (!outputAdapter) { + console.warn(`Output channel '${job.output.channel}' not found for cron job '${peerId}'`); + return; + } + + await outputAdapter.send(job.output.peer, message); + } + + onMessage(handler: (msg: InboundMessage) => void): void { + this.messageHandler = handler; + } + + /** Manually trigger a job (also called by cron on schedule). */ + triggerJob(jobName: string): void { + const job = this.jobs.get(jobName); + if (!job) return; + + const msg: InboundMessage = { + id: `cron-${jobName}-${Date.now()}`, + channel: 'cron', + senderId: jobName, + senderName: `cron:${jobName}`, + text: job.message, + timestamp: Date.now(), + metadata: { cronJob: jobName, scheduled: true }, + }; + + this.messageHandler?.(msg); + } + + /** Get list of all job names (enabled and disabled). */ + getJobNames(): string[] { + return Array.from(this.jobs.keys()); + } +} +``` + +**Step 4: Create index.ts for automation module** + +Create `src/automation/index.ts`: + +```typescript +export { CronScheduler } from './cron.js'; +``` + +**Step 5: Run test to verify it passes** + +Run: `pnpm vitest run src/automation/cron.test.ts` +Expected: PASS + +**Step 6: Commit** + +```bash +git add src/automation/ +git commit -m "feat(automation): add CronScheduler channel adapter" +``` + +--- + +## Task 12: Wire CronScheduler into Daemon + +**Files:** +- Modify: `src/daemon/index.ts:254-278` (after channel registry setup) +- Test: Existing daemon integration verified by running full test suite + typecheck + +**Step 1: Add CronScheduler import** + +In `src/daemon/index.ts`, add to imports: + +```typescript +import { CronScheduler } from '../automation/index.js'; +``` + +**Step 2: Register CronScheduler after WebChat adapter** + +In `src/daemon/index.ts`, after the line `channelRegistry.register(webChatAdapter);` (line 277), add: + +```typescript + // Register cron scheduler adapter (if any cron jobs configured) + if (config.automation.cron.length > 0) { + const cronScheduler = new CronScheduler(config.automation.cron, channelRegistry); + channelRegistry.register(cronScheduler); + console.log(`Registered ${config.automation.cron.length} cron job(s)`); + } +``` + +**Step 3: Run full test suite and typecheck** + +Run: `pnpm vitest run && pnpm typecheck` +Expected: All tests pass, no type errors + +**Step 4: Commit** + +```bash +git add src/daemon/index.ts +git commit -m "feat(daemon): wire CronScheduler into channel registry" +``` + +--- + +## Task 13: Export New Types from Config Index + +**Files:** +- Modify: `src/config/index.ts` + +**Step 1: Add CronJobConfig export** + +In `src/config/index.ts`, add `CronJobConfig` to the schema export: + +```typescript +export { loadConfig } from './loader.js'; +export { configSchema, type Config, type TelegramConfig, type ModelConfig, type CronJobConfig } from './schema.js'; +``` + +**Step 2: Run typecheck** + +Run: `pnpm typecheck` +Expected: Clean + +**Step 3: Commit** + +```bash +git add src/config/index.ts +git commit -m "chore: export CronJobConfig type from config index" +``` + +--- + +## Task 14: Final Verification and Cleanup + +**Step 1: Run full test suite** + +Run: `pnpm vitest run` +Expected: All tests pass (298 existing + new tests) + +**Step 2: Run typecheck** + +Run: `pnpm typecheck` +Expected: No errors + +**Step 3: Run build** + +Run: `pnpm build` +Expected: Compiles successfully + +**Step 4: Verify CLI works** + +Run: `npx tsx src/cli/index.ts --help` +Expected: Shows Flynn CLI help with all commands listed + +Run: `npx tsx src/cli/index.ts version` +Expected: Shows version 0.1.0 + +Run: `npx tsx src/cli/index.ts doctor --config /nonexistent/path.yaml` +Expected: Shows FAIL for config file exists, SKIPs for downstream checks + +**Step 5: Commit final cleanup if needed** + +```bash +git add -A +git commit -m "feat: Phase 5a complete — CLI surface, cron scheduling, doctor diagnostics" +``` + +--- + +## Summary + +| Task | What | Tests | +|------|------|-------| +| 1 | Install dependencies | N/A | +| 2 | Config schema: automation.cron | 5 tests | +| 3 | CLI shared utilities | 7 tests | +| 4 | CLI entry point + start command | 3 tests | +| 5 | CLI send command | 1 test | +| 6 | CLI sessions command | 2 tests | +| 7 | CLI config command | 1 test | +| 8 | CLI tui command | 0 (uses existing TUI tests) | +| 9 | Retire old entry points | 0 (verified by full suite) | +| 10 | Doctor diagnostics | 7 tests | +| 11 | CronScheduler adapter | 7 tests | +| 12 | Wire cron into daemon | 0 (verified by full suite) | +| 13 | Export types | 0 | +| 14 | Final verification | 0 | + +**Total new tests: ~33** +**Total commits: ~14**