diff --git a/package.json b/package.json index 64350d7..e6dec7e 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,16 @@ "description": "Self-hosted personal AI agent", "type": "module", "main": "dist/index.js", + "bin": { + "flynn": "dist/cli/index.js" + }, "scripts": { "build": "tsc", - "dev": "tsx watch src/index.ts", - "start": "node dist/index.js", - "tui": "tsx src/tui.ts", - "tui:fs": "tsx src/tui.ts --fullscreen", - "tui:dev": "tsx watch src/tui.ts", + "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/", diff --git a/src/cli/config-cmd.ts b/src/cli/config-cmd.ts new file mode 100644 index 0000000..de56609 --- /dev/null +++ b/src/cli/config-cmd.ts @@ -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 ', 'Config file path') + .action(async (_opts: { config?: string }) => { + console.error('Not yet implemented'); + process.exit(1); + }); +} diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts new file mode 100644 index 0000000..b55c020 --- /dev/null +++ b/src/cli/doctor.ts @@ -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 ', 'Config file path') + .action(async (_opts: { config?: string }) => { + console.error('Not yet implemented'); + process.exit(1); + }); +} diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts new file mode 100644 index 0000000..f7d36cb --- /dev/null +++ b/src/cli/index.test.ts @@ -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'); + }); +}); diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..59b9453 --- /dev/null +++ b/src/cli/index.ts @@ -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); +} diff --git a/src/cli/send.ts b/src/cli/send.ts new file mode 100644 index 0000000..099d958 --- /dev/null +++ b/src/cli/send.ts @@ -0,0 +1,12 @@ +import type { Command } from 'commander'; + +export function registerSendCommand(program: Command): void { + program + .command('send ') + .description('Send a one-shot message and print the response') + .option('-c, --config ', 'Config file path') + .action(async (_message: string, _opts: { config?: string }) => { + console.error('Not yet implemented'); + process.exit(1); + }); +} diff --git a/src/cli/sessions.ts b/src/cli/sessions.ts new file mode 100644 index 0000000..733b6e9 --- /dev/null +++ b/src/cli/sessions.ts @@ -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 ', 'Config file path') + .action(async (_opts: { config?: string }) => { + console.error('Not yet implemented'); + process.exit(1); + }); +} diff --git a/src/cli/shared.test.ts b/src/cli/shared.test.ts index 4d8f123..b52607a 100644 --- a/src/cli/shared.test.ts +++ b/src/cli/shared.test.ts @@ -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; + 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>; + expect(models.default.api_key).toBe('***'); }); }); diff --git a/src/cli/start.ts b/src/cli/start.ts new file mode 100644 index 0000000..6d46e2e --- /dev/null +++ b/src/cli/start.ts @@ -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 ', 'Config file path') + .action(async (opts: { config?: string }) => { + const configPath = opts.config ?? getConfigPath(); + + if (!existsSync(configPath)) { + console.error(`Config file not found: ${configPath}`); + console.error('Run "flynn doctor" to diagnose, or create a config at ~/.config/flynn/config.yaml'); + process.exit(1); + } + + console.log('Flynn starting...'); + console.log(`Loading config from: ${configPath}`); + + const { config, error } = loadConfigSafe(configPath); + if (!config) { + console.error(error); + process.exit(1); + } + + // Dynamic import to avoid loading daemon code for other commands + const { startDaemon } = await import('../daemon/index.js'); + const daemon = await startDaemon(config); + + console.log(`Allowed Telegram chat IDs: ${config.telegram.allowed_chat_ids.join(', ')}`); + + // Keep process alive + await new Promise((resolve) => { + daemon.lifecycle.onShutdown(async () => resolve()); + }); + }); +} diff --git a/src/cli/tui.ts b/src/cli/tui.ts new file mode 100644 index 0000000..ec34b69 --- /dev/null +++ b/src/cli/tui.ts @@ -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 ', 'Config file path') + .action(async (_opts: { fullscreen?: boolean; config?: string }) => { + console.error('Not yet implemented'); + process.exit(1); + }); +}