Files
flynn/docs/plans/2026-02-05-phase5a-implementation.md
2026-02-05 22:10:41 -08:00

60 KiB

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

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:

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:

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:

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:

export type CronJobConfig = z.infer<typeof cronJobSchema>;

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

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:

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:

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<string, unknown>): Record<string, unknown> {
  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<string, unknown> = {};
      for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
        if (sensitiveKeys.includes(key) && typeof value === 'string') {
          result[key] = '***';
        } else {
          result[key] = redact(value);
        }
      }
      return result;
    }
    return obj;
  }

  return redact(config) as Record<string, unknown>;
}

/** Format output for terminal display. */
export function formatStatus(
  status: 'pass' | 'fail' | 'warn' | 'skip',
  label: string,
  detail?: string,
): string {
  const icons: Record<string, string> = {
    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

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:

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:

#!/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:

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 <path>', '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<void>((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:

import type { Command } from 'commander';

export function registerSendCommand(program: Command): void {
  program
    .command('send <message>')
    .description('Send a one-shot message and print the response')
    .option('-c, --config <path>', 'Config file path')
    .action(async (_message: string, _opts: { config?: string }) => {
      console.error('Not yet implemented');
      process.exit(1);
    });
}

Create src/cli/sessions.ts:

import type { Command } from 'commander';

export function registerSessionsCommand(program: Command): void {
  program
    .command('sessions')
    .description('List active sessions')
    .option('-c, --config <path>', 'Config file path')
    .action(async (_opts: { config?: string }) => {
      console.error('Not yet implemented');
      process.exit(1);
    });
}

Create src/cli/doctor.ts:

import type { Command } from 'commander';

export function registerDoctorCommand(program: Command): void {
  program
    .command('doctor')
    .description('Validate configuration and check system health')
    .option('-c, --config <path>', 'Config file path')
    .action(async (_opts: { config?: string }) => {
      console.error('Not yet implemented');
      process.exit(1);
    });
}

Create src/cli/config-cmd.ts:

import type { Command } from 'commander';

export function registerConfigCommand(program: Command): void {
  program
    .command('config')
    .description('Show resolved configuration (secrets redacted)')
    .option('-c, --config <path>', 'Config file path')
    .action(async (_opts: { config?: string }) => {
      console.error('Not yet implemented');
      process.exit(1);
    });
}

Create src/cli/tui.ts:

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 <path>', '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":

"bin": {
  "flynn": "dist/cli/index.js"
},

Update scripts:

"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

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:

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:

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<typeof NativeAgent>[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 <message>')
    .description('Send a one-shot message and print the response')
    .option('-c, --config <path>', '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

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:

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:

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

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:

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:

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, unknown>): 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 <path>', '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<string, unknown>));
      }
    });
}

Step 4: Run test to verify it passes

Run: pnpm vitest run src/cli/config-cmd.test.ts Expected: PASS

Step 5: Commit

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:

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 <path>', '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

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

// Legacy entry point — delegates to CLI
import './cli/index.js';

Step 2: Replace src/tui.ts

// 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

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:

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:

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<CheckResult>;

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<CheckResult[]> {
  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 <path>', '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

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:

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>): 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<typeof vi.fn> };

  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:

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<void> } | undefined;
}

export class CronScheduler implements ChannelAdapter {
  readonly name = 'cron';
  private _status: ChannelStatus = 'disconnected';
  private messageHandler?: (msg: InboundMessage) => void;
  private cronInstances: Map<string, Cron> = new Map();
  private jobs: Map<string, CronJobConfig> = 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<void> {
    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<void> {
    for (const [, cron] of this.cronInstances) {
      cron.stop();
    }
    this.cronInstances.clear();
    this._status = 'disconnected';
  }

  async send(peerId: string, message: OutboundMessage): Promise<void> {
    // 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:

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

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:

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:

  // 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

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:

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

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

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