diff --git a/cli/notecode.js b/cli/notecode.js new file mode 100755 index 0000000..92d9fa3 --- /dev/null +++ b/cli/notecode.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node + +/** + * NoteCode CLI + * Command-line interface for managing tasks and sessions + * + * Usage: + * notecode Start the NoteCode server (default) + * notecode serve [-p ] Start the NoteCode server + * notecode task list [--status ] List tasks + * notecode task get [--json] Get task details + * notecode task create --title "..." Create a task + * notecode task update [options] Update a task + * notecode session list [--task-id ] List sessions + * notecode session status Get session details + */ + +import { Command } from 'commander'; +import { createTaskCommands } from './src/commands/task.js'; +import { createSessionCommands } from './src/commands/session.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', '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 ', '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()); + + // 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..f277d9b --- /dev/null +++ b/cli/src/api.js @@ -0,0 +1,90 @@ +/** + * 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} - 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'); +} 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 ', 'Filter by task ID') + .option('--status ', 'Filter by status (running, completed, etc.)') + .option('--limit ', '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 (alias for get, more intuitive) + session + .command('status ') + .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 (same as status, for consistency) + session + .command('get ') + .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/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 ', 'Filter by status (comma-separated: not-started,in-progress,review,done,cancelled,archived)') + .option('--priority ', 'Filter by priority (comma-separated: high,medium,low)') + .option('--project ', 'Filter by project ID') + .option('--assignee ', 'Filter by agent/assignee ID') + .option('--search ', '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 + task + .command('get ') + .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 ', '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/formatters.js b/cli/src/formatters.js new file mode 100644 index 0000000..3854896 --- /dev/null +++ b/cli/src/formatters.js @@ -0,0 +1,282 @@ +/** + * 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}`); +} 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",