2110 lines
60 KiB
Markdown
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**
|