diff --git a/backend/bin/cli.js b/backend/bin/cli.js index 39f32a3..b9a8ec1 100644 --- a/backend/bin/cli.js +++ b/backend/bin/cli.js @@ -2,82 +2,130 @@ /** * NoteCode CLI Entry Point - * Usage: npx notecode [options] + * Usage: npx notecode [command] [options] */ -import { createServer } from '../dist/infrastructure/server/fastify.server.js'; -import { initializeDatabase, closeDatabase } from '../dist/infrastructure/database/connection.js'; -import { - DEFAULT_PORT, - findAvailablePort, - parsePort, -} from '../dist/infrastructure/server/port-utils.js'; +import { Command } from 'commander'; import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; -import { networkInterfaces } from 'os'; // Get package.json for version const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8')); -const args = process.argv.slice(2); -const HOST = process.env.HOST || '0.0.0.0'; -const NO_BROWSER = process.env.NO_BROWSER === 'true' || args.includes('--no-browser'); +// Default API URL +const DEFAULT_API_URL = 'http://localhost:41920'; -// Get local network IP -function getLocalIP() { - try { - const nets = networkInterfaces(); - for (const name of Object.keys(nets)) { - for (const net of nets[name] || []) { - if (net.family === 'IPv4' && !net.internal) { - return net.address; - } - } - } - } catch { - // Ignore +// ============================================================================ +// Formatting Helpers +// ============================================================================ + +function formatTable(rows, columns) { + if (rows.length === 0) { + console.log('No items found.'); + return; + } + + // Calculate column widths + const widths = columns.map(col => { + const values = rows.map(row => String(row[col.key] ?? '').length); + return Math.max(col.header.length, ...values); + }); + + // Print header + const header = columns.map((col, i) => col.header.padEnd(widths[i])).join(' '); + console.log(header); + console.log(columns.map((_, i) => '─'.repeat(widths[i])).join('──')); + + // Print rows + for (const row of rows) { + const line = columns.map((col, i) => { + const value = String(row[col.key] ?? ''); + return value.padEnd(widths[i]); + }).join(' '); + console.log(line); } - return null; } -// Handle --help -if (args.includes('--help') || args.includes('-h')) { - console.log(` -NoteCode v${pkg.version} - AI Coding Task Management - -Usage: npx notecode [options] - -Options: - -p, --port Server port (default: ${DEFAULT_PORT}) - --no-browser Don't open browser automatically - -h, --help Show this help - -v, --version Show version - -Environment Variables: - PORT Server port (default: ${DEFAULT_PORT}) - HOST Server host (default: 0.0.0.0) - NO_BROWSER Set to 'true' to skip opening browser - NOTECODE_DATA_DIR Data directory (default: ~/.notecode) - -Examples: - npx notecode Start on default port - npx notecode -p 5000 Start on port 5000 - npx notecode --no-browser Start without opening browser -`); - process.exit(0); +function formatJson(data) { + console.log(JSON.stringify(data, null, 2)); } -// Handle --version -if (args.includes('--version') || args.includes('-v')) { - console.log(`notecode v${pkg.version}`); - process.exit(0); +function formatDate(dateStr) { + if (!dateStr) return '-'; + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +} + +function truncate(str, len) { + if (!str) return ''; + return str.length > len ? str.slice(0, len - 1) + '…' : str; +} + +// ============================================================================ +// API Client +// ============================================================================ + +async function apiRequest(apiUrl, method, path, body = null) { + const url = `${apiUrl}${path}`; + + try { + const options = { + method, + headers: { 'Content-Type': 'application/json' }, + }; + if (body) { + options.body = JSON.stringify(body); + } + + const response = await fetch(url, options); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || `HTTP ${response.status}`); + } + + return await response.json(); + } catch (error) { + if (error.cause?.code === 'ECONNREFUSED') { + throw new Error(`Cannot connect to NoteCode server at ${apiUrl}\nIs the server running? Start it with: notecode server start`); + } + throw error; + } } -async function main() { - // ASCII banner - NOTECODE +// ============================================================================ +// Server Command (current behavior) +// ============================================================================ + +async function startServer(options) { + const { createServer } = await import('../dist/infrastructure/server/fastify.server.js'); + const { initializeDatabase, closeDatabase } = await import('../dist/infrastructure/database/connection.js'); + const { DEFAULT_PORT, findAvailablePort, parsePort } = await import('../dist/infrastructure/server/port-utils.js'); + const { networkInterfaces } = await import('os'); + + const HOST = process.env.HOST || '0.0.0.0'; + const NO_BROWSER = process.env.NO_BROWSER === 'true' || options.noBrowser; + + function getLocalIP() { + try { + const nets = networkInterfaces(); + for (const name of Object.keys(nets)) { + for (const net of nets[name] || []) { + if (net.family === 'IPv4' && !net.internal) { + return net.address; + } + } + } + } catch { + // Ignore + } + return null; + } + + // ASCII banner console.log(` ╔══════════════════════════════════════════════════════════════════════════╗ ║ ║ @@ -93,20 +141,16 @@ async function main() { `); try { - // Determine port: CLI flag > env > auto-detect available port - const specifiedPort = parsePort(args); + const specifiedPort = options.port ? parseInt(options.port, 10) : null; const PORT = specifiedPort ?? await findAvailablePort(DEFAULT_PORT); - // Initialize database console.log(' 📦 Initializing database...'); await initializeDatabase(); - // Create and start server console.log(' 🌐 Starting server...'); const server = await createServer(); await server.listen({ port: PORT, host: HOST }); - // Get actual port (in case of dynamic allocation) const address = server.server.address(); const actualPort = typeof address === 'object' && address !== null ? address.port : PORT; const localIP = getLocalIP(); @@ -123,7 +167,6 @@ async function main() { Press Ctrl+C to stop `); - // Open browser if not disabled if (!NO_BROWSER) { try { const open = (await import('open')).default; @@ -133,11 +176,8 @@ async function main() { } } - // Graceful shutdown with timeout const shutdown = async (signal) => { console.log(`\n 🛑 Received ${signal}, shutting down...`); - - // Force exit after 3 seconds if graceful shutdown hangs const forceExit = setTimeout(() => { console.log(' ⚠️ Force exit'); process.exit(0); @@ -166,7 +206,7 @@ async function main() { Error: ${error.message} Troubleshooting: - 1. Check if port is available: npx notecode -p 5000 + 1. Check if port is available: notecode server start -p 5000 2. Check file permissions for data directory 3. Run with --help for more options `); @@ -174,4 +214,606 @@ async function main() { } } -main(); +// ============================================================================ +// Task Commands +// ============================================================================ + +async function taskList(options) { + const params = new URLSearchParams(); + if (options.status) params.set('status', options.status); + if (options.assignee) params.set('agentId', options.assignee); + if (options.projectId) params.set('projectId', options.projectId); + + const query = params.toString(); + const path = `/api/tasks${query ? '?' + query : ''}`; + + try { + const data = await apiRequest(options.apiUrl, 'GET', path); + + if (options.json) { + formatJson(data.tasks); + } else { + const rows = data.tasks.map(t => ({ + id: t.id.slice(0, 8), + status: t.status, + priority: t.priority ?? '-', + title: truncate(t.title, 50), + created: formatDate(t.createdAt), + })); + formatTable(rows, [ + { key: 'id', header: 'ID' }, + { key: 'status', header: 'Status' }, + { key: 'priority', header: 'Priority' }, + { key: 'title', header: 'Title' }, + { key: 'created', header: 'Created' }, + ]); + console.log(`\n${data.tasks.length} task(s) found`); + } + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +async function taskGet(taskId, options) { + try { + const data = await apiRequest(options.apiUrl, 'GET', `/api/tasks/${taskId}`); + + if (options.json) { + formatJson(data.task); + } else { + const t = data.task; + console.log(`Task: ${t.title}`); + console.log(`${'─'.repeat(60)}`); + console.log(`ID: ${t.id}`); + console.log(`Status: ${t.status}`); + console.log(`Priority: ${t.priority ?? 'none'}`); + console.log(`Description: ${t.description || '(none)'}`); + console.log(`Project ID: ${t.projectId}`); + console.log(`Created: ${formatDate(t.createdAt)}`); + console.log(`Updated: ${formatDate(t.updatedAt)}`); + if (t.provider) console.log(`Provider: ${t.provider}`); + if (t.model) console.log(`Model: ${t.model}`); + } + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +async function taskCreate(title, options) { + const body = { + title, + description: options.description ?? '', + priority: options.priority ?? null, + }; + if (options.projectId) body.projectId = options.projectId; + + try { + const data = await apiRequest(options.apiUrl, 'POST', '/api/tasks', body); + + if (options.json) { + formatJson(data.task); + } else { + console.log(`✅ Task created: ${data.task.id}`); + console.log(` Title: ${data.task.title}`); + } + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +async function taskUpdate(taskId, options) { + const body = {}; + if (options.status) body.status = options.status; + if (options.priority) body.priority = options.priority; + if (options.title) body.title = options.title; + if (options.description) body.description = options.description; + + if (Object.keys(body).length === 0) { + console.error('Error: No update fields provided. Use --status, --priority, --title, or --description.'); + process.exit(1); + } + + try { + const data = await apiRequest(options.apiUrl, 'PATCH', `/api/tasks/${taskId}`, body); + + if (options.json) { + formatJson(data.task); + } else { + console.log(`✅ Task updated: ${data.task.id}`); + console.log(` Status: ${data.task.status}`); + if (data.warnings?.length) { + for (const w of data.warnings) { + console.log(` ⚠️ ${w.message}`); + } + } + } + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +// ============================================================================ +// Session Commands +// ============================================================================ + +async function sessionList(options) { + const params = new URLSearchParams(); + if (options.task) params.set('taskId', options.task); + if (options.limit) params.set('limit', options.limit); + + const query = params.toString(); + const path = `/api/sessions${query ? '?' + query : ''}`; + + try { + const data = await apiRequest(options.apiUrl, 'GET', path); + + if (options.json) { + formatJson(data.sessions); + } else { + const rows = data.sessions.map(s => ({ + id: s.id.slice(0, 8), + status: s.status, + task: s.taskId?.slice(0, 8) ?? '-', + provider: s.provider ?? '-', + started: formatDate(s.startedAt), + duration: s.endedAt && s.startedAt + ? `${Math.round((new Date(s.endedAt) - new Date(s.startedAt)) / 1000)}s` + : '-', + })); + formatTable(rows, [ + { key: 'id', header: 'ID' }, + { key: 'status', header: 'Status' }, + { key: 'task', header: 'Task' }, + { key: 'provider', header: 'Provider' }, + { key: 'started', header: 'Started' }, + { key: 'duration', header: 'Duration' }, + ]); + console.log(`\n${data.sessions.length} session(s) found`); + } + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +async function sessionGet(sessionId, options) { + try { + const data = await apiRequest(options.apiUrl, 'GET', `/api/sessions/${sessionId}`); + + if (options.json) { + formatJson(data.session); + } else { + const s = data.session; + console.log(`Session: ${s.id}`); + console.log(`${'─'.repeat(60)}`); + console.log(`Status: ${s.status}`); + console.log(`Task ID: ${s.taskId ?? '(none)'}`); + console.log(`Provider: ${s.provider ?? '(none)'}`); + console.log(`Working Dir: ${s.workingDir ?? '(none)'}`); + console.log(`Started: ${formatDate(s.startedAt)}`); + if (s.endedAt) console.log(`Ended: ${formatDate(s.endedAt)}`); + if (s.tokenUsage) { + console.log(`Tokens: ${s.tokenUsage.inputTokens ?? 0} in / ${s.tokenUsage.outputTokens ?? 0} out`); + } + } + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +// ============================================================================ +// Approval Commands (Phase 2) +// ============================================================================ + +async function approvalList(options) { + try { + const data = await apiRequest(options.apiUrl, 'GET', '/api/approvals/pending'); + + if (options.json) { + formatJson(data.approvals); + } else { + if (data.approvals.length === 0) { + console.log('No pending approvals'); + return; + } + + const rows = data.approvals.map(a => ({ + id: a.id.slice(0, 8), + type: a.type ?? '-', + tool: a.payload?.toolName ?? '-', + category: a.category ?? '-', + session: a.sessionId?.slice(0, 8) ?? '-', + timeout: a.timeoutAt ? formatDate(a.timeoutAt) : '-', + })); + formatTable(rows, [ + { key: 'id', header: 'ID' }, + { key: 'type', header: 'Type' }, + { key: 'tool', header: 'Tool' }, + { key: 'category', header: 'Category' }, + { key: 'session', header: 'Session' }, + { key: 'timeout', header: 'Timeout' }, + ]); + console.log(`\n${data.approvals.length} pending approval(s)`); + } + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +async function approvalGet(approvalId, options) { + try { + const data = await apiRequest(options.apiUrl, 'GET', `/api/approvals/${approvalId}`); + + if (options.json) { + formatJson(data); + } else { + const a = data.approval; + console.log(`Approval: ${a.id}`); + console.log(`${'─'.repeat(60)}`); + console.log(`Status: ${a.status}`); + console.log(`Type: ${a.type ?? '(none)'}`); + console.log(`Category: ${a.category ?? '(none)'}`); + console.log(`Session ID: ${a.sessionId ?? '(none)'}`); + console.log(`Created: ${formatDate(a.createdAt)}`); + if (a.timeoutAt) console.log(`Timeout At: ${formatDate(a.timeoutAt)}`); + if (a.payload) { + console.log(`\nPayload:`); + console.log(` Tool: ${a.payload.toolName ?? '(none)'}`); + if (a.payload.toolInput) { + console.log(` Input: ${JSON.stringify(a.payload.toolInput).slice(0, 100)}...`); + } + } + if (data.diffs && data.diffs.length > 0) { + console.log(`\nRelated Diffs: ${data.diffs.length}`); + for (const diff of data.diffs) { + console.log(` - ${diff.filePath} (${diff.operation})`); + } + } + } + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +// ============================================================================ +// Watch Command (Phase 2) +// ============================================================================ + +async function watchSessions(options) { + const pollInterval = parseInt(options.interval, 10) || 2000; + let lastSessions = []; + let isFirstRun = true; + + console.log(`Watching NoteCode sessions (polling every ${pollInterval}ms)...`); + console.log('Press Ctrl+C to stop\n'); + + const poll = async () => { + try { + const [sessionsData, approvalsData] = await Promise.all([ + apiRequest(options.apiUrl, 'GET', '/api/sessions?limit=10'), + apiRequest(options.apiUrl, 'GET', '/api/approvals/pending'), + ]); + + const sessions = sessionsData.sessions || []; + const approvals = approvalsData.approvals || []; + + if (options.json) { + console.log(JSON.stringify({ timestamp: new Date().toISOString(), sessions, approvals })); + } else { + // Check for changes + const currentIds = sessions.map(s => `${s.id}:${s.status}`).join(','); + const lastIds = lastSessions.map(s => `${s.id}:${s.status}`).join(','); + + if (currentIds !== lastIds || isFirstRun) { + console.log(`\n[${new Date().toLocaleTimeString()}] Session Update`); + console.log('─'.repeat(60)); + + // Show running sessions + const running = sessions.filter(s => s.status === 'running'); + if (running.length > 0) { + console.log(`🟢 Running: ${running.length}`); + for (const s of running) { + console.log(` ${s.id.slice(0, 8)} - ${s.provider ?? 'unknown'}`); + } + } + + // Show pending approvals + if (approvals.length > 0) { + console.log(`\n⚠️ Pending Approvals: ${approvals.length}`); + for (const a of approvals) { + console.log(` ${a.id.slice(0, 8)} - ${a.payload?.toolName ?? 'unknown'} (${a.category})`); + } + } + + // Show recent completed + const completed = sessions.filter(s => s.status === 'completed').slice(0, 3); + if (completed.length > 0) { + console.log(`\n✅ Recent Completed: ${completed.length}`); + for (const s of completed) { + console.log(` ${s.id.slice(0, 8)} - ${formatDate(s.endedAt)}`); + } + } + + lastSessions = sessions; + isFirstRun = false; + } + } + } catch (error) { + console.error(`[${new Date().toLocaleTimeString()}] Error: ${error.message}`); + } + }; + + // Initial poll + await poll(); + + // Set up interval + const intervalId = setInterval(poll, pollInterval); + + // Handle graceful shutdown + process.on('SIGINT', () => { + clearInterval(intervalId); + console.log('\n\n👋 Watch stopped'); + process.exit(0); + }); +} + +// ============================================================================ +// Status Command (Phase 2) +// ============================================================================ + +async function showStatus(options) { + try { + // Fetch multiple endpoints in parallel + const [platformData, sessionsData, tasksData, approvalsData] = await Promise.all([ + apiRequest(options.apiUrl, 'GET', '/api/system/platform').catch(() => null), + apiRequest(options.apiUrl, 'GET', '/api/sessions?limit=100').catch(() => ({ sessions: [] })), + apiRequest(options.apiUrl, 'GET', '/api/tasks').catch(() => ({ tasks: [] })), + apiRequest(options.apiUrl, 'GET', '/api/approvals/pending').catch(() => ({ approvals: [] })), + ]); + + const sessions = sessionsData.sessions || []; + const tasks = tasksData.tasks || []; + const approvals = approvalsData.approvals || []; + + // Count by status + const sessionsByStatus = sessions.reduce((acc, s) => { + acc[s.status] = (acc[s.status] || 0) + 1; + return acc; + }, {}); + + const tasksByStatus = tasks.reduce((acc, t) => { + acc[t.status] = (acc[t.status] || 0) + 1; + return acc; + }, {}); + + if (options.json) { + formatJson({ + server: { url: options.apiUrl, platform: platformData }, + sessions: { total: sessions.length, byStatus: sessionsByStatus }, + tasks: { total: tasks.length, byStatus: tasksByStatus }, + pendingApprovals: approvals.length, + }); + } else { + console.log('NoteCode Server Status'); + console.log('─'.repeat(40)); + console.log(`Server: ${options.apiUrl}`); + if (platformData) { + console.log(`Platform: ${platformData.platform} (${platformData.arch})`); + } + console.log(''); + + console.log('Sessions'); + console.log('─'.repeat(40)); + console.log(`Total: ${sessions.length}`); + if (sessionsByStatus.running) console.log(` Running: ${sessionsByStatus.running}`); + if (sessionsByStatus.completed) console.log(` Completed: ${sessionsByStatus.completed}`); + if (sessionsByStatus.failed) console.log(` Failed: ${sessionsByStatus.failed}`); + console.log(''); + + console.log('Tasks'); + console.log('─'.repeat(40)); + console.log(`Total: ${tasks.length}`); + if (tasksByStatus['not-started']) console.log(` Not Started: ${tasksByStatus['not-started']}`); + if (tasksByStatus['in-progress']) console.log(` In Progress: ${tasksByStatus['in-progress']}`); + if (tasksByStatus.review) console.log(` Review: ${tasksByStatus.review}`); + if (tasksByStatus.done) console.log(` Done: ${tasksByStatus.done}`); + console.log(''); + + console.log('Approvals'); + console.log('─'.repeat(40)); + console.log(`Pending: ${approvals.length}`); + if (approvals.length > 0) { + console.log(''); + for (const a of approvals.slice(0, 3)) { + console.log(` ⚠️ ${a.id.slice(0, 8)} - ${a.payload?.toolName ?? 'unknown'}`); + } + if (approvals.length > 3) { + console.log(` ... and ${approvals.length - 3} more`); + } + } + } + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +// ============================================================================ +// Main Program +// ============================================================================ + +// Check for legacy invocation BEFORE commander parses +// Legacy: notecode, notecode -p 5000, notecode --no-browser +const args = process.argv.slice(2); +const knownCommands = ['server', 'task', 'session', 'approval', 'watch', 'status', 'help', '--help', '-h', '--version', '-V']; +const isLegacyInvocation = args.length === 0 || + (args[0] === '-p' || args[0] === '--port' || args[0] === '--no-browser') || + (!knownCommands.includes(args[0]) && !args[0].startsWith('--api-url')); + +if (isLegacyInvocation && !args.includes('--help') && !args.includes('-h')) { + // Parse legacy flags manually + const opts = {}; + const pIndex = args.indexOf('-p'); + if (pIndex !== -1 && args[pIndex + 1]) { + opts.port = args[pIndex + 1]; + } + const portIndex = args.findIndex(a => a.startsWith('--port=')); + if (portIndex !== -1) { + opts.port = args[portIndex].split('=')[1]; + } else if (args.indexOf('--port') !== -1) { + const pi = args.indexOf('--port'); + if (args[pi + 1]) opts.port = args[pi + 1]; + } + opts.noBrowser = args.includes('--no-browser'); + startServer(opts); +} else { + // Use commander for subcommand mode + const program = new Command(); + + program + .name('notecode') + .description('NoteCode - AI Coding Task Management') + .version(pkg.version) + .option('--api-url ', 'API server URL', DEFAULT_API_URL); + + // Server commands + const serverCmd = program + .command('server') + .description('Server management'); + + serverCmd + .command('start') + .description('Start the NoteCode server') + .option('-p, --port ', 'Server port') + .option('--no-browser', 'Don\'t open browser automatically') + .action(startServer); + + // Task commands + const taskCmd = program + .command('task') + .description('Task management'); + + taskCmd + .command('list') + .description('List tasks') + .option('--status ', 'Filter by status (not-started,in-progress,review,done,cancelled,archived)') + .option('--assignee ', 'Filter by assignee agent ID') + .option('--project-id ', 'Filter by project ID') + .option('--json', 'Output as JSON') + .action((options) => { + options.apiUrl = program.opts().apiUrl; + taskList(options); + }); + + taskCmd + .command('get ') + .description('Get task details') + .option('--json', 'Output as JSON') + .action((taskId, options) => { + options.apiUrl = program.opts().apiUrl; + taskGet(taskId, options); + }); + + taskCmd + .command('create ') + .description('Create a new task') + .option('-p, --priority <priority>', 'Priority (high, medium, low)') + .option('-d, --description <desc>', 'Task description') + .option('--project-id <id>', 'Project ID (uses active project if not specified)') + .option('--json', 'Output as JSON') + .action((title, options) => { + options.apiUrl = program.opts().apiUrl; + taskCreate(title, options); + }); + + taskCmd + .command('update <task-id>') + .description('Update a task') + .option('--status <status>', 'New status') + .option('--priority <priority>', 'New priority') + .option('--title <title>', 'New title') + .option('--description <desc>', 'New description') + .option('--json', 'Output as JSON') + .action((taskId, options) => { + options.apiUrl = program.opts().apiUrl; + taskUpdate(taskId, options); + }); + + // Session commands + const sessionCmd = program + .command('session') + .description('Session management'); + + sessionCmd + .command('list') + .description('List sessions') + .option('--task <task-id>', 'Filter by task ID') + .option('--limit <n>', 'Maximum number of sessions to return') + .option('--json', 'Output as JSON') + .action((options) => { + options.apiUrl = program.opts().apiUrl; + sessionList(options); + }); + + sessionCmd + .command('get <session-id>') + .description('Get session details') + .option('--json', 'Output as JSON') + .action((sessionId, options) => { + options.apiUrl = program.opts().apiUrl; + sessionGet(sessionId, options); + }); + + // Approval commands (Phase 2) + const approvalCmd = program + .command('approval') + .description('Approval management'); + + approvalCmd + .command('list') + .description('List pending approvals') + .option('--json', 'Output as JSON') + .action((options) => { + options.apiUrl = program.opts().apiUrl; + approvalList(options); + }); + + approvalCmd + .command('get <approval-id>') + .description('Get approval details') + .option('--json', 'Output as JSON') + .action((approvalId, options) => { + options.apiUrl = program.opts().apiUrl; + approvalGet(approvalId, options); + }); + + // Watch command (Phase 2) + program + .command('watch') + .description('Watch sessions and approvals in real-time') + .option('--interval <ms>', 'Poll interval in milliseconds', '2000') + .option('--json', 'Output as JSON (one line per update)') + .action((options) => { + options.apiUrl = program.opts().apiUrl; + watchSessions(options); + }); + + // Status command (Phase 2) + program + .command('status') + .description('Show server status overview') + .option('--json', 'Output as JSON') + .action((options) => { + options.apiUrl = program.opts().apiUrl; + showStatus(options); + }); + + program.parse(); +} diff --git a/backend/package.json b/backend/package.json index 6cf0987..53bb699 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "@fastify/static": "^6.12.0", "@lancedb/lancedb": "^0.23.0", "better-sqlite3": "^11.10.0", + "commander": "^14.0.3", "diff": "^8.0.3", "drizzle-orm": "^0.30.0", "fast-glob": "^3.3.3", diff --git a/cli/notecode.js b/cli/notecode.js new file mode 100755 index 0000000..333e8fd --- /dev/null +++ b/cli/notecode.js @@ -0,0 +1,119 @@ +#!/usr/bin/env node + +/** + * NoteCode CLI + * Command-line interface for managing tasks, sessions, projects, and agents + * + * Usage: + * notecode Start the NoteCode server (default) + * notecode serve [-p <port>] Start the NoteCode server + * notecode task list [--status <s>] List tasks + * notecode task get <id> [--json] Get task details + * notecode task create --title "..." Create a task + * notecode task update <id> [options] Update a task + * notecode session list [--task-id <id>] List sessions + * notecode session status <id> Get session details + * notecode approval list [--session <id>] List pending approvals + * notecode approval get <id> Get approval details + * notecode approval approve <id> Approve a request + * notecode approval reject <id> -r Reject a request (reason required) + * notecode watch [--json] Real-time activity monitoring + * notecode status [--json] Show system status summary + * notecode project list [--favorite] List all projects + * notecode project get <id> [--json] Get project details + * notecode project switch <id> Switch active project + * notecode project current Show current active project + * notecode agent list [--project <id>] List discovered agents + * notecode agent get <name> Get agent details + * notecode agent skills List available skills + * notecode agent spawn --task <id> Spawn agent (experimental) + */ + +import { Command } from 'commander'; +import { createTaskCommands } from './src/commands/task.js'; +import { createSessionCommands } from './src/commands/session.js'; +import { createApprovalCommands } from './src/commands/approval.js'; +import { createWatchCommand } from './src/commands/watch.js'; +import { createStatusCommand } from './src/commands/status.js'; +import { createProjectCommands } from './src/commands/project.js'; +import { createAgentCommands } from './src/commands/agent.js'; +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { spawn } from 'child_process'; + +// Get version from root package.json +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +let version = '0.1.0'; +try { + const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8')); + version = pkg.version; +} catch { + // Ignore +} + +/** + * Start the NoteCode server using the existing backend CLI + */ +function startServer(args = []) { + const serverCli = join(__dirname, '../backend/bin/cli.js'); + const child = spawn('node', [serverCli, ...args], { + stdio: 'inherit', + env: process.env, + }); + + child.on('exit', (code) => { + process.exit(code ?? 0); + }); +} + +// Check if we should start the server (no subcommand, or server-related flags) +const args = process.argv.slice(2); +const serverFlags = ['-p', '--port', '--no-browser']; +const subcommands = ['task', 'session', 'approval', 'watch', 'status', 'serve', 'help', '--help', '-h', '--version', '-V']; + +// If no args, or only server flags, start the server +const hasSubcommand = args.some(arg => subcommands.includes(arg)); +const hasOnlyServerFlags = args.length > 0 && args.every(arg => + serverFlags.includes(arg) || + (args.indexOf(arg) > 0 && serverFlags.includes(args[args.indexOf(arg) - 1])) +); + +if (args.length === 0 || (hasOnlyServerFlags && !hasSubcommand)) { + startServer(args); +} else { + // Run management CLI + const program = new Command(); + + program + .name('notecode') + .description('NoteCode CLI - AI Coding Task Management') + .version(version); + + // Server command (explicit) + program + .command('serve') + .description('Start the NoteCode server') + .option('-p, --port <port>', 'Server port') + .option('--no-browser', 'Do not open browser automatically') + .action((opts) => { + const serverArgs = []; + if (opts.port) serverArgs.push('-p', opts.port); + if (opts.browser === false) serverArgs.push('--no-browser'); + startServer(serverArgs); + }); + + // Register management subcommands + program.addCommand(createTaskCommands()); + program.addCommand(createSessionCommands()); + program.addCommand(createApprovalCommands()); + program.addCommand(createWatchCommand()); + program.addCommand(createStatusCommand()); + program.addCommand(createProjectCommands()); + program.addCommand(createAgentCommands()); + + // Parse and execute + program.parse(); +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/cli/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/cli/src/api.js b/cli/src/api.js new file mode 100644 index 0000000..d265b6c --- /dev/null +++ b/cli/src/api.js @@ -0,0 +1,152 @@ +/** + * NoteCode CLI - API Client + * Communicates with the NoteCode REST API + */ + +const API_BASE = process.env.NOTECODE_API_URL || 'http://localhost:41920'; + +/** + * Make an API request + * @param {string} method - HTTP method + * @param {string} path - API path (e.g., '/api/tasks') + * @param {object} body - Request body (for POST/PATCH) + * @returns {Promise<object>} - API response + */ +async function request(method, path, body = null) { + const url = `${API_BASE}${path}`; + + const options = { + method, + headers: { + 'Content-Type': 'application/json', + }, + }; + + if (body) { + options.body = JSON.stringify(body); + } + + try { + const response = await fetch(url, options); + const data = await response.json(); + + if (!response.ok) { + const error = new Error(data.error || `HTTP ${response.status}`); + error.status = response.status; + error.data = data; + throw error; + } + + return data; + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + throw new Error('Cannot connect to NoteCode server. Is it running on http://localhost:41920?'); + } + throw err; + } +} + +// Task API +export async function listTasks(filters = {}) { + const params = new URLSearchParams(); + if (filters.status) params.set('status', filters.status); + if (filters.priority) params.set('priority', filters.priority); + if (filters.projectId) params.set('projectId', filters.projectId); + if (filters.agentId) params.set('agentId', filters.agentId); + if (filters.search) params.set('search', filters.search); + + const query = params.toString(); + return request('GET', `/api/tasks${query ? '?' + query : ''}`); +} + +export async function getTask(id) { + return request('GET', `/api/tasks/${id}`); +} + +export async function createTask(data) { + return request('POST', '/api/tasks', data); +} + +export async function updateTask(id, data) { + return request('PATCH', `/api/tasks/${id}`, data); +} + +// Session API +export async function listSessions(filters = {}) { + const params = new URLSearchParams(); + if (filters.taskId) params.set('taskId', filters.taskId); + if (filters.limit) params.set('limit', filters.limit.toString()); + + const query = params.toString(); + return request('GET', `/api/sessions${query ? '?' + query : ''}`); +} + +export async function getSession(id) { + return request('GET', `/api/sessions/${id}`); +} + +export async function listRunningSessions() { + return request('GET', '/api/sessions/running'); +} + +// Approval API +export async function listPendingApprovals() { + return request('GET', '/api/approvals/pending'); +} + +export async function getApproval(id) { + return request('GET', `/api/approvals/${id}`); +} + +export async function approveApproval(id, message) { + return request('POST', `/api/approvals/${id}/approve`, { decidedBy: message || 'cli' }); +} + +export async function rejectApproval(id, reason) { + return request('POST', `/api/approvals/${id}/reject`, { decidedBy: reason || 'cli' }); +} + +export async function listApprovalsBySession(sessionId) { + return request('GET', `/api/approvals/session/${sessionId}`); +} + +export async function getApprovalStatus(id) { + return request('GET', `/api/approvals/${id}/status`); +} + +// Project API +export async function listProjects(filters = {}) { + const params = new URLSearchParams(); + if (filters.search) params.set('search', filters.search); + if (filters.favorite) params.set('favorite', 'true'); + + const query = params.toString(); + return request('GET', `/api/projects${query ? '?' + query : ''}`); +} + +export async function getProject(id) { + return request('GET', `/api/projects/${id}`); +} + +export async function getRecentProjects(limit = 10) { + return request('GET', `/api/projects/recent?limit=${limit}`); +} + +export async function switchProject(projectId) { + return request('PATCH', '/api/settings', { currentActiveProjectId: projectId }); +} + +export async function getSettings() { + return request('GET', '/api/settings'); +} + +// Agent Discovery API (per-project agent discovery) +export async function discoverAgents(projectId, provider) { + const params = provider ? `?provider=${provider}` : ''; + return request('GET', `/api/projects/${projectId}/discovery/agents${params}`); +} + +export async function discoverSkills(projectId, provider) { + const params = provider ? `?provider=${provider}` : ''; + return request('GET', `/api/projects/${projectId}/discovery/skills${params}`); +} diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js new file mode 100644 index 0000000..492f436 --- /dev/null +++ b/cli/src/commands/agent.js @@ -0,0 +1,223 @@ +/** + * NoteCode CLI - Agent Commands + * Agent discovery and management + */ + +import { Command } from 'commander'; +import { + discoverAgents, + discoverSkills, + getSettings, + getProject, +} from '../api.js'; +import { + formatAgentHeader, + formatAgentRow, + formatAgentDetails, + formatSkillHeader, + formatSkillRow, + printError, + printSuccess, +} from '../formatters.js'; + +/** + * Create the agent command group + */ +export function createAgentCommands() { + const agent = new Command('agent') + .description('Agent discovery and management'); + + // notecode agent list [--project <id>] [--provider <provider>] + agent + .command('list') + .description('List available agents for a project') + .option('-p, --project <id>', 'Project ID (uses active project if not specified)') + .option('--provider <provider>', 'Provider (anthropic, google, openai)') + .option('--json', 'Output as JSON') + .action(async (opts) => { + try { + // Determine project ID + let projectId = opts.project; + if (!projectId) { + const settings = await getSettings(); + projectId = settings.currentActiveProjectId; + if (!projectId) { + printError('No project specified and no active project set. Use --project <id> or set an active project.'); + process.exit(1); + } + } + + const result = await discoverAgents(projectId, opts.provider); + const agents = result.agents || []; + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + if (agents.length === 0) { + console.log(`No agents found for project (provider: ${result.provider})`); + return; + } + + console.log(`Agents for project (provider: ${result.provider}):`); + console.log(''); + console.log(formatAgentHeader()); + console.log('-'.repeat(110)); + for (const ag of agents) { + console.log(formatAgentRow(ag)); + } + console.log(''); + console.log(`Total: ${agents.length} agent(s)`); + } catch (err) { + printError(err.message); + process.exit(1); + } + }); + + // notecode agent get <name> [--project <id>] + agent + .command('get <name>') + .description('Get details of a specific agent') + .option('-p, --project <id>', 'Project ID (uses active project if not specified)') + .option('--provider <provider>', 'Provider (anthropic, google, openai)') + .option('--json', 'Output as JSON') + .action(async (name, opts) => { + try { + // Determine project ID + let projectId = opts.project; + if (!projectId) { + const settings = await getSettings(); + projectId = settings.currentActiveProjectId; + if (!projectId) { + printError('No project specified and no active project set. Use --project <id> or set an active project.'); + process.exit(1); + } + } + + const result = await discoverAgents(projectId, opts.provider); + const agents = result.agents || []; + + // Find agent by name (case-insensitive) + const agent = agents.find(a => + a.name.toLowerCase() === name.toLowerCase() + ); + + if (!agent) { + printError(`Agent "${name}" not found. Available agents: ${agents.map(a => a.name).join(', ') || 'none'}`); + process.exit(1); + } + + if (opts.json) { + console.log(JSON.stringify({ agent, provider: result.provider }, null, 2)); + return; + } + + console.log(formatAgentDetails(agent)); + } catch (err) { + printError(err.message); + process.exit(1); + } + }); + + // notecode agent skills [--project <id>] + agent + .command('skills') + .description('List available skills for a project') + .option('-p, --project <id>', 'Project ID (uses active project if not specified)') + .option('--provider <provider>', 'Provider (anthropic, google, openai)') + .option('--json', 'Output as JSON') + .action(async (opts) => { + try { + // Determine project ID + let projectId = opts.project; + if (!projectId) { + const settings = await getSettings(); + projectId = settings.currentActiveProjectId; + if (!projectId) { + printError('No project specified and no active project set. Use --project <id> or set an active project.'); + process.exit(1); + } + } + + const result = await discoverSkills(projectId, opts.provider); + const skills = result.skills || []; + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + if (skills.length === 0) { + console.log(`No skills found for project (provider: ${result.provider})`); + return; + } + + console.log(`Skills for project (provider: ${result.provider}):`); + console.log(''); + console.log(formatSkillHeader()); + console.log('-'.repeat(110)); + for (const skill of skills) { + console.log(formatSkillRow(skill)); + } + console.log(''); + console.log(`Total: ${skills.length} skill(s)`); + } catch (err) { + printError(err.message); + process.exit(1); + } + }); + + // notecode agent spawn --task <id> --prompt "<prompt>" [--skills] + agent + .command('spawn') + .description('Spawn an agent to work on a task (experimental)') + .requiredOption('-t, --task <id>', 'Task ID to work on') + .requiredOption('--prompt <prompt>', 'Prompt for the agent') + .option('-s, --skills <skills>', 'Comma-separated list of skills to use') + .option('-a, --agent <name>', 'Agent name to use') + .option('--provider <provider>', 'Provider (anthropic, google, openai)') + .option('--json', 'Output as JSON') + .action(async (opts) => { + try { + // This is a placeholder - the actual spawn functionality would need + // to be implemented in the backend. For now, we'll show what would be done. + const spawnConfig = { + taskId: opts.task, + prompt: opts.prompt, + skills: opts.skills ? opts.skills.split(',').map(s => s.trim()) : [], + agentName: opts.agent, + provider: opts.provider, + }; + + if (opts.json) { + console.log(JSON.stringify({ + message: 'Agent spawn not yet implemented in backend', + config: spawnConfig, + }, null, 2)); + return; + } + + console.log('Agent Spawn Configuration:'); + console.log(` Task ID: ${spawnConfig.taskId}`); + console.log(` Prompt: ${spawnConfig.prompt}`); + if (spawnConfig.skills.length > 0) { + console.log(` Skills: ${spawnConfig.skills.join(', ')}`); + } + if (spawnConfig.agentName) { + console.log(` Agent: ${spawnConfig.agentName}`); + } + if (spawnConfig.provider) { + console.log(` Provider: ${spawnConfig.provider}`); + } + console.log(''); + console.log('Note: Agent spawn functionality is experimental.'); + console.log('Use "notecode task start <id>" to start a session on this task.'); + } catch (err) { + printError(err.message); + process.exit(1); + } + }); + + return agent; +} diff --git a/cli/src/commands/approval.js b/cli/src/commands/approval.js new file mode 100644 index 0000000..3b03fa2 --- /dev/null +++ b/cli/src/commands/approval.js @@ -0,0 +1,117 @@ +/** + * NoteCode CLI - Approval Commands + * Manage approval requests for tool/diff operations + */ + +import { Command } from 'commander'; +import { + listPendingApprovals, + getApproval, + approveApproval, + rejectApproval, + listApprovalsBySession, +} from '../api.js'; +import { + formatApprovalRow, + formatApprovalHeader, + formatApprovalDetails, + printError, + printSuccess, +} from '../formatters.js'; + +export function createApprovalCommands() { + const approval = new Command('approval') + .description('Manage approval requests'); + + // approval list + approval + .command('list') + .description('List pending approvals') + .option('--session <id>', 'Filter by session ID') + .option('--json', 'Output as JSON') + .action(async (opts) => { + try { + let approvals; + if (opts.session) { + const result = await listApprovalsBySession(opts.session); + approvals = result.approvals; + } else { + const result = await listPendingApprovals(); + approvals = result.approvals; + } + + if (opts.json) { + console.log(JSON.stringify(approvals, null, 2)); + return; + } + + if (approvals.length === 0) { + console.log('No pending approvals'); + return; + } + + console.log(formatApprovalHeader()); + console.log('-'.repeat(100)); + for (const approval of approvals) { + console.log(formatApprovalRow(approval)); + } + console.log(`\n${approvals.length} approval(s) pending`); + } catch (err) { + printError(err.message); + process.exit(1); + } + }); + + // approval get <id> + approval + .command('get <id>') + .description('Get approval details') + .option('--json', 'Output as JSON') + .action(async (id, opts) => { + try { + const result = await getApproval(id); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(formatApprovalDetails(result.approval, result.diffs)); + } catch (err) { + printError(err.message); + process.exit(1); + } + }); + + // approval approve <id> + approval + .command('approve <id>') + .description('Approve a pending request') + .option('-m, --message <message>', 'Approval message/reason') + .action(async (id, opts) => { + try { + await approveApproval(id, opts.message); + printSuccess(`Approved: ${id}`); + } catch (err) { + printError(err.message); + process.exit(1); + } + }); + + // approval reject <id> + approval + .command('reject <id>') + .description('Reject a pending request') + .requiredOption('-r, --reason <reason>', 'Rejection reason (required)') + .action(async (id, opts) => { + try { + await rejectApproval(id, opts.reason); + printSuccess(`Rejected: ${id}`); + } catch (err) { + printError(err.message); + process.exit(1); + } + }); + + return approval; +} diff --git a/cli/src/commands/project.js b/cli/src/commands/project.js new file mode 100644 index 0000000..afaace5 --- /dev/null +++ b/cli/src/commands/project.js @@ -0,0 +1,164 @@ +/** + * NoteCode CLI - Project Commands + * Project management operations + */ + +import { Command } from 'commander'; +import { + listProjects, + getProject, + getRecentProjects, + switchProject, + getSettings, +} from '../api.js'; +import { + formatProjectHeader, + formatProjectRow, + formatProjectDetails, + printError, + printSuccess, +} from '../formatters.js'; + +/** + * Create the project command group + */ +export function createProjectCommands() { + const project = new Command('project') + .description('Project management'); + + // notecode project list + project + .command('list') + .description('List all projects') + .option('--favorite', 'Show only favorite projects') + .option('--recent [limit]', 'Show recent projects (default: 10)') + .option('-s, --search <query>', 'Search projects by name') + .option('--json', 'Output as JSON') + .action(async (opts) => { + try { + let projects; + + if (opts.recent !== undefined) { + const limit = typeof opts.recent === 'string' ? parseInt(opts.recent, 10) : 10; + const result = await getRecentProjects(limit); + projects = result.projects; + } else { + const result = await listProjects({ + search: opts.search, + favorite: opts.favorite, + }); + projects = result.projects; + } + + // Get active project from settings + const settings = await getSettings(); + const activeProjectId = settings.currentActiveProjectId; + + if (opts.json) { + console.log(JSON.stringify({ projects, activeProjectId }, null, 2)); + return; + } + + if (projects.length === 0) { + console.log('No projects found'); + return; + } + + console.log(formatProjectHeader()); + console.log('-'.repeat(100)); + for (const proj of projects) { + console.log(formatProjectRow(proj, proj.id === activeProjectId)); + } + console.log(''); + console.log(`Total: ${projects.length} project(s)`); + if (activeProjectId) { + console.log(`Active: ${activeProjectId.slice(0, 8)}...`); + } + } catch (err) { + printError(err.message); + process.exit(1); + } + }); + + // notecode project get <id> + project + .command('get <id>') + .description('Get project details') + .option('--json', 'Output as JSON') + .action(async (id, opts) => { + try { + const result = await getProject(id); + const project = result.project; + + // Get active project from settings + const settings = await getSettings(); + const isActive = settings.currentActiveProjectId === project.id; + + if (opts.json) { + console.log(JSON.stringify({ project, isActive }, null, 2)); + return; + } + + console.log(formatProjectDetails(project, isActive)); + } catch (err) { + printError(err.message); + process.exit(1); + } + }); + + // notecode project switch <id> + project + .command('switch <id>') + .description('Switch active project') + .action(async (id) => { + try { + // Verify project exists first + const result = await getProject(id); + const project = result.project; + + // Switch active project + await switchProject(id); + + printSuccess(`Switched to project: ${project.name}`); + console.log(` Path: ${project.path}`); + } catch (err) { + printError(err.message); + process.exit(1); + } + }); + + // notecode project current + project + .command('current') + .description('Show current active project') + .option('--json', 'Output as JSON') + .action(async (opts) => { + try { + const settings = await getSettings(); + + if (!settings.currentActiveProjectId) { + if (opts.json) { + console.log(JSON.stringify({ project: null }, null, 2)); + } else { + console.log('No active project set. Use "notecode project switch <id>" to set one.'); + } + return; + } + + const result = await getProject(settings.currentActiveProjectId); + const project = result.project; + + if (opts.json) { + console.log(JSON.stringify({ project }, null, 2)); + return; + } + + console.log(formatProjectDetails(project, true)); + } catch (err) { + printError(err.message); + process.exit(1); + } + }); + + return project; +} diff --git a/cli/src/commands/session.js b/cli/src/commands/session.js new file mode 100644 index 0000000..ac9e97a --- /dev/null +++ b/cli/src/commands/session.js @@ -0,0 +1,118 @@ +/** + * NoteCode CLI - Session Commands + */ + +import { Command } from 'commander'; +import * as api from '../api.js'; +import { + formatSessionRow, + formatSessionHeader, + formatSessionDetails, + printError, +} from '../formatters.js'; + +export function createSessionCommands() { + const session = new Command('session') + .description('Manage sessions'); + + // session list + session + .command('list') + .description('List sessions') + .option('--task-id <id>', 'Filter by task ID') + .option('--status <status>', 'Filter by status (running, completed, etc.)') + .option('--limit <n>', 'Maximum number of results', '20') + .option('--json', 'Output as JSON') + .action(async (opts) => { + try { + let sessions; + + // If filtering by running status, use the dedicated endpoint + if (opts.status === 'running') { + const result = await api.listRunningSessions(); + sessions = result.sessions; + } else { + const result = await api.listSessions({ + taskId: opts.taskId, + limit: parseInt(opts.limit, 10), + }); + sessions = result.sessions; + + // Client-side filter by status if specified + if (opts.status && opts.status !== 'running') { + sessions = sessions.filter(s => s.status === opts.status); + } + } + + if (opts.json) { + console.log(JSON.stringify({ sessions }, null, 2)); + return; + } + + if (sessions.length === 0) { + console.log('No sessions found.'); + return; + } + + console.log(formatSessionHeader()); + console.log('-'.repeat(100)); + sessions.forEach(s => console.log(formatSessionRow(s))); + console.log(`\n${sessions.length} session(s) found.`); + } catch (err) { + printError(err.message); + process.exit(1); + } + }); + + // session status <id> (alias for get, more intuitive) + session + .command('status <id>') + .description('Get session status and details') + .option('--json', 'Output as JSON') + .action(async (id, opts) => { + try { + const { session: sessionData } = await api.getSession(id); + + if (opts.json) { + console.log(JSON.stringify({ session: sessionData }, null, 2)); + return; + } + + console.log(formatSessionDetails(sessionData)); + } catch (err) { + if (err.status === 404) { + printError(`Session not found: ${id}`); + } else { + printError(err.message); + } + process.exit(1); + } + }); + + // session get <id> (same as status, for consistency) + session + .command('get <id>') + .description('Get session details (alias for status)') + .option('--json', 'Output as JSON') + .action(async (id, opts) => { + try { + const { session: sessionData } = await api.getSession(id); + + if (opts.json) { + console.log(JSON.stringify({ session: sessionData }, null, 2)); + return; + } + + console.log(formatSessionDetails(sessionData)); + } catch (err) { + if (err.status === 404) { + printError(`Session not found: ${id}`); + } else { + printError(err.message); + } + process.exit(1); + } + }); + + return session; +} diff --git a/cli/src/commands/status.js b/cli/src/commands/status.js new file mode 100644 index 0000000..4ead9a0 --- /dev/null +++ b/cli/src/commands/status.js @@ -0,0 +1,224 @@ +/** + * NoteCode CLI - Status Command + * Show overall system status summary + */ + +import { Command } from 'commander'; +import { printError } from '../formatters.js'; + +const API_BASE = process.env.NOTECODE_API_URL || 'http://localhost:41920'; + +// ANSI colors +const colors = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + red: '\x1b[31m', + gray: '\x1b[90m', +}; + +/** + * Fetch with error handling + */ +async function safeFetch(url) { + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } catch (err) { + return null; + } +} + +/** + * Get task counts by status + */ +async function getTaskCounts() { + const result = await safeFetch(`${API_BASE}/api/tasks`); + if (!result || !result.tasks) return null; + + const counts = { + 'not-started': 0, + 'in-progress': 0, + 'review': 0, + 'done': 0, + 'cancelled': 0, + 'archived': 0, + }; + + for (const task of result.tasks) { + if (counts.hasOwnProperty(task.status)) { + counts[task.status]++; + } + } + + return counts; +} + +/** + * Get session counts + */ +async function getSessionCounts() { + const running = await safeFetch(`${API_BASE}/api/sessions/running`); + const all = await safeFetch(`${API_BASE}/api/sessions?limit=100`); + + const runningCount = running?.sessions?.length || 0; + const pausedCount = all?.sessions?.filter(s => s.status === 'paused').length || 0; + + return { running: runningCount, paused: pausedCount }; +} + +/** + * Get pending approval count + */ +async function getPendingApprovals() { + const result = await safeFetch(`${API_BASE}/api/approvals/pending`); + return result?.approvals?.length || 0; +} + +/** + * Check server health + */ +async function checkHealth() { + try { + const res = await fetch(`${API_BASE}/health`); + if (res.ok) { + const data = await res.json(); + return { ok: true, ...data }; + } + return { ok: false, status: res.status }; + } catch (err) { + return { ok: false, error: err.message }; + } +} + +/** + * Get version info + */ +async function getVersion() { + return await safeFetch(`${API_BASE}/api/version`); +} + +/** + * Format status output + */ +function formatStatus(health, version, tasks, sessions, approvals) { + const lines = []; + + // Header + lines.push(`${colors.bold}NoteCode Status${colors.reset}`); + lines.push('═'.repeat(50)); + lines.push(''); + + // Server status + if (health.ok) { + lines.push(`${colors.green}●${colors.reset} Server: ${colors.green}Running${colors.reset} on ${API_BASE}`); + } else { + lines.push(`${colors.red}●${colors.reset} Server: ${colors.red}Not reachable${colors.reset}`); + if (health.error) { + lines.push(` ${colors.dim}${health.error}${colors.reset}`); + } + } + + // Version + if (version) { + lines.push(` Version: ${version.version || 'unknown'}`); + } + + lines.push(''); + + // Tasks + if (tasks) { + lines.push(`${colors.cyan}Tasks:${colors.reset}`); + + if (tasks['not-started'] > 0) { + lines.push(` ${colors.gray}●${colors.reset} Not Started: ${tasks['not-started']}`); + } + if (tasks['in-progress'] > 0) { + lines.push(` ${colors.blue}●${colors.reset} In Progress: ${tasks['in-progress']}`); + } + if (tasks['review'] > 0) { + lines.push(` ${colors.yellow}●${colors.reset} Review: ${tasks['review']}`); + } + if (tasks['done'] > 0) { + lines.push(` ${colors.green}●${colors.reset} Done: ${tasks['done']}`); + } + if (tasks['cancelled'] > 0) { + lines.push(` ${colors.red}●${colors.reset} Cancelled: ${tasks['cancelled']}`); + } + + const total = Object.values(tasks).reduce((a, b) => a + b, 0); + if (total === 0) { + lines.push(` ${colors.dim}No tasks${colors.reset}`); + } + + lines.push(''); + } + + // Sessions + lines.push(`${colors.cyan}Sessions:${colors.reset}`); + if (sessions.running > 0) { + lines.push(` ${colors.blue}●${colors.reset} Running: ${sessions.running}`); + } + if (sessions.paused > 0) { + lines.push(` ${colors.yellow}●${colors.reset} Paused: ${sessions.paused}`); + } + if (sessions.running === 0 && sessions.paused === 0) { + lines.push(` ${colors.dim}No active sessions${colors.reset}`); + } + + lines.push(''); + + // Approvals + if (approvals > 0) { + lines.push(`${colors.yellow}⚠ Pending Approvals: ${approvals}${colors.reset}`); + lines.push(` ${colors.dim}Run: notecode approval list${colors.reset}`); + } else { + lines.push(`${colors.cyan}Approvals:${colors.reset} ${colors.dim}None pending${colors.reset}`); + } + + return lines.join('\n'); +} + +export function createStatusCommand() { + const status = new Command('status') + .description('Show overall system status') + .option('--json', 'Output as JSON') + .action(async (opts) => { + try { + // Gather all status info in parallel + const [health, version, tasks, sessions, approvals] = await Promise.all([ + checkHealth(), + getVersion(), + getTaskCounts(), + getSessionCounts(), + getPendingApprovals(), + ]); + + if (opts.json) { + console.log(JSON.stringify({ + server: { + status: health.ok ? 'running' : 'unreachable', + url: API_BASE, + version: version?.version, + }, + tasks, + sessions, + pendingApprovals: approvals, + }, null, 2)); + return; + } + + console.log(formatStatus(health, version, tasks, sessions, approvals)); + } catch (err) { + printError(err.message); + process.exit(1); + } + }); + + return status; +} diff --git a/cli/src/commands/task.js b/cli/src/commands/task.js new file mode 100644 index 0000000..8b90f9f --- /dev/null +++ b/cli/src/commands/task.js @@ -0,0 +1,360 @@ +/** + * NoteCode CLI - Task Commands + */ + +import { Command } from 'commander'; +import { readFileSync, writeFileSync } from 'fs'; +import * as api from '../api.js'; +import { + formatTaskRow, + formatTaskHeader, + formatTaskDetails, + printError, + printSuccess, +} from '../formatters.js'; + +export function createTaskCommands() { + const task = new Command('task') + .description('Manage tasks'); + + // task list + task + .command('list') + .description('List all tasks') + .option('--status <status>', 'Filter by status (comma-separated: not-started,in-progress,review,done,cancelled,archived)') + .option('--priority <priority>', 'Filter by priority (comma-separated: high,medium,low)') + .option('--project <id>', 'Filter by project ID') + .option('--assignee <id>', 'Filter by agent/assignee ID') + .option('--search <query>', 'Search in title/description') + .option('--json', 'Output as JSON') + .action(async (opts) => { + try { + const { tasks } = await api.listTasks({ + status: opts.status, + priority: opts.priority, + projectId: opts.project, + agentId: opts.assignee, + search: opts.search, + }); + + if (opts.json) { + console.log(JSON.stringify({ tasks }, null, 2)); + return; + } + + if (tasks.length === 0) { + console.log('No tasks found.'); + return; + } + + console.log(formatTaskHeader()); + console.log('-'.repeat(100)); + tasks.forEach(t => console.log(formatTaskRow(t))); + console.log(`\n${tasks.length} task(s) found.`); + } catch (err) { + printError(err.message); + process.exit(1); + } + }); + + // task get <id> + task + .command('get <id>') + .description('Get task details') + .option('--json', 'Output as JSON') + .action(async (id, opts) => { + try { + const { task: taskData } = await api.getTask(id); + + if (opts.json) { + console.log(JSON.stringify({ task: taskData }, null, 2)); + return; + } + + console.log(formatTaskDetails(taskData)); + } catch (err) { + if (err.status === 404) { + printError(`Task not found: ${id}`); + } else { + printError(err.message); + } + process.exit(1); + } + }); + + // task create + task + .command('create') + .description('Create a new task') + .requiredOption('--title <title>', 'Task title') + .option('--description <desc>', 'Task description') + .option('--priority <priority>', 'Priority (high, medium, low)') + .option('--assignee <id>', 'Agent ID to assign') + .option('--project <id>', 'Project ID (uses active project if not set)') + .option('--provider <provider>', 'AI provider (anthropic, google, openai)') + .option('--model <model>', 'Model name') + .option('--skills <skills>', 'Comma-separated skill names') + .option('--context-files <files>', 'Comma-separated context file paths') + .option('--auto-branch', 'Enable auto-branch on start') + .option('--auto-commit', 'Enable auto-commit on completion') + .option('--json', 'Output as JSON') + .action(async (opts) => { + try { + const data = { + title: opts.title, + description: opts.description || '', + priority: opts.priority || null, + agentId: opts.assignee, + projectId: opts.project, + provider: opts.provider, + model: opts.model, + skills: opts.skills ? opts.skills.split(',').map(s => s.trim()) : [], + contextFiles: opts.contextFiles ? opts.contextFiles.split(',').map(f => f.trim()) : [], + autoBranch: opts.autoBranch || false, + autoCommit: opts.autoCommit || false, + }; + + const { task: taskData } = await api.createTask(data); + + if (opts.json) { + console.log(JSON.stringify({ task: taskData }, null, 2)); + return; + } + + printSuccess(`Task created: ${taskData.id}`); + console.log(formatTaskDetails(taskData)); + } catch (err) { + printError(err.message); + process.exit(1); + } + }); + + // task update <id> + task + .command('update <id>') + .description('Update a task') + .option('--title <title>', 'New title') + .option('--description <desc>', 'New description') + .option('--status <status>', 'New status (not-started, in-progress, review, done, cancelled, archived)') + .option('--priority <priority>', 'New priority (high, medium, low)') + .option('--assignee <id>', 'Agent ID to assign (use "none" to unassign)') + .option('--provider <provider>', 'AI provider (anthropic, google, openai, or "none" to clear)') + .option('--model <model>', 'Model name (or "none" to clear)') + .option('--skills <skills>', 'Comma-separated skill names') + .option('--context-files <files>', 'Comma-separated context file paths') + .option('--auto-branch', 'Enable auto-branch') + .option('--no-auto-branch', 'Disable auto-branch') + .option('--auto-commit', 'Enable auto-commit') + .option('--no-auto-commit', 'Disable auto-commit') + .option('--json', 'Output as JSON') + .action(async (id, opts) => { + try { + const data = {}; + + if (opts.title) data.title = opts.title; + if (opts.description !== undefined) data.description = opts.description; + if (opts.status) data.status = opts.status; + if (opts.priority) data.priority = opts.priority; + if (opts.assignee) { + data.agentId = opts.assignee === 'none' ? null : opts.assignee; + } + if (opts.provider) { + data.provider = opts.provider === 'none' ? null : opts.provider; + } + if (opts.model) { + data.model = opts.model === 'none' ? null : opts.model; + } + if (opts.skills) { + data.skills = opts.skills.split(',').map(s => s.trim()); + } + if (opts.contextFiles) { + data.contextFiles = opts.contextFiles.split(',').map(f => f.trim()); + } + if (opts.autoBranch !== undefined) { + data.autoBranch = opts.autoBranch; + } + if (opts.autoCommit !== undefined) { + data.autoCommit = opts.autoCommit; + } + + if (Object.keys(data).length === 0) { + printError('No update options provided. Use --help to see available options.'); + process.exit(1); + } + + const { task: taskData, warnings } = await api.updateTask(id, data); + + if (opts.json) { + console.log(JSON.stringify({ task: taskData, warnings }, null, 2)); + return; + } + + printSuccess(`Task updated: ${taskData.id}`); + + if (warnings && warnings.length > 0) { + console.log('\nWarnings:'); + warnings.forEach(w => console.log(` ⚠️ ${w.message}`)); + } + + console.log(''); + console.log(formatTaskDetails(taskData)); + } catch (err) { + if (err.status === 404) { + printError(`Task not found: ${id}`); + } else { + printError(err.message); + } + process.exit(1); + } + }); + + // task export + task + .command('export') + .description('Export tasks to JSON file or stdout') + .option('--status <status>', 'Filter by status (comma-separated)') + .option('--project <id>', 'Filter by project ID') + .option('-o, --output <file>', 'Output file (defaults to stdout)') + .action(async (opts) => { + try { + const { tasks } = await api.listTasks({ + status: opts.status, + projectId: opts.project, + }); + + const exportData = { + exportedAt: new Date().toISOString(), + count: tasks.length, + tasks: tasks.map(t => ({ + title: t.title, + description: t.description, + status: t.status, + priority: t.priority, + projectId: t.projectId, + agentId: t.agentId, + agentRole: t.agentRole, + provider: t.provider, + model: t.model, + skills: t.skills, + contextFiles: t.contextFiles, + autoBranch: t.autoBranch, + autoCommit: t.autoCommit, + // Metadata for reference (not imported) + _originalId: t.id, + _createdAt: t.createdAt, + _updatedAt: t.updatedAt, + })), + }; + + const json = JSON.stringify(exportData, null, 2); + + if (opts.output) { + writeFileSync(opts.output, json, 'utf-8'); + printSuccess(`Exported ${tasks.length} task(s) to ${opts.output}`); + } else { + console.log(json); + } + } catch (err) { + printError(err.message); + process.exit(1); + } + }); + + // task import + task + .command('import <file>') + .description('Import tasks from JSON file') + .option('--project <id>', 'Override project ID for all imported tasks') + .option('--dry-run', 'Show what would be imported without creating tasks') + .option('--json', 'Output as JSON') + .action(async (file, opts) => { + try { + const content = readFileSync(file, 'utf-8'); + const data = JSON.parse(content); + + if (!data.tasks || !Array.isArray(data.tasks)) { + printError('Invalid import file: missing "tasks" array'); + process.exit(1); + } + + const results = { + imported: [], + failed: [], + }; + + for (const taskData of data.tasks) { + const createData = { + title: taskData.title, + description: taskData.description || '', + status: taskData.status, + priority: taskData.priority, + projectId: opts.project || taskData.projectId, + agentId: taskData.agentId, + agentRole: taskData.agentRole, + provider: taskData.provider, + model: taskData.model, + skills: taskData.skills || [], + contextFiles: taskData.contextFiles || [], + autoBranch: taskData.autoBranch || false, + autoCommit: taskData.autoCommit || false, + }; + + if (opts.dryRun) { + results.imported.push({ + title: createData.title, + status: createData.status, + priority: createData.priority, + dryRun: true, + }); + continue; + } + + try { + const { task: newTask } = await api.createTask(createData); + results.imported.push({ + id: newTask.id, + title: newTask.title, + }); + } catch (err) { + results.failed.push({ + title: createData.title, + error: err.message, + }); + } + } + + if (opts.json) { + console.log(JSON.stringify(results, null, 2)); + return; + } + + if (opts.dryRun) { + console.log('Dry run - would import:'); + results.imported.forEach(t => { + console.log(` ✓ ${t.title} (${t.status || 'not-started'}, ${t.priority || 'none'})`); + }); + console.log(`\n${results.imported.length} task(s) would be imported.`); + } else { + if (results.imported.length > 0) { + printSuccess(`Imported ${results.imported.length} task(s):`); + results.imported.forEach(t => console.log(` - ${t.id.slice(0, 8)}: ${t.title}`)); + } + if (results.failed.length > 0) { + console.log('\nFailed to import:'); + results.failed.forEach(t => console.log(` ✗ ${t.title}: ${t.error}`)); + } + } + } catch (err) { + if (err.code === 'ENOENT') { + printError(`File not found: ${file}`); + } else if (err instanceof SyntaxError) { + printError(`Invalid JSON in file: ${err.message}`); + } else { + printError(err.message); + } + process.exit(1); + } + }); + + return task; +} diff --git a/cli/src/commands/watch.js b/cli/src/commands/watch.js new file mode 100644 index 0000000..ee0ee9f --- /dev/null +++ b/cli/src/commands/watch.js @@ -0,0 +1,206 @@ +/** + * NoteCode CLI - Watch Command + * Real-time activity monitoring via SSE + */ + +import { Command } from 'commander'; +import { printError } from '../formatters.js'; + +const API_BASE = process.env.NOTECODE_API_URL || 'http://localhost:41920'; + +/** + * Format event for human-readable output + */ +function formatEvent(event) { + const time = new Date().toLocaleTimeString(); + const { type, ...data } = event; + + switch (type) { + case 'session.started': + return `[${time}] 🚀 Session started: ${data.aggregateId} (task: ${data.taskId || 'none'})`; + + case 'session.completed': + return `[${time}] ✅ Session completed: ${data.aggregateId}`; + + case 'session.failed': + return `[${time}] ❌ Session failed: ${data.aggregateId} - ${data.reason}`; + + case 'approval.pending': + return `[${time}] ⏳ Approval pending: ${data.toolName} (session: ${data.sessionId})`; + + case 'task.status_changed': + return `[${time}] 📋 Task ${data.taskId}: ${data.previousStatus} → ${data.status}`; + + case 'git:approval:created': + return `[${time}] 🔀 Git approval: ${data.commitMessage} (${data.diffSummary?.files || 0} files)`; + + case 'git:approval:resolved': + return `[${time}] ${data.status === 'approved' ? '✅' : '❌'} Git ${data.status}: ${data.aggregateId}`; + + case 'notification': + return `[${time}] 🔔 ${data.title || 'Notification'}: ${data.message || ''}`; + + default: + return `[${time}] ${type}: ${JSON.stringify(data)}`; + } +} + +/** + * Connect to SSE endpoint and stream events + */ +async function watchEvents(opts) { + const url = `${API_BASE}/sse/notifications`; + + console.log(`Connecting to ${url}...`); + console.log('Press Ctrl+C to stop\n'); + + try { + const response = await fetch(url, { + headers: { 'Accept': 'text/event-stream' } + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + + if (done) { + console.log('\nConnection closed'); + break; + } + + buffer += decoder.decode(value, { stream: true }); + + // Parse SSE messages + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // Keep incomplete line in buffer + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const event = JSON.parse(line.slice(6)); + + if (opts.json) { + // JSON mode: output raw event + console.log(JSON.stringify(event)); + } else { + // Human mode: format nicely + console.log(formatEvent(event)); + } + } catch (e) { + // Not JSON, might be heartbeat + if (line.slice(6).trim() && !line.includes(':ping')) { + console.log(`[raw] ${line.slice(6)}`); + } + } + } + } + } + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + printError('Cannot connect to NoteCode server. Is it running?'); + } else { + printError(err.message); + } + process.exit(1); + } +} + +/** + * Polling-based watch (fallback when SSE fails) + */ +async function watchPolling(opts) { + const pollInterval = opts.interval || 2000; + let lastApprovals = new Set(); + let lastSessionCount = 0; + + console.log(`Polling every ${pollInterval}ms (Ctrl+C to stop)\n`); + + while (true) { + try { + // Check pending approvals + const approvalsRes = await fetch(`${API_BASE}/api/approvals/pending`); + if (approvalsRes.ok) { + const { approvals } = await approvalsRes.json(); + + for (const approval of approvals) { + if (!lastApprovals.has(approval.id)) { + const event = { + type: 'approval.pending', + aggregateId: approval.id, + sessionId: approval.sessionId, + toolName: approval.payload?.toolName || 'unknown', + occurredAt: new Date().toISOString() + }; + + if (opts.json) { + console.log(JSON.stringify(event)); + } else { + console.log(formatEvent(event)); + } + } + } + + lastApprovals = new Set(approvals.map(a => a.id)); + } + + // Check running sessions + const sessionsRes = await fetch(`${API_BASE}/api/sessions/running`); + if (sessionsRes.ok) { + const { sessions } = await sessionsRes.json(); + + if (sessions.length !== lastSessionCount) { + const event = { + type: 'notification', + title: 'Sessions', + message: `${sessions.length} running session(s)`, + occurredAt: new Date().toISOString() + }; + + if (opts.json) { + console.log(JSON.stringify(event)); + } else if (sessions.length > 0) { + console.log(formatEvent(event)); + } + + lastSessionCount = sessions.length; + } + } + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + printError('Connection lost. Retrying...'); + } + } + + await new Promise(r => setTimeout(r, pollInterval)); + } +} + +export function createWatchCommand() { + const watch = new Command('watch') + .description('Real-time activity monitoring') + .option('--json', 'Output events as JSON stream') + .option('--poll', 'Use polling instead of SSE') + .option('-i, --interval <ms>', 'Polling interval in ms (default: 2000)', parseInt) + .action(async (opts) => { + // Handle Ctrl+C gracefully + process.on('SIGINT', () => { + console.log('\nStopping watch...'); + process.exit(0); + }); + + if (opts.poll) { + await watchPolling(opts); + } else { + await watchEvents(opts); + } + }); + + return watch; +} diff --git a/cli/src/formatters.js b/cli/src/formatters.js new file mode 100644 index 0000000..d2366d8 --- /dev/null +++ b/cli/src/formatters.js @@ -0,0 +1,524 @@ +/** + * NoteCode CLI - Output Formatters + * Formats data for human-readable output + */ + +// ANSI color codes +const colors = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m', + gray: '\x1b[90m', +}; + +// Status colors +const statusColors = { + 'not-started': colors.gray, + 'in-progress': colors.blue, + 'review': colors.yellow, + 'done': colors.green, + 'cancelled': colors.red, + 'archived': colors.dim, + // Session statuses + 'queued': colors.gray, + 'running': colors.blue, + 'paused': colors.yellow, + 'completed': colors.green, + 'failed': colors.red, + 'cancelled': colors.red, +}; + +// Priority colors +const priorityColors = { + high: colors.red, + medium: colors.yellow, + low: colors.gray, +}; + +/** + * Format a status with color + */ +function formatStatus(status) { + const color = statusColors[status] || colors.white; + return `${color}${status}${colors.reset}`; +} + +/** + * Format priority with color + */ +function formatPriority(priority) { + if (!priority) return colors.dim + 'none' + colors.reset; + const color = priorityColors[priority] || colors.white; + return `${color}${priority}${colors.reset}`; +} + +/** + * Format a date + */ +function formatDate(date) { + if (!date) return '-'; + const d = new Date(date); + return d.toLocaleString(); +} + +/** + * Format relative time (e.g., "2 hours ago") + */ +function formatRelativeTime(date) { + if (!date) return '-'; + const d = new Date(date); + const now = new Date(); + const diffMs = now - d; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return formatDate(date); +} + +/** + * Truncate text to max length + */ +function truncate(text, maxLen = 50) { + if (!text) return ''; + if (text.length <= maxLen) return text; + return text.slice(0, maxLen - 3) + '...'; +} + +/** + * Format a task for list display + */ +export function formatTaskRow(task) { + const id = colors.dim + task.id.slice(0, 8) + colors.reset; + const status = formatStatus(task.status); + const priority = formatPriority(task.priority); + const title = truncate(task.title, 40); + const updated = formatRelativeTime(task.updatedAt); + + return `${id} ${status.padEnd(20)} ${priority.padEnd(18)} ${title.padEnd(42)} ${updated}`; +} + +/** + * Format task list header + */ +export function formatTaskHeader() { + return `${colors.bold}${'ID'.padEnd(10)} ${'STATUS'.padEnd(12)} ${'PRIORITY'.padEnd(10)} ${'TITLE'.padEnd(42)} ${'UPDATED'}${colors.reset}`; +} + +/** + * Format full task details + */ +export function formatTaskDetails(task) { + const lines = [ + `${colors.bold}Task: ${task.title}${colors.reset}`, + '', + ` ${colors.cyan}ID:${colors.reset} ${task.id}`, + ` ${colors.cyan}Status:${colors.reset} ${formatStatus(task.status)}`, + ` ${colors.cyan}Priority:${colors.reset} ${formatPriority(task.priority)}`, + ` ${colors.cyan}Project ID:${colors.reset} ${task.projectId}`, + ]; + + if (task.agentId) { + lines.push(` ${colors.cyan}Agent ID:${colors.reset} ${task.agentId}`); + } + if (task.agentRole) { + lines.push(` ${colors.cyan}Agent Role:${colors.reset} ${task.agentRole}`); + } + if (task.provider) { + lines.push(` ${colors.cyan}Provider:${colors.reset} ${task.provider}`); + } + if (task.model) { + lines.push(` ${colors.cyan}Model:${colors.reset} ${task.model}`); + } + + lines.push(''); + lines.push(` ${colors.cyan}Created:${colors.reset} ${formatDate(task.createdAt)}`); + lines.push(` ${colors.cyan}Updated:${colors.reset} ${formatDate(task.updatedAt)}`); + + if (task.startedAt) { + lines.push(` ${colors.cyan}Started:${colors.reset} ${formatDate(task.startedAt)}`); + } + if (task.completedAt) { + lines.push(` ${colors.cyan}Completed:${colors.reset} ${formatDate(task.completedAt)}`); + } + + if (task.description) { + lines.push(''); + lines.push(` ${colors.cyan}Description:${colors.reset}`); + lines.push(` ${task.description.split('\n').join('\n ')}`); + } + + if (task.contextFiles && task.contextFiles.length > 0) { + lines.push(''); + lines.push(` ${colors.cyan}Context Files:${colors.reset}`); + task.contextFiles.forEach(f => lines.push(` - ${f}`)); + } + + if (task.skills && task.skills.length > 0) { + lines.push(''); + lines.push(` ${colors.cyan}Skills:${colors.reset} ${task.skills.join(', ')}`); + } + + // Git info + if (task.branchName) { + lines.push(''); + lines.push(` ${colors.cyan}Branch:${colors.reset} ${task.branchName}`); + if (task.baseBranch) { + lines.push(` ${colors.cyan}Base:${colors.reset} ${task.baseBranch}`); + } + } + + // Attempt tracking + if (task.attemptCount > 0) { + lines.push(''); + lines.push(` ${colors.cyan}Attempts:${colors.reset} ${task.attemptCount} (success: ${task.successCount}, fail: ${task.failureCount})`); + if (task.totalTokens) { + lines.push(` ${colors.cyan}Tokens:${colors.reset} ${task.totalTokens.toLocaleString()}`); + } + } + + return lines.join('\n'); +} + +/** + * Format session for list display + */ +export function formatSessionRow(session) { + const id = colors.dim + session.id.slice(0, 8) + colors.reset; + const status = formatStatus(session.status); + const taskId = colors.dim + (session.taskId?.slice(0, 8) || '-') + colors.reset; + const name = truncate(session.name || '-', 35); + const updated = formatRelativeTime(session.updatedAt); + + return `${id} ${status.padEnd(20)} ${taskId} ${name.padEnd(37)} ${updated}`; +} + +/** + * Format session list header + */ +export function formatSessionHeader() { + return `${colors.bold}${'ID'.padEnd(10)} ${'STATUS'.padEnd(12)} ${'TASK ID'.padEnd(10)} ${'NAME'.padEnd(37)} ${'UPDATED'}${colors.reset}`; +} + +/** + * Format full session details + */ +export function formatSessionDetails(session) { + const lines = [ + `${colors.bold}Session: ${session.name || session.id}${colors.reset}`, + '', + ` ${colors.cyan}ID:${colors.reset} ${session.id}`, + ` ${colors.cyan}Status:${colors.reset} ${formatStatus(session.status)}`, + ` ${colors.cyan}Task ID:${colors.reset} ${session.taskId}`, + ]; + + if (session.agentId) { + lines.push(` ${colors.cyan}Agent ID:${colors.reset} ${session.agentId}`); + } + if (session.provider) { + lines.push(` ${colors.cyan}Provider:${colors.reset} ${session.provider}`); + } + if (session.workingDir) { + lines.push(` ${colors.cyan}Working Dir:${colors.reset} ${session.workingDir}`); + } + + lines.push(''); + lines.push(` ${colors.cyan}Created:${colors.reset} ${formatDate(session.createdAt)}`); + lines.push(` ${colors.cyan}Updated:${colors.reset} ${formatDate(session.updatedAt)}`); + + if (session.startedAt) { + lines.push(` ${colors.cyan}Started:${colors.reset} ${formatDate(session.startedAt)}`); + } + if (session.endedAt) { + lines.push(` ${colors.cyan}Ended:${colors.reset} ${formatDate(session.endedAt)}`); + } + + // Token/cost info + if (session.inputTokens || session.outputTokens) { + lines.push(''); + lines.push(` ${colors.cyan}Input Tokens:${colors.reset} ${(session.inputTokens || 0).toLocaleString()}`); + lines.push(` ${colors.cyan}Output Tokens:${colors.reset} ${(session.outputTokens || 0).toLocaleString()}`); + if (session.costUsd) { + lines.push(` ${colors.cyan}Cost:${colors.reset} $${session.costUsd.toFixed(4)}`); + } + } + + if (session.providerSessionId) { + lines.push(''); + lines.push(` ${colors.cyan}Provider Session:${colors.reset} ${session.providerSessionId}`); + } + + if (session.exitReason) { + lines.push(''); + lines.push(` ${colors.cyan}Exit Reason:${colors.reset} ${session.exitReason}`); + } + + return lines.join('\n'); +} + +/** + * Print an error message + */ +export function printError(message) { + console.error(`${colors.red}Error:${colors.reset} ${message}`); +} + +/** + * Print a success message + */ +export function printSuccess(message) { + console.log(`${colors.green}✓${colors.reset} ${message}`); +} + +/** + * Format approval status with color + */ +function formatApprovalStatus(status) { + const statusMap = { + pending: colors.yellow + 'pending' + colors.reset, + approved: colors.green + 'approved' + colors.reset, + rejected: colors.red + 'rejected' + colors.reset, + timeout: colors.gray + 'timeout' + colors.reset, + }; + return statusMap[status] || status; +} + +/** + * Format approval category with color + */ +function formatCategory(category) { + const categoryMap = { + safe: colors.green + 'safe' + colors.reset, + dangerous: colors.red + 'dangerous' + colors.reset, + 'requires-approval': colors.yellow + 'requires-approval' + colors.reset, + }; + return categoryMap[category] || category; +} + +/** + * Format approval for list display + */ +export function formatApprovalRow(approval) { + const id = colors.dim + approval.id.slice(0, 8) + colors.reset; + const status = formatApprovalStatus(approval.status); + const toolName = approval.payload?.toolName || '-'; + const category = formatCategory(approval.category); + const sessionId = colors.dim + (approval.sessionId?.slice(0, 8) || '-') + colors.reset; + const remaining = approval.remainingTimeMs + ? `${Math.ceil(approval.remainingTimeMs / 1000)}s` + : '-'; + + return `${id} ${status.padEnd(20)} ${toolName.padEnd(15)} ${category.padEnd(28)} ${sessionId} ${remaining}`; +} + +/** + * Format approval list header + */ +export function formatApprovalHeader() { + return `${colors.bold}${'ID'.padEnd(10)} ${'STATUS'.padEnd(12)} ${'TOOL'.padEnd(15)} ${'CATEGORY'.padEnd(20)} ${'SESSION'.padEnd(10)} ${'TIMEOUT'}${colors.reset}`; +} + +/** + * Format full approval details + */ +export function formatApprovalDetails(approval, diffs = []) { + const lines = [ + `${colors.bold}Approval: ${approval.id}${colors.reset}`, + '', + ` ${colors.cyan}Status:${colors.reset} ${formatApprovalStatus(approval.status)}`, + ` ${colors.cyan}Category:${colors.reset} ${formatCategory(approval.category)}`, + ` ${colors.cyan}Session:${colors.reset} ${approval.sessionId}`, + ` ${colors.cyan}Type:${colors.reset} ${approval.type}`, + ]; + + // Payload info + if (approval.payload) { + const { toolName, toolInput, matchedPattern } = approval.payload; + lines.push(''); + lines.push(` ${colors.cyan}Tool:${colors.reset} ${toolName || '-'}`); + + if (matchedPattern) { + lines.push(` ${colors.cyan}Matched:${colors.reset} ${colors.red}${matchedPattern}${colors.reset}`); + } + + if (toolInput) { + lines.push(''); + lines.push(` ${colors.cyan}Tool Input:${colors.reset}`); + const inputStr = JSON.stringify(toolInput, null, 2) + .split('\n') + .map(line => ' ' + line) + .join('\n'); + lines.push(inputStr); + } + } + + lines.push(''); + lines.push(` ${colors.cyan}Created:${colors.reset} ${formatDate(approval.createdAt)}`); + lines.push(` ${colors.cyan}Timeout At:${colors.reset} ${formatDate(approval.timeoutAt)}`); + + if (approval.decidedAt) { + lines.push(` ${colors.cyan}Decided At:${colors.reset} ${formatDate(approval.decidedAt)}`); + lines.push(` ${colors.cyan}Decided By:${colors.reset} ${approval.decidedBy || '-'}`); + } + + // Related diffs + if (diffs && diffs.length > 0) { + lines.push(''); + lines.push(` ${colors.cyan}Related Diffs:${colors.reset}`); + for (const diff of diffs) { + lines.push(` - ${diff.filePath} (${diff.type}): +${diff.linesAdded || 0}/-${diff.linesRemoved || 0}`); + } + } + + return lines.join('\n'); +} + +/** + * Format project for list display + */ +export function formatProjectRow(project, isActive = false) { + const id = colors.dim + project.id.slice(0, 8) + colors.reset; + const activeMarker = isActive ? colors.green + '●' + colors.reset : ' '; + const favorite = project.isFavorite ? colors.yellow + '★' + colors.reset : ' '; + const name = truncate(project.name, 25); + const path = truncate(project.path, 45); + const accessed = formatRelativeTime(project.lastAccessedAt); + + return `${activeMarker} ${favorite} ${id} ${name.padEnd(27)} ${path.padEnd(47)} ${accessed}`; +} + +/** + * Format project list header + */ +export function formatProjectHeader() { + return `${colors.bold} ${'ID'.padEnd(10)} ${'NAME'.padEnd(27)} ${'PATH'.padEnd(47)} ${'ACCESSED'}${colors.reset}`; +} + +/** + * Format full project details + */ +export function formatProjectDetails(project, isActive = false) { + const lines = [ + `${colors.bold}Project: ${project.name}${colors.reset}${isActive ? colors.green + ' (active)' + colors.reset : ''}`, + '', + ` ${colors.cyan}ID:${colors.reset} ${project.id}`, + ` ${colors.cyan}Path:${colors.reset} ${project.path}`, + ` ${colors.cyan}Favorite:${colors.reset} ${project.isFavorite ? colors.yellow + '★ Yes' + colors.reset : 'No'}`, + ]; + + if (project.systemPrompt) { + lines.push(''); + lines.push(` ${colors.cyan}System Prompt:${colors.reset}`); + const promptLines = project.systemPrompt.split('\n').slice(0, 5); + promptLines.forEach(line => lines.push(` ${truncate(line, 70)}`)); + if (project.systemPrompt.split('\n').length > 5) { + lines.push(` ${colors.dim}... (truncated)${colors.reset}`); + } + } + + if (project.approvalGate) { + lines.push(''); + lines.push(` ${colors.cyan}Approval Gate:${colors.reset} ${project.approvalGate.enabled ? colors.green + 'Enabled' + colors.reset : colors.gray + 'Disabled' + colors.reset}`); + if (project.approvalGate.enabled) { + if (project.approvalGate.timeoutSeconds) { + lines.push(` Timeout: ${project.approvalGate.timeoutSeconds}s`); + } + if (project.approvalGate.autoAllowTools?.length) { + lines.push(` Auto-allow: ${project.approvalGate.autoAllowTools.join(', ')}`); + } + } + } + + lines.push(''); + lines.push(` ${colors.cyan}Created:${colors.reset} ${formatDate(project.createdAt)}`); + lines.push(` ${colors.cyan}Updated:${colors.reset} ${formatDate(project.updatedAt)}`); + lines.push(` ${colors.cyan}Last Access:${colors.reset} ${formatDate(project.lastAccessedAt)}`); + + return lines.join('\n'); +} + +/** + * Format discovered agent for list display + */ +export function formatAgentRow(agent) { + const name = truncate(agent.name, 20); + const description = truncate(agent.description || agent.bio || '-', 50); + const location = truncate(agent.location || '-', 35); + + return `${name.padEnd(22)} ${description.padEnd(52)} ${location}`; +} + +/** + * Format agent list header + */ +export function formatAgentHeader() { + return `${colors.bold}${'NAME'.padEnd(22)} ${'DESCRIPTION'.padEnd(52)} ${'LOCATION'}${colors.reset}`; +} + +/** + * Format full agent details + */ +export function formatAgentDetails(agent) { + const lines = [ + `${colors.bold}Agent: ${agent.name}${colors.reset}`, + '', + ` ${colors.cyan}Location:${colors.reset} ${agent.location || '-'}`, + ]; + + if (agent.description) { + lines.push(` ${colors.cyan}Description:${colors.reset} ${agent.description}`); + } + + if (agent.bio) { + lines.push(''); + lines.push(` ${colors.cyan}Bio:${colors.reset}`); + agent.bio.split('\n').forEach(line => lines.push(` ${line}`)); + } + + if (agent.skills && agent.skills.length > 0) { + lines.push(''); + lines.push(` ${colors.cyan}Skills:${colors.reset}`); + agent.skills.forEach(skill => lines.push(` - ${skill}`)); + } + + if (agent.instructions) { + lines.push(''); + lines.push(` ${colors.cyan}Instructions:${colors.reset}`); + const instructionLines = agent.instructions.split('\n').slice(0, 10); + instructionLines.forEach(line => lines.push(` ${truncate(line, 70)}`)); + if (agent.instructions.split('\n').length > 10) { + lines.push(` ${colors.dim}... (truncated)${colors.reset}`); + } + } + + return lines.join('\n'); +} + +/** + * Format discovered skill for list display + */ +export function formatSkillRow(skill) { + const name = truncate(skill.name, 20); + const description = truncate(skill.description || '-', 50); + const location = truncate(skill.location || '-', 35); + + return `${name.padEnd(22)} ${description.padEnd(52)} ${location}`; +} + +/** + * Format skill list header + */ +export function formatSkillHeader() { + return `${colors.bold}${'NAME'.padEnd(22)} ${'DESCRIPTION'.padEnd(52)} ${'LOCATION'}${colors.reset}`; +} diff --git a/package.json b/package.json index 3927895..648d7ff 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,13 @@ "main": "electron/dist/main.js", "type": "commonjs", "bin": { - "notecode": "./backend/bin/cli.js" + "notecode": "./cli/notecode.js" }, "publishConfig": { "access": "public" }, "files": [ + "cli/**", "backend/bin/**", "backend/dist/**", "backend/package.json", @@ -60,6 +61,7 @@ "@fastify/rate-limit": "^9.0.0", "@fastify/static": "^6.12.0", "better-sqlite3": "^11.10.0", + "commander": "^14.0.3", "diff": "^8.0.3", "drizzle-orm": "^0.30.0", "fast-glob": "^3.3.3",