diff --git a/src/cli/index.ts b/src/cli/index.ts index 0fcff0e6..6dd2ac29 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -96,11 +96,14 @@ program .description('List items (changes by default). Use --specs to list specs.') .option('--specs', 'List specs instead of changes') .option('--changes', 'List changes explicitly (default)') - .action(async (options?: { specs?: boolean; changes?: boolean }) => { + .option('--sort ', 'Sort order: "recent" (default) or "name"', 'recent') + .option('--json', 'Output as JSON (for programmatic use)') + .action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; json?: boolean }) => { try { const listCommand = new ListCommand(); const mode: 'changes' | 'specs' = options?.specs ? 'specs' : 'changes'; - await listCommand.execute('.', mode); + const sort = options?.sort === 'name' ? 'name' : 'recent'; + await listCommand.execute('.', mode, { sort, json: options?.json }); } catch (error) { console.log(); // Empty line for spacing ora().fail(`Error: ${(error as Error).message}`); diff --git a/src/core/list.ts b/src/core/list.ts index c815540a..3f40829a 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -9,13 +9,78 @@ interface ChangeInfo { name: string; completedTasks: number; totalTasks: number; + lastModified: Date; +} + +interface ListOptions { + sort?: 'recent' | 'name'; + json?: boolean; +} + +/** + * Get the most recent modification time of any file in a directory (recursive). + * Falls back to the directory's own mtime if no files are found. + */ +async function getLastModified(dirPath: string): Promise { + let latest: Date | null = null; + + async function walk(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + } else { + const stat = await fs.stat(fullPath); + if (latest === null || stat.mtime > latest) { + latest = stat.mtime; + } + } + } + } + + await walk(dirPath); + + // If no files found, use the directory's own modification time + if (latest === null) { + const dirStat = await fs.stat(dirPath); + return dirStat.mtime; + } + + return latest; +} + +/** + * Format a date as relative time (e.g., "2 hours ago", "3 days ago") + */ +function formatRelativeTime(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffDays > 30) { + return date.toLocaleDateString(); + } else if (diffDays > 0) { + return `${diffDays}d ago`; + } else if (diffHours > 0) { + return `${diffHours}h ago`; + } else if (diffMins > 0) { + return `${diffMins}m ago`; + } else { + return 'just now'; + } } export class ListCommand { - async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes'): Promise { + async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes', options: ListOptions = {}): Promise { + const { sort = 'recent', json = false } = options; + if (mode === 'changes') { const changesDir = path.join(targetPath, 'openspec', 'changes'); - + // Check if changes directory exists try { await fs.access(changesDir); @@ -30,24 +95,48 @@ export class ListCommand { .map(entry => entry.name); if (changeDirs.length === 0) { - console.log('No active changes found.'); + if (json) { + console.log(JSON.stringify({ changes: [] })); + } else { + console.log('No active changes found.'); + } return; } // Collect information about each change const changes: ChangeInfo[] = []; - + for (const changeDir of changeDirs) { const progress = await getTaskProgressForChange(changesDir, changeDir); + const changePath = path.join(changesDir, changeDir); + const lastModified = await getLastModified(changePath); changes.push({ name: changeDir, completedTasks: progress.completed, - totalTasks: progress.total + totalTasks: progress.total, + lastModified }); } - // Sort alphabetically by name - changes.sort((a, b) => a.name.localeCompare(b.name)); + // Sort by preference (default: recent first) + if (sort === 'recent') { + changes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + } else { + changes.sort((a, b) => a.name.localeCompare(b.name)); + } + + // JSON output for programmatic use + if (json) { + const jsonOutput = changes.map(c => ({ + name: c.name, + completedTasks: c.completedTasks, + totalTasks: c.totalTasks, + lastModified: c.lastModified.toISOString(), + status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress' + })); + console.log(JSON.stringify({ changes: jsonOutput }, null, 2)); + return; + } // Display results console.log('Changes:'); @@ -56,7 +145,8 @@ export class ListCommand { for (const change of changes) { const paddedName = change.name.padEnd(nameWidth); const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks }); - console.log(`${padding}${paddedName} ${status}`); + const timeAgo = formatRelativeTime(change.lastModified); + console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`); } return; }