Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 1e17cfdd0b Address review 2025-08-20 00:14:34 +10:00
Tabish Bidiwale 5d185ba3a8 feat: add unified show command with interactive selection 2025-08-20 00:06:19 +10:00
Tabish Bidiwale 08b41c7bea Merge pull request #43 from Fission-AI/feat/validate-command-interactive-selection
feat: add unified validate command with interactive selection and bulk operations
2025-08-19 23:23:11 +10:00
12 changed files with 683 additions and 62 deletions
+58
View File
@@ -49,6 +49,64 @@ openspec/
│ └── archive/ # Completed changes (dated)
```
## CLI Usage: show command
Use the `show` command to display change proposals or specs with automatic detection and interactive selection.
- Interactive (no args, in a TTY):
```bash
openspec show
# → prompts to pick change/spec, then item
```
- Direct item (auto-detect type):
```bash
openspec show demo # shows change 'demo'
openspec show auth # shows spec 'auth'
```
- Disambiguation when names collide:
```bash
openspec show foo # if both change/spec exist → error suggests --type
openspec show foo --type spec # forces spec
openspec show foo --type change
```
- Common flags:
```bash
# JSON output (both types)
openspec show <item> --json
# Change-only flags
openspec show <change-id> --json --deltas-only
openspec show <change-id> --json --requirements-only # deprecated alias of --deltas-only
# Spec-only flags
openspec show <spec-id> --json --requirements
openspec show <spec-id> --json --no-scenarios
openspec show <spec-id> --json -r 1 # show requirement 1 only (1-based)
```
- Interactivity controls:
```bash
openspec show --no-interactive # never prompt
# or via env
OPEN_SPEC_INTERACTIVE=0 openspec show
```
- Backwards compatibility (subcommands also support interactive selection when no arg):
```bash
openspec change show [change-id] [--json] [--deltas-only]
openspec spec show [spec-id] [--json] [--requirements] [--no-scenarios] [-r N]
```
### Capability Organization
**Use capabilities, not features** - Each directory under `specs/` represents a single, focused responsibility:
@@ -0,0 +1,142 @@
# Implementation Tasks — Add Interactive Show Command
## Goals
- Add a top-level `show` command with intelligent selection and type detection.
- Add interactive selection to `change show` and `spec show` when no ID is provided.
- Preserve raw-first output behavior and existing JSON formats/filters.
- Respect `--no-interactive` and `OPEN_SPEC_INTERACTIVE=0` consistently.
---
## 1) CLI wiring
- [x] In `src/cli/index.ts` add a top-level command: `program.command('show [item-name]')`
- Options:
- `--json`
- `--type <type>` where `<type>` is `change|spec`
- `--no-interactive`
- Allow passing-through type-specific flags using `.allowUnknownOption(true)` so the top-level can forward flags to the underlying type handler.
- Action: instantiate `new ShowCommand().execute(itemName, options)`.
- [x] Update `change show` subcommand to accept `--no-interactive` and pass it to `ChangeCommand.show(...)`.
- [x] Change `spec show` subcommand to accept optional ID (`show [spec-id]`), add `--no-interactive`, and pass to spec show implementation.
Acceptance:
- `openspec show` exists and prints a helpful hint in non-interactive contexts when no args.
- Unknown flags for other types do not crash parsing; they are warned/ignored appropriately.
---
## 2) New module: `src/commands/show.ts`
- [x] Create `ShowCommand` with:
- `execute(itemName?: string, options?: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any })`
- Interactive path when `!itemName` and interactive is enabled:
- Prompt: "What would you like to show?" → `change` or `spec`.
- Load available IDs for the chosen type and prompt selection.
- Delegate to type-specific show implementation.
- Non-interactive path when `!itemName`:
- Print hint with examples:
- `openspec show <item>`
- `openspec change show`
- `openspec spec show`
- Exit with code 1.
- Direct item path when `itemName` is provided:
- Type override via `--type` takes precedence.
- Otherwise detect using `getActiveChangeIds()` and `getSpecIds()`.
- If ambiguous and no override: print error + suggestion to pass `--type` or use subcommands; exit code 1.
- If unknown: print not-found with nearest-match suggestions; exit code 1.
- On success: delegate to type-specific show.
- [x] Flag scoping and pass-through:
- Common: `--json` → forwarded to both types.
- Change-only: `--deltas-only`, `--requirements-only` (deprecated alias).
- Spec-only: `--requirements`, `--no-scenarios`, `-r/--requirement`.
- Warn and ignore irrelevant flags for the resolved type.
Acceptance:
- `openspec show <change-id> --json --deltas-only` matches `openspec change show <id> --json --deltas-only` output.
- `openspec show <spec-id> --json --requirements` matches `openspec spec show <id> --json --requirements` output.
- Ambiguity and not-found behaviors match the `cli-show` spec.
---
## 3) Refactor spec show into reusable API
- [x] In `src/commands/spec.ts`, extract show logic into an exported `SpecCommand` with `show(specId?: string, options?: { json?: boolean; requirements?: boolean; scenarios?: boolean; requirement?: string; noInteractive?: boolean })`.
- Reuse current helpers (`parseSpecFromFile`, `filterSpec`, raw-first printing).
- Keep `registerSpecCommand` but delegate to `new SpecCommand().show(...)`.
- [x] Update CLI spec show subcommand to optional arg and interactive behavior (see section 4).
Acceptance:
- Existing `spec show` tests continue to pass.
- New `SpecCommand.show` can be called from `ShowCommand`.
---
## 4) Backwards-compatible interactive in subcommands
- [x] `src/commands/change.ts` → extend `show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean })`:
- When `!changeName` and interactive enabled: prompt from `getActiveChangeIds()` and show the selected change.
- Non-interactive fallback: keep current behavior (print available IDs + `openspec change list` hint, set `process.exitCode = 1`).
- [x] `src/commands/spec.ts` → `SpecCommand.show` as above:
- When `!specId` and interactive enabled: prompt from `getSpecIds()` and show the selected spec.
- Non-interactive fallback: print the same error as existing behavior for missing `<spec-id>` and set non-zero exit code.
Acceptance:
- `openspec change show` in non-interactive prints list hint and exits non-zero.
- `openspec spec show` in non-interactive prints missing-arg error and exits non-zero.
---
## 5) Shared utilities
- [x] Extract `nearestMatches` and `levenshtein` from `src/commands/validate.ts` into `src/utils/match.ts` (exported helpers).
- [x] Update `ValidateCommand` and new `ShowCommand` to import from `utils/match`.
Acceptance:
- Build succeeds with shared helpers and no duplication.
---
## 6) Hints, warnings, and messages
- [x] Top-level `show` hint (non-interactive no-arg):
- Lines include: `openspec show <item>`, `openspec change show`, `openspec spec show`, and "Or run in an interactive terminal.".
- [x] Ambiguity message suggests `--type change|spec` and the subcommands.
- [x] Not-found suggests nearest matches (up to 5).
- [x] Irrelevant flag warnings for the resolved type (printed to stderr, no crash).
Acceptance:
- Messages match the `cli-show` spec wording intent and style used elsewhere.
---
## 7) Tests
Add tests mirroring existing patterns (non-TTY simulation via `OPEN_SPEC_INTERACTIVE=0`).
- [x] `test/commands/show.test.ts`
- Non-interactive, no arg → prints hint and exits non-zero.
- Direct item detection for change and for spec.
- Ambiguity case when both exist → error and suggestion for `--type`.
- Not-found case → nearest-match suggestions.
- Pass-through flags: change `--json --deltas-only`, spec `--json --requirements`.
- [x] `test/commands/change.interactive-show.test.ts` (non-interactive fallback)
- Ensure `openspec change show` without args prints available IDs + list hint and non-zero exit.
- [x] `test/commands/spec.interactive-show.test.ts` (non-interactive fallback)
- Ensure `openspec spec show` without args prints missing-arg error and non-zero exit.
Acceptance:
- All new tests pass after build; no regressions in existing tests.
---
## 8) Documentation (optional but recommended)
- [x] Update `openspec/README.md` usage examples to include the new `show` command with type detection and flags.
---
## 9) Non-functional checks
- [x] Run `pnpm build` and all tests (`pnpm test`).
- [x] Ensure no linter/type errors and messages are consistent with existing style.
---
## Notes on consistency
- Follow raw-first behavior for text output: passthrough file content with no formatting, mirroring current `change show` and `spec show`.
- Reuse `isInteractive` and `item-discovery` helpers for consistent prompting behavior.
- Keep JSON output shapes identical to current `ChangeCommand.show` and `spec show` outputs.
+30 -1
View File
@@ -10,6 +10,7 @@ import { ArchiveCommand } from '../core/archive.js';
import { registerSpecCommand } from '../commands/spec.js';
import { ChangeCommand } from '../commands/change.js';
import { ValidateCommand } from '../commands/validate.js';
import { ShowCommand } from '../commands/show.js';
const program = new Command();
@@ -117,7 +118,8 @@ changeCmd
.option('--json', 'Output as JSON')
.option('--deltas-only', 'Show only deltas (JSON only)')
.option('--requirements-only', 'Alias for --deltas-only (deprecated)')
.action(async (changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean }) => {
.option('--no-interactive', 'Disable interactive prompts')
.action(async (changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean }) => {
try {
const changeCommand = new ChangeCommand();
await changeCommand.show(changeName, options);
@@ -200,4 +202,31 @@ program
}
});
// Top-level show command
program
.command('show [item-name]')
.description('Show a change or spec')
.option('--json', 'Output as JSON')
.option('--type <type>', 'Specify item type when ambiguous: change|spec')
.option('--no-interactive', 'Disable interactive prompts')
// change-only flags
.option('--deltas-only', 'Show only deltas (JSON only, change)')
.option('--requirements-only', 'Alias for --deltas-only (deprecated, change)')
// spec-only flags
.option('--requirements', 'JSON only: Show only requirements (exclude scenarios)')
.option('--no-scenarios', 'JSON only: Exclude scenario content')
.option('-r, --requirement <id>', 'JSON only: Show specific requirement by ID (1-based)')
// allow unknown options to pass-through to underlying command implementation
.allowUnknownOption(true)
.action(async (itemName?: string, options?: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any }) => {
try {
const showCommand = new ShowCommand();
await showCommand.execute(itemName, options ?? {});
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
program.parse();
+16 -7
View File
@@ -26,19 +26,28 @@ export class ChangeCommand {
* - JSON mode: minimal object with deltas; --deltas-only returns same object with filtered deltas
* Note: --requirements-only is deprecated alias for --deltas-only
*/
async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean }): Promise<void> {
async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean }): Promise<void> {
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
if (!changeName) {
const canPrompt = isInteractive(options?.noInteractive);
const changes = await this.getActiveChanges(changesPath);
if (changes.length === 0) {
console.error('No change specified. No active changes found.');
if (canPrompt && changes.length > 0) {
const selected = await select({
message: 'Select a change to show',
choices: changes.map(id => ({ name: id, value: id })),
});
changeName = selected;
} else {
console.error(`No change specified. Available IDs: ${changes.join(', ')}`);
if (changes.length === 0) {
console.error('No change specified. No active changes found.');
} else {
console.error(`No change specified. Available IDs: ${changes.join(', ')}`);
}
console.error('Hint: use "openspec change list" to view available changes.');
process.exitCode = 1;
return;
}
console.error('Hint: use "openspec change list" to view available changes.');
process.exitCode = 1;
return;
}
const proposalPath = path.join(changesPath, changeName, 'proposal.md');
+139
View File
@@ -0,0 +1,139 @@
import { select } from '@inquirer/prompts';
import path from 'path';
import { isInteractive } from '../utils/interactive.js';
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
import { ChangeCommand } from './change.js';
import { SpecCommand } from './spec.js';
import { nearestMatches } from '../utils/match.js';
type ItemType = 'change' | 'spec';
const CHANGE_FLAG_KEYS = new Set(['deltasOnly', 'requirementsOnly']);
const SPEC_FLAG_KEYS = new Set(['requirements', 'scenarios', 'requirement']);
export class ShowCommand {
async execute(itemName?: string, options: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any } = {}): Promise<void> {
const interactive = isInteractive(options.noInteractive);
const typeOverride = this.normalizeType(options.type);
if (!itemName) {
if (interactive) {
const type = await select<ItemType>({
message: 'What would you like to show?',
choices: [
{ name: 'Change', value: 'change' as const },
{ name: 'Spec', value: 'spec' as const },
],
});
await this.runInteractiveByType(type, options);
return;
}
this.printNonInteractiveHint();
process.exitCode = 1;
return;
}
await this.showDirect(itemName, { typeOverride, options });
}
private normalizeType(value?: string): ItemType | undefined {
if (!value) return undefined;
const v = value.toLowerCase();
if (v === 'change' || v === 'spec') return v;
return undefined;
}
private async runInteractiveByType(type: ItemType, options: { json?: boolean; noInteractive?: boolean; [k: string]: any }): Promise<void> {
if (type === 'change') {
const changes = await getActiveChangeIds();
if (changes.length === 0) {
console.error('No changes found.');
process.exitCode = 1;
return;
}
const picked = await select<string>({ message: 'Pick a change', choices: changes.map(id => ({ name: id, value: id })) });
const cmd = new ChangeCommand();
await cmd.show(picked, options as any);
return;
}
const specs = await getSpecIds();
if (specs.length === 0) {
console.error('No specs found.');
process.exitCode = 1;
return;
}
const picked = await select<string>({ message: 'Pick a spec', choices: specs.map(id => ({ name: id, value: id })) });
const cmd = new SpecCommand();
await cmd.show(picked, options as any);
}
private async showDirect(itemName: string, params: { typeOverride?: ItemType; options: { json?: boolean; [k: string]: any } }): Promise<void> {
// Optimize lookups when type is pre-specified
let isChange = false;
let isSpec = false;
let changes: string[] = [];
let specs: string[] = [];
if (params.typeOverride === 'change') {
changes = await getActiveChangeIds();
isChange = changes.includes(itemName);
} else if (params.typeOverride === 'spec') {
specs = await getSpecIds();
isSpec = specs.includes(itemName);
} else {
[changes, specs] = await Promise.all([getActiveChangeIds(), getSpecIds()]);
isChange = changes.includes(itemName);
isSpec = specs.includes(itemName);
}
const resolvedType = params.typeOverride ?? (isChange ? 'change' : isSpec ? 'spec' : undefined);
if (!resolvedType) {
console.error(`Unknown item '${itemName}'`);
const suggestions = nearestMatches(itemName, [...changes, ...specs]);
if (suggestions.length) console.error(`Did you mean: ${suggestions.join(', ')}?`);
process.exitCode = 1;
return;
}
if (!params.typeOverride && isChange && isSpec) {
console.error(`Ambiguous item '${itemName}' matches both a change and a spec.`);
console.error('Pass --type change|spec, or use: openspec change show / openspec spec show');
process.exitCode = 1;
return;
}
this.warnIrrelevantFlags(resolvedType, params.options);
if (resolvedType === 'change') {
const cmd = new ChangeCommand();
await cmd.show(itemName, params.options as any);
return;
}
const cmd = new SpecCommand();
await cmd.show(itemName, params.options as any);
}
private printNonInteractiveHint(): void {
console.error('Nothing to show. Try one of:');
console.error(' openspec show <item>');
console.error(' openspec change show');
console.error(' openspec spec show');
console.error('Or run in an interactive terminal.');
}
private warnIrrelevantFlags(type: ItemType, options: { [k: string]: any }): boolean {
const irrelevant: string[] = [];
if (type === 'change') {
for (const k of SPEC_FLAG_KEYS) if (k in options) irrelevant.push(k);
} else {
for (const k of CHANGE_FLAG_KEYS) if (k in options) irrelevant.push(k);
}
if (irrelevant.length > 0) {
console.error(`Warning: Ignoring flags not applicable to ${type}: ${irrelevant.join(', ')}`);
return true;
}
return false;
}
}
+48 -27
View File
@@ -10,6 +10,49 @@ import { getSpecIds } from '../utils/item-discovery.js';
const SPECS_DIR = 'openspec/specs';
export class SpecCommand {
private SPECS_DIR = 'openspec/specs';
async show(specId?: string, options: { json?: boolean; requirements?: boolean; scenarios?: boolean; requirement?: string; noInteractive?: boolean } = {}): Promise<void> {
if (!specId) {
const canPrompt = isInteractive(options?.noInteractive);
const specIds = await getSpecIds();
if (canPrompt && specIds.length > 0) {
specId = await select({
message: 'Select a spec to show',
choices: specIds.map(id => ({ name: id, value: id })),
});
} else {
throw new Error('Missing required argument <spec-id>');
}
}
const specPath = join(this.SPECS_DIR, specId, 'spec.md');
if (!existsSync(specPath)) {
throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`);
}
if (options.json) {
if (options.requirements && options.requirement) {
throw new Error('Options --requirements and --requirement cannot be used together');
}
const parsed = parseSpecFromFile(specPath, specId);
const filtered = filterSpec(parsed, options);
const output = {
id: specId,
title: parsed.name,
overview: parsed.overview,
requirementCount: filtered.requirements.length,
requirements: filtered.requirements,
metadata: parsed.metadata ?? { version: '1.0.0', format: 'openspec' as const },
};
console.log(JSON.stringify(output, null, 2));
return;
}
printSpecTextRaw(specPath);
}
}
export function registerSpecCommand(rootProgram: typeof program) {
const specCommand = rootProgram
.command('spec')
@@ -70,39 +113,17 @@ export function registerSpecCommand(rootProgram: typeof program) {
}
specCommand
.command('show <spec-id>')
.command('show [spec-id]')
.description('Display a specific specification')
.option('--json', 'Output as JSON')
.option('--requirements', 'JSON only: Show only requirements (exclude scenarios)')
.option('--no-scenarios', 'JSON only: Exclude scenario content')
.option('-r, --requirement <id>', 'JSON only: Show specific requirement by ID (1-based)')
.action((specId: string, options: ShowOptions) => {
.option('--no-interactive', 'Disable interactive prompts')
.action(async (specId: string | undefined, options: ShowOptions & { noInteractive?: boolean }) => {
try {
const specPath = join(SPECS_DIR, specId, 'spec.md');
if (!existsSync(specPath)) {
throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`);
}
if (options.json) {
if (options.requirements && options.requirement) {
throw new Error('Options --requirements and --requirement cannot be used together');
}
const parsed = parseSpecFromFile(specPath, specId);
const filtered = filterSpec(parsed, options);
const output = {
id: specId,
title: parsed.name,
overview: parsed.overview,
requirementCount: filtered.requirements.length,
requirements: filtered.requirements,
metadata: parsed.metadata ?? { version: '1.0.0', format: 'openspec' as const },
};
console.log(JSON.stringify(output, null, 2));
} else {
// raw-first text: print raw file
printSpecTextRaw(specPath);
}
const cmd = new SpecCommand();
await cmd.show(specId, options as any);
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
process.exitCode = 1;
+1 -25
View File
@@ -4,6 +4,7 @@ import path from 'path';
import { Validator } from '../core/validation/validator.js';
import { isInteractive } from '../utils/interactive.js';
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
import { nearestMatches } from '../utils/match.js';
type ItemType = 'change' | 'spec';
@@ -262,31 +263,6 @@ function summarizeType(results: BulkItemResult[], type: ItemType) {
return { items, passed, failed };
}
function nearestMatches(input: string, candidates: string[], max: number = 5): string[] {
const scored = candidates.map(c => ({ c, d: levenshtein(input, c) }));
scored.sort((a, b) => a.d - b.d);
return scored.slice(0, max).map(s => s.c);
}
function levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost
);
}
}
return dp[m][n];
}
function normalizeConcurrency(value?: string): number | undefined {
if (!value) return undefined;
const n = parseInt(value, 10);
+26
View File
@@ -0,0 +1,26 @@
export function nearestMatches(input: string, candidates: string[], max: number = 5): string[] {
const scored = candidates.map(candidate => ({ candidate, distance: levenshtein(input, candidate) }));
scored.sort((a, b) => a.distance - b.distance);
return scored.slice(0, max).map(s => s.candidate);
}
export function levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost
);
}
}
return dp[m][n];
}
@@ -0,0 +1,48 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { execSync } from 'child_process';
describe('change show (interactive behavior)', () => {
const projectRoot = process.cwd();
const testDir = path.join(projectRoot, 'test-change-show-tmp');
const changesDir = path.join(testDir, 'openspec', 'changes');
const bin = path.join(projectRoot, 'bin', 'openspec.js');
beforeAll(() => {
execSync('pnpm -s build', { stdio: 'pipe' });
});
beforeEach(async () => {
await fs.mkdir(changesDir, { recursive: true });
const content = `# Change: Demo\n\n## Why\n\n## What Changes\n- x`;
await fs.mkdir(path.join(changesDir, 'demo'), { recursive: true });
await fs.writeFile(path.join(changesDir, 'demo', 'proposal.md'), content, 'utf-8');
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('prints list hint and exits non-zero when no arg and non-interactive', () => {
const originalCwd = process.cwd();
const originalEnv = { ...process.env };
try {
process.chdir(testDir);
process.env.OPEN_SPEC_INTERACTIVE = '0';
let err: any;
try {
execSync(`node ${bin} change show`, { encoding: 'utf-8' });
} catch (e) { err = e; }
expect(err).toBeDefined();
expect(err.status).not.toBe(0);
expect(err.stderr.toString()).toContain('Available IDs:');
expect(err.stderr.toString()).toContain('openspec change list');
} finally {
process.chdir(originalCwd);
process.env = originalEnv;
}
});
});
+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { execSync } from 'child_process';
describe('top-level show command', () => {
const projectRoot = process.cwd();
const testDir = path.join(projectRoot, 'test-show-command-tmp');
const changesDir = path.join(testDir, 'openspec', 'changes');
const specsDir = path.join(testDir, 'openspec', 'specs');
const openspecBin = path.join(projectRoot, 'bin', 'openspec.js');
beforeAll(() => {
execSync('pnpm -s build', { stdio: 'pipe' });
});
beforeEach(async () => {
await fs.mkdir(changesDir, { recursive: true });
await fs.mkdir(specsDir, { recursive: true });
const changeContent = `# Change: Demo\n\n## Why\nBecause reasons.\n\n## What Changes\n- **auth:** Add requirement\n`;
await fs.mkdir(path.join(changesDir, 'demo'), { recursive: true });
await fs.writeFile(path.join(changesDir, 'demo', 'proposal.md'), changeContent, 'utf-8');
const specContent = `## Purpose\nAuth spec.\n\n## Requirements\n\n### Requirement: User Authentication\nText\n`;
await fs.mkdir(path.join(specsDir, 'auth'), { recursive: true });
await fs.writeFile(path.join(specsDir, 'auth', 'spec.md'), specContent, 'utf-8');
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('prints hint and non-zero exit when no args and non-interactive', () => {
const originalCwd = process.cwd();
const originalEnv = { ...process.env };
try {
process.chdir(testDir);
process.env.OPEN_SPEC_INTERACTIVE = '0';
let err: any;
try {
execSync(`node ${openspecBin} show`, { encoding: 'utf-8' });
} catch (e) { err = e; }
expect(err).toBeDefined();
expect(err.status).not.toBe(0);
const stderr = err.stderr.toString();
expect(stderr).toContain('Nothing to show.');
expect(stderr).toContain('openspec show <item>');
expect(stderr).toContain('openspec change show');
expect(stderr).toContain('openspec spec show');
} finally {
process.chdir(originalCwd);
process.env = originalEnv;
}
});
it('auto-detects change id and supports --json', () => {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
const output = execSync(`node ${openspecBin} show demo --json`, { encoding: 'utf-8' });
const json = JSON.parse(output);
expect(json.id).toBe('demo');
expect(Array.isArray(json.deltas)).toBe(true);
} finally {
process.chdir(originalCwd);
}
});
it('auto-detects spec id and supports spec-only flags', () => {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
const output = execSync(`node ${openspecBin} show auth --json --requirements`, { encoding: 'utf-8' });
const json = JSON.parse(output);
expect(json.id).toBe('auth');
expect(Array.isArray(json.requirements)).toBe(true);
} finally {
process.chdir(originalCwd);
}
});
it('handles ambiguity and suggests --type', async () => {
// create matching spec and change named 'foo'
await fs.mkdir(path.join(changesDir, 'foo'), { recursive: true });
await fs.writeFile(path.join(changesDir, 'foo', 'proposal.md'), '# Change: Foo\n\n## Why\n\n## What Changes\n', 'utf-8');
await fs.mkdir(path.join(specsDir, 'foo'), { recursive: true });
await fs.writeFile(path.join(specsDir, 'foo', 'spec.md'), '## Purpose\n\n## Requirements\n\n### Requirement: R\nX', 'utf-8');
const originalCwd = process.cwd();
try {
process.chdir(testDir);
let err: any;
try {
execSync(`node ${openspecBin} show foo`, { encoding: 'utf-8' });
} catch (e) { err = e; }
expect(err).toBeDefined();
expect(err.status).not.toBe(0);
const stderr = err.stderr.toString();
expect(stderr).toContain('Ambiguous item');
expect(stderr).toContain('--type change|spec');
} finally {
process.chdir(originalCwd);
}
});
it('prints nearest matches when not found', () => {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
let err: any;
try {
execSync(`node ${openspecBin} show unknown-item`, { encoding: 'utf-8' });
} catch (e) { err = e; }
expect(err).toBeDefined();
expect(err.status).not.toBe(0);
const stderr = err.stderr.toString();
expect(stderr).toContain("Unknown item 'unknown-item'");
expect(stderr).toContain('Did you mean:');
} finally {
process.chdir(originalCwd);
}
});
});
@@ -0,0 +1,47 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { execSync } from 'child_process';
describe('spec show (interactive behavior)', () => {
const projectRoot = process.cwd();
const testDir = path.join(projectRoot, 'test-spec-show-tmp');
const specsDir = path.join(testDir, 'openspec', 'specs');
const bin = path.join(projectRoot, 'bin', 'openspec.js');
beforeAll(() => {
execSync('pnpm -s build', { stdio: 'pipe' });
});
beforeEach(async () => {
await fs.mkdir(specsDir, { recursive: true });
const content = `## Purpose\nX\n\n## Requirements\n\n### Requirement: R\nText`;
await fs.mkdir(path.join(specsDir, 's1'), { recursive: true });
await fs.writeFile(path.join(specsDir, 's1', 'spec.md'), content, 'utf-8');
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('errors when no arg and non-interactive', () => {
const originalCwd = process.cwd();
const originalEnv = { ...process.env };
try {
process.chdir(testDir);
process.env.OPEN_SPEC_INTERACTIVE = '0';
let err: any;
try {
execSync(`node ${bin} spec show`, { encoding: 'utf-8' });
} catch (e) { err = e; }
expect(err).toBeDefined();
expect(err.status).not.toBe(0);
expect(err.stderr.toString()).toContain('Missing required argument <spec-id>');
} finally {
process.chdir(originalCwd);
process.env = originalEnv;
}
});
});
+2 -2
View File
@@ -1,9 +1,9 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"module": "NodeNext",
"lib": ["ES2022"],
"moduleResolution": "node",
"moduleResolution": "NodeNext",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,