UNPKG

@fission-ai/openspec

Version:

AI-native system for spec-driven development

171 lines 6.71 kB
import { promises as fs } from 'fs'; import path from 'path'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; import { readFileSync } from 'fs'; import { join } from 'path'; import { MarkdownParser } from './parsers/markdown-parser.js'; /** * 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) { let latest = null; async function walk(dir) { 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) { 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 = '.', mode = 'changes', options = {}) { 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); } catch { throw new Error("No OpenSpec changes directory found. Run 'openspec init' first."); } // Get all directories in changes (excluding archive) const entries = await fs.readdir(changesDir, { withFileTypes: true }); const changeDirs = entries .filter(entry => entry.isDirectory() && entry.name !== 'archive') .map(entry => entry.name); if (changeDirs.length === 0) { if (json) { console.log(JSON.stringify({ changes: [] })); } else { console.log('No active changes found.'); } return; } // Collect information about each change const changes = []; 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, lastModified }); } // 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:'); const padding = ' '; const nameWidth = Math.max(...changes.map(c => c.name.length)); for (const change of changes) { const paddedName = change.name.padEnd(nameWidth); const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks }); const timeAgo = formatRelativeTime(change.lastModified); console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`); } return; } // specs mode const specsDir = path.join(targetPath, 'openspec', 'specs'); try { await fs.access(specsDir); } catch { console.log('No specs found.'); return; } const entries = await fs.readdir(specsDir, { withFileTypes: true }); const specDirs = entries.filter(e => e.isDirectory()).map(e => e.name); if (specDirs.length === 0) { console.log('No specs found.'); return; } const specs = []; for (const id of specDirs) { const specPath = join(specsDir, id, 'spec.md'); try { const content = readFileSync(specPath, 'utf-8'); const parser = new MarkdownParser(content); const spec = parser.parseSpec(id); specs.push({ id, requirementCount: spec.requirements.length }); } catch { // If spec cannot be read or parsed, include with 0 count specs.push({ id, requirementCount: 0 }); } } specs.sort((a, b) => a.id.localeCompare(b.id)); console.log('Specs:'); const padding = ' '; const nameWidth = Math.max(...specs.map(s => s.id.length)); for (const spec of specs) { const padded = spec.id.padEnd(nameWidth); console.log(`${padding}${padded} requirements ${spec.requirementCount}`); } } } //# sourceMappingURL=list.js.map