Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <order>', '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}`);
Expand Down
106 changes: 98 additions & 8 deletions src/core/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Date> {
let latest: Date | null = null;

async function walk(dir: string): Promise<void> {
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<void> {
async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes', options: ListOptions = {}): Promise<void> {
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);
Expand All @@ -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:');
Expand All @@ -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;
}
Expand Down
Loading