Compare commits

...
3 changed files with 270 additions and 0 deletions
+15
View File
@@ -5,6 +5,7 @@ import { promises as fs } from 'fs';
import { InitCommand } from '../core/init.js';
import { UpdateCommand } from '../core/update.js';
import { DiffCommand } from '../core/diff.js';
import { ListCommand } from '../core/list.js';
const program = new Command();
@@ -75,4 +76,18 @@ program
}
});
program
.command('list')
.description('List all active changes with their task status')
.action(async () => {
try {
const listCommand = new ListCommand();
await listCommand.execute();
} catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
program.parse();
+90
View File
@@ -0,0 +1,90 @@
import { promises as fs } from 'fs';
import path from 'path';
interface ChangeInfo {
name: string;
completedTasks: number;
totalTasks: number;
}
export class ListCommand {
async execute(targetPath: string = '.'): Promise<void> {
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) {
console.log('No active changes found.');
return;
}
// Collect information about each change
const changes: ChangeInfo[] = [];
for (const changeDir of changeDirs) {
const tasksPath = path.join(changesDir, changeDir, 'tasks.md');
let completedTasks = 0;
let incompleteTasks = 0;
try {
const content = await fs.readFile(tasksPath, 'utf-8');
const lines = content.split('\n');
for (const line of lines) {
if (line.includes('- [x]')) {
completedTasks++;
} else if (line.includes('- [ ]')) {
incompleteTasks++;
}
}
} catch {
// No tasks.md file
changes.push({
name: changeDir,
completedTasks: 0,
totalTasks: 0
});
continue;
}
changes.push({
name: changeDir,
completedTasks,
totalTasks: completedTasks + incompleteTasks
});
}
// Sort alphabetically by name
changes.sort((a, b) => a.name.localeCompare(b.name));
// Display results
console.log('Changes:');
for (const change of changes) {
const padding = ' ';
const nameWidth = Math.max(...changes.map(c => c.name.length));
const paddedName = change.name.padEnd(nameWidth);
let status: string;
if (change.totalTasks === 0) {
status = 'No tasks';
} else if (change.completedTasks === change.totalTasks) {
status = '✓ Complete';
} else {
status = `${change.completedTasks}/${change.totalTasks} tasks`;
}
console.log(`${padding}${paddedName} ${status}`);
}
}
}
+165
View File
@@ -0,0 +1,165 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { ListCommand } from '../../src/core/list.js';
describe('ListCommand', () => {
let tempDir: string;
let originalLog: typeof console.log;
let logOutput: string[] = [];
beforeEach(async () => {
// Create temp directory
tempDir = path.join(os.tmpdir(), `openspec-list-test-${Date.now()}`);
await fs.mkdir(tempDir, { recursive: true });
// Mock console.log to capture output
originalLog = console.log;
console.log = (...args: any[]) => {
logOutput.push(args.join(' '));
};
logOutput = [];
});
afterEach(async () => {
// Restore console.log
console.log = originalLog;
// Clean up temp directory
await fs.rm(tempDir, { recursive: true, force: true });
});
describe('execute', () => {
it('should handle missing openspec/changes directory', async () => {
const listCommand = new ListCommand();
await expect(listCommand.execute(tempDir)).rejects.toThrow(
"No OpenSpec changes directory found. Run 'openspec init' first."
);
});
it('should handle empty changes directory', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(changesDir, { recursive: true });
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput).toEqual(['No active changes found.']);
});
it('should exclude archive directory', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'archive'), { recursive: true });
await fs.mkdir(path.join(changesDir, 'my-change'), { recursive: true });
// Create tasks.md with some tasks
await fs.writeFile(
path.join(changesDir, 'my-change', 'tasks.md'),
'- [x] Task 1\n- [ ] Task 2\n'
);
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput).toContain('Changes:');
expect(logOutput.some(line => line.includes('my-change'))).toBe(true);
expect(logOutput.some(line => line.includes('archive'))).toBe(false);
});
it('should count tasks correctly', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'test-change'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'test-change', 'tasks.md'),
`# Tasks
- [x] Completed task 1
- [x] Completed task 2
- [ ] Incomplete task 1
- [ ] Incomplete task 2
- [ ] Incomplete task 3
Regular text that should be ignored
`
);
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput.some(line => line.includes('2/5 tasks'))).toBe(true);
});
it('should show complete status for fully completed changes', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'completed-change'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'completed-change', 'tasks.md'),
'- [x] Task 1\n- [x] Task 2\n- [x] Task 3\n'
);
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput.some(line => line.includes('✓ Complete'))).toBe(true);
});
it('should handle changes without tasks.md', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'no-tasks'), { recursive: true });
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput.some(line => line.includes('no-tasks') && line.includes('No tasks'))).toBe(true);
});
it('should sort changes alphabetically', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'zebra'), { recursive: true });
await fs.mkdir(path.join(changesDir, 'alpha'), { recursive: true });
await fs.mkdir(path.join(changesDir, 'middle'), { recursive: true });
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
const changeLines = logOutput.filter(line =>
line.includes('alpha') || line.includes('middle') || line.includes('zebra')
);
expect(changeLines[0]).toContain('alpha');
expect(changeLines[1]).toContain('middle');
expect(changeLines[2]).toContain('zebra');
});
it('should handle multiple changes with various states', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
// Complete change
await fs.mkdir(path.join(changesDir, 'completed'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'completed', 'tasks.md'),
'- [x] Task 1\n- [x] Task 2\n'
);
// Partial change
await fs.mkdir(path.join(changesDir, 'partial'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'partial', 'tasks.md'),
'- [x] Done\n- [ ] Not done\n- [ ] Also not done\n'
);
// No tasks
await fs.mkdir(path.join(changesDir, 'no-tasks'), { recursive: true });
const listCommand = new ListCommand();
await listCommand.execute(tempDir);
expect(logOutput).toContain('Changes:');
expect(logOutput.some(line => line.includes('completed') && line.includes('✓ Complete'))).toBe(true);
expect(logOutput.some(line => line.includes('partial') && line.includes('1/3 tasks'))).toBe(true);
expect(logOutput.some(line => line.includes('no-tasks') && line.includes('No tasks'))).toBe(true);
});
});
});