@fission-ai/openspec
Version:
AI-native system for spec-driven development
225 lines • 9.4 kB
JavaScript
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