mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 14:38:54 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e17cfdd0b | ||
|
|
5d185ba3a8 | ||
|
|
08b41c7bea |
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"esModuleInterop": true,
|
||||
|
||||
Reference in New Issue
Block a user