UNPKG

@fission-ai/openspec

Version:

AI-native system for spec-driven development

225 lines 9.4 kB
import { existsSync, readdirSync, readFileSync } from 'fs'; import { join } from 'path'; import { MarkdownParser } from '../core/parsers/markdown-parser.js'; import { Validator } from '../core/validation/validator.js'; import { isInteractive } from '../utils/interactive.js'; import { getSpecIds } from '../utils/item-discovery.js'; const SPECS_DIR = 'openspec/specs'; function parseSpecFromFile(specPath, specId) { const content = readFileSync(specPath, 'utf-8'); const parser = new MarkdownParser(content); return parser.parseSpec(specId); } function validateRequirementIndex(spec, requirementOpt) { if (!requirementOpt) return undefined; const index = Number.parseInt(requirementOpt, 10); if (!Number.isInteger(index) || index < 1 || index > spec.requirements.length) { throw new Error(`Requirement ${requirementOpt} not found`); } return index - 1; // convert to 0-based } function filterSpec(spec, options) { const requirementIndex = validateRequirementIndex(spec, options.requirement); const includeScenarios = options.scenarios !== false && !options.requirements; const filteredRequirements = (requirementIndex !== undefined ? [spec.requirements[requirementIndex]] : spec.requirements).map(req => ({ text: req.text, scenarios: includeScenarios ? req.scenarios : [], })); const metadata = spec.metadata ?? { version: '1.0.0', format: 'openspec' }; return { name: spec.name, overview: spec.overview, requirements: filteredRequirements, metadata, }; } /** * Print the raw markdown content for a spec file without any formatting. * Raw-first behavior ensures text mode is a passthrough for deterministic output. */ function printSpecTextRaw(specPath) { const content = readFileSync(specPath, 'utf-8'); console.log(content); } export class SpecCommand { SPECS_DIR = 'openspec/specs'; async show(specId, options = {}) { if (!specId) { const canPrompt = isInteractive(options); const specIds = await getSpecIds(); if (canPrompt && specIds.length > 0) { const { select } = await import('@inquirer/prompts'); 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' }, }; console.log(JSON.stringify(output, null, 2)); return; } printSpecTextRaw(specPath); } } export function registerSpecCommand(rootProgram) { const specCommand = rootProgram .command('spec') .description('Manage and view OpenSpec specifications'); // Deprecation notice for noun-based commands specCommand.hook('preAction', () => { console.error('Warning: The "openspec spec ..." commands are deprecated. Prefer verb-first commands (e.g., "openspec show", "openspec validate --specs").'); }); specCommand .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)') .option('--no-interactive', 'Disable interactive prompts') .action(async (specId, options) => { try { const cmd = new SpecCommand(); await cmd.show(specId, options); } catch (error) { console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); process.exitCode = 1; } }); specCommand .command('list') .description('List all available specifications') .option('--json', 'Output as JSON') .option('--long', 'Show id and title with counts') .action((options) => { try { if (!existsSync(SPECS_DIR)) { console.log('No items found'); return; } const specs = readdirSync(SPECS_DIR, { withFileTypes: true }) .filter(dirent => dirent.isDirectory()) .map(dirent => { const specPath = join(SPECS_DIR, dirent.name, 'spec.md'); if (existsSync(specPath)) { try { const spec = parseSpecFromFile(specPath, dirent.name); return { id: dirent.name, title: spec.name, requirementCount: spec.requirements.length }; } catch { return { id: dirent.name, title: dirent.name, requirementCount: 0 }; } } return null; }) .filter((spec) => spec !== null) .sort((a, b) => a.id.localeCompare(b.id)); if (options.json) { console.log(JSON.stringify(specs, null, 2)); } else { if (specs.length === 0) { console.log('No items found'); return; } if (!options.long) { specs.forEach(spec => console.log(spec.id)); return; } specs.forEach(spec => { console.log(`${spec.id}: ${spec.title} [requirements ${spec.requirementCount}]`); }); } } catch (error) { console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); process.exitCode = 1; } }); specCommand .command('validate [spec-id]') .description('Validate a specification structure') .option('--strict', 'Enable strict validation mode') .option('--json', 'Output validation report as JSON') .option('--no-interactive', 'Disable interactive prompts') .action(async (specId, options) => { try { if (!specId) { const canPrompt = isInteractive(options); const specIds = await getSpecIds(); if (canPrompt && specIds.length > 0) { const { select } = await import('@inquirer/prompts'); specId = await select({ message: 'Select a spec to validate', choices: specIds.map(id => ({ name: id, value: id })), }); } else { throw new Error('Missing required argument <spec-id>'); } } const specPath = join(SPECS_DIR, specId, 'spec.md'); if (!existsSync(specPath)) { throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`); } const validator = new Validator(options.strict); const report = await validator.validateSpec(specPath); if (options.json) { console.log(JSON.stringify(report, null, 2)); } else { if (report.valid) { console.log(`Specification '${specId}' is valid`); } else { console.error(`Specification '${specId}' has issues`); report.issues.forEach(issue => { const label = issue.level === 'ERROR' ? 'ERROR' : issue.level; const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ'; console.error(`${prefix} [${label}] ${issue.path}: ${issue.message}`); }); } } process.exitCode = report.valid ? 0 : 1; } catch (error) { console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); process.exitCode = 1; } }); return specCommand; } //# sourceMappingURL=spec.js.map