diff --git a/backend/bin/cli.js b/backend/bin/cli.js index 39f32a3..58f220f 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)); +} + +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' }); } -// Handle --version -if (args.includes('--version') || args.includes('-v')) { - console.log(`notecode v${pkg.version}`); - process.exit(0); +function truncate(str, len) { + if (!str) return ''; + return str.length > len ? str.slice(0, len - 1) + '…' : str; } -async function main() { - // ASCII banner - NOTECODE +// ============================================================================ +// 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; + } +} + +// ============================================================================ +// 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,323 @@ 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); + } +} + +// ============================================================================ +// 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', '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); + }); + + 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..7f514a6 --- /dev/null +++ b/cli/notecode.js @@ -0,0 +1,107 @@ +#!/usr/bin/env node + +/** + * NoteCode CLI + * Command-line interface for managing tasks and sessions + * + * 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 + */ + +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 { 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()); + + // 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..35f63bf --- /dev/null +++ b/cli/src/api.js @@ -0,0 +1,115 @@ +/** + * 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`); +} 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/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..1e26900 --- /dev/null +++ b/cli/src/commands/task.js @@ -0,0 +1,211 @@ +/** + * NoteCode CLI - Task Commands + */ + +import { Command } from 'commander'; +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); + } + }); + + 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..241c4d3 --- /dev/null +++ b/cli/src/formatters.js @@ -0,0 +1,385 @@ +/** + * 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'); +} 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",