# 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**