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

2110 lines
60 KiB
Markdown

# 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<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**
```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<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**
```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 <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`:
```typescript
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`:
```typescript
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`:
```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 <path>', '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 <path>', '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 <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"`:
```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<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**
```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, 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**
```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 <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**
```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<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**
```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>): 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`:
```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<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`:
```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**