feat(cli): add CLI entry point with commander and start command

This commit is contained in:
William Valentin
2026-02-05 22:14:42 -08:00
parent 6f7b5b8f0f
commit 72c75a8bd7
10 changed files with 175 additions and 8 deletions
+12
View File
@@ -0,0 +1,12 @@
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);
});
}
+12
View File
@@ -0,0 +1,12 @@
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);
});
}
+26
View File
@@ -0,0 +1,26 @@
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');
});
});
+36
View File
@@ -0,0 +1,36 @@
#!/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);
}
+12
View File
@@ -0,0 +1,12 @@
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);
});
}
+12
View File
@@ -0,0 +1,12 @@
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);
});
}
+5 -3
View File
@@ -76,8 +76,9 @@ models:
models: { default: { provider: 'anthropic', model: 'claude' } },
};
const redacted = redactSecrets(config);
expect(redacted.telegram.bot_token).toBe('***');
expect(redacted.telegram.allowed_chat_ids).toEqual([123]);
const telegram = redacted.telegram as Record<string, unknown>;
expect(telegram.bot_token).toBe('***');
expect(telegram.allowed_chat_ids).toEqual([123]);
});
it('redacts api_key in models', () => {
@@ -88,7 +89,8 @@ models:
},
};
const redacted = redactSecrets(config);
expect(redacted.models.default.api_key).toBe('***');
const models = redacted.models as Record<string, Record<string, unknown>>;
expect(models.default.api_key).toBe('***');
});
});
+39
View File
@@ -0,0 +1,39 @@
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());
});
});
}
+13
View File
@@ -0,0 +1,13 @@
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);
});
}