@fission-ai/openspec
Version:
AI-native system for spec-driven development
482 lines • 18.1 kB
JavaScript
import { Command } from 'commander';
import { createRequire } from 'module';
import ora from 'ora';
import path from 'path';
import { promises as fs } from 'fs';
import { AI_TOOLS } from '../core/config.js';
import { UpdateCommand } from '../core/update.js';
import { ListCommand } from '../core/list.js';
import { ArchiveCommand } from '../core/archive.js';
import { ViewCommand } from '../core/view.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';
import { CompletionCommand } from '../commands/completion.js';
import { FeedbackCommand } from '../commands/feedback.js';
import { registerConfigCommand } from '../commands/config.js';
import { registerSchemaCommand } from '../commands/schema.js';
import { statusCommand, instructionsCommand, applyInstructionsCommand, templatesCommand, schemasCommand, newChangeCommand, DEFAULT_SCHEMA, } from '../commands/workflow/index.js';
import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
const program = new Command();
const require = createRequire(import.meta.url);
const { version } = require('../../package.json');
/**
* Get the full command path for nested commands.
* For example: 'change show' -> 'change:show'
*/
function getCommandPath(command) {
const names = [];
let current = command;
while (current) {
const name = current.name();
// Skip the root 'openspec' command
if (name && name !== 'openspec') {
names.unshift(name);
}
current = current.parent;
}
return names.join(':') || 'openspec';
}
program
.name('openspec')
.description('AI-native system for spec-driven development')
.version(version);
// Global options
program.option('--no-color', 'Disable color output');
// Apply global flags and telemetry before any command runs
// Note: preAction receives (thisCommand, actionCommand) where:
// - thisCommand: the command where hook was added (root program)
// - actionCommand: the command actually being executed (subcommand)
program.hook('preAction', async (thisCommand, actionCommand) => {
const opts = thisCommand.opts();
if (opts.color === false) {
process.env.NO_COLOR = '1';
}
// Show first-run telemetry notice (if not seen)
await maybeShowTelemetryNotice();
// Track command execution (use actionCommand to get the actual subcommand)
const commandPath = getCommandPath(actionCommand);
await trackCommand(commandPath, version);
});
// Shutdown telemetry after command completes
program.hook('postAction', async () => {
await shutdown();
});
const availableToolIds = AI_TOOLS.filter((tool) => tool.skillsDir).map((tool) => tool.value);
const toolsOptionDescription = `Configure AI tools non-interactively. Use "all", "none", or a comma-separated list of: ${availableToolIds.join(', ')}`;
program
.command('init [path]')
.description('Initialize OpenSpec in your project')
.option('--tools <tools>', toolsOptionDescription)
.option('--force', 'Auto-cleanup legacy files without prompting')
.option('--profile <profile>', 'Override global config profile (core or custom)')
.action(async (targetPath = '.', options) => {
try {
// Validate that the path is a valid directory
const resolvedPath = path.resolve(targetPath);
try {
const stats = await fs.stat(resolvedPath);
if (!stats.isDirectory()) {
throw new Error(`Path "${targetPath}" is not a directory`);
}
}
catch (error) {
if (error.code === 'ENOENT') {
// Directory doesn't exist, but we can create it
console.log(`Directory "${targetPath}" doesn't exist, it will be created.`);
}
else if (error.message && error.message.includes('not a directory')) {
throw error;
}
else {
throw new Error(`Cannot access path "${targetPath}": ${error.message}`);
}
}
const { InitCommand } = await import('../core/init.js');
const initCommand = new InitCommand({
tools: options?.tools,
force: options?.force,
profile: options?.profile,
});
await initCommand.execute(targetPath);
}
catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${error.message}`);
process.exit(1);
}
});
// Hidden alias: 'experimental' -> 'init' for backwards compatibility
program
.command('experimental', { hidden: true })
.description('Alias for init (deprecated)')
.option('--tool <tool-id>', 'Target AI tool (maps to --tools)')
.option('--no-interactive', 'Disable interactive prompts')
.action(async (options) => {
try {
console.log('Note: "openspec experimental" is deprecated. Use "openspec init" instead.');
const { InitCommand } = await import('../core/init.js');
const initCommand = new InitCommand({
tools: options?.tool,
interactive: options?.noInteractive === true ? false : undefined,
});
await initCommand.execute('.');
}
catch (error) {
console.log();
ora().fail(`Error: ${error.message}`);
process.exit(1);
}
});
program
.command('update [path]')
.description('Update OpenSpec instruction files')
.option('--force', 'Force update even when tools are up to date')
.action(async (targetPath = '.', options) => {
try {
const resolvedPath = path.resolve(targetPath);
const updateCommand = new UpdateCommand({ force: options?.force });
await updateCommand.execute(resolvedPath);
}
catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${error.message}`);
process.exit(1);
}
});
program
.command('list')
.description('List items (changes by default). Use --specs to list specs.')
.option('--specs', 'List specs instead of changes')
.option('--changes', 'List changes explicitly (default)')
.option('--sort <order>', 'Sort order: "recent" (default) or "name"', 'recent')
.option('--json', 'Output as JSON (for programmatic use)')
.action(async (options) => {
try {
const listCommand = new ListCommand();
const mode = options?.specs ? 'specs' : 'changes';
const sort = options?.sort === 'name' ? 'name' : 'recent';
await listCommand.execute('.', mode, { sort, json: options?.json });
}
catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${error.message}`);
process.exit(1);
}
});
program
.command('view')
.description('Display an interactive dashboard of specs and changes')
.action(async () => {
try {
const viewCommand = new ViewCommand();
await viewCommand.execute('.');
}
catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${error.message}`);
process.exit(1);
}
});
// Change command with subcommands
const changeCmd = program
.command('change')
.description('Manage OpenSpec change proposals');
// Deprecation notice for noun-based commands
changeCmd.hook('preAction', () => {
console.error('Warning: The "openspec change ..." commands are deprecated. Prefer verb-first commands (e.g., "openspec list", "openspec validate --changes").');
});
changeCmd
.command('show [change-name]')
.description('Show a change proposal in JSON or markdown format')
.option('--json', 'Output as JSON')
.option('--deltas-only', 'Show only deltas (JSON only)')
.option('--requirements-only', 'Alias for --deltas-only (deprecated)')
.option('--no-interactive', 'Disable interactive prompts')
.action(async (changeName, options) => {
try {
const changeCommand = new ChangeCommand();
await changeCommand.show(changeName, options);
}
catch (error) {
console.error(`Error: ${error.message}`);
process.exitCode = 1;
}
});
changeCmd
.command('list')
.description('List all active changes (DEPRECATED: use "openspec list" instead)')
.option('--json', 'Output as JSON')
.option('--long', 'Show id and title with counts')
.action(async (options) => {
try {
console.error('Warning: "openspec change list" is deprecated. Use "openspec list".');
const changeCommand = new ChangeCommand();
await changeCommand.list(options);
}
catch (error) {
console.error(`Error: ${error.message}`);
process.exitCode = 1;
}
});
changeCmd
.command('validate [change-name]')
.description('Validate a change proposal')
.option('--strict', 'Enable strict validation mode')
.option('--json', 'Output validation report as JSON')
.option('--no-interactive', 'Disable interactive prompts')
.action(async (changeName, options) => {
try {
const changeCommand = new ChangeCommand();
await changeCommand.validate(changeName, options);
if (typeof process.exitCode === 'number' && process.exitCode !== 0) {
process.exit(process.exitCode);
}
}
catch (error) {
console.error(`Error: ${error.message}`);
process.exitCode = 1;
}
});
program
.command('archive [change-name]')
.description('Archive a completed change and update main specs')
.option('-y, --yes', 'Skip confirmation prompts')
.option('--skip-specs', 'Skip spec update operations (useful for infrastructure, tooling, or doc-only changes)')
.option('--no-validate', 'Skip validation (not recommended, requires confirmation)')
.action(async (changeName, options) => {
try {
const archiveCommand = new ArchiveCommand();
await archiveCommand.execute(changeName, options);
}
catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${error.message}`);
process.exit(1);
}
});
registerSpecCommand(program);
registerConfigCommand(program);
registerSchemaCommand(program);
// Top-level validate command
program
.command('validate [item-name]')
.description('Validate changes and specs')
.option('--all', 'Validate all changes and specs')
.option('--changes', 'Validate all changes')
.option('--specs', 'Validate all specs')
.option('--type <type>', 'Specify item type when ambiguous: change|spec')
.option('--strict', 'Enable strict validation mode')
.option('--json', 'Output validation results as JSON')
.option('--concurrency <n>', 'Max concurrent validations (defaults to env OPENSPEC_CONCURRENCY or 6)')
.option('--no-interactive', 'Disable interactive prompts')
.action(async (itemName, options) => {
try {
const validateCommand = new ValidateCommand();
await validateCommand.execute(itemName, options);
}
catch (error) {
console.log();
ora().fail(`Error: ${error.message}`);
process.exit(1);
}
});
// 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, options) => {
try {
const showCommand = new ShowCommand();
await showCommand.execute(itemName, options ?? {});
}
catch (error) {
console.log();
ora().fail(`Error: ${error.message}`);
process.exit(1);
}
});
// Feedback command
program
.command('feedback <message>')
.description('Submit feedback about OpenSpec')
.option('--body <text>', 'Detailed description for the feedback')
.action(async (message, options) => {
try {
const feedbackCommand = new FeedbackCommand();
await feedbackCommand.execute(message, options);
}
catch (error) {
console.log();
ora().fail(`Error: ${error.message}`);
process.exit(1);
}
});
// Completion command with subcommands
const completionCmd = program
.command('completion')
.description('Manage shell completions for OpenSpec CLI');
completionCmd
.command('generate [shell]')
.description('Generate completion script for a shell (outputs to stdout)')
.action(async (shell) => {
try {
const completionCommand = new CompletionCommand();
await completionCommand.generate({ shell });
}
catch (error) {
console.log();
ora().fail(`Error: ${error.message}`);
process.exit(1);
}
});
completionCmd
.command('install [shell]')
.description('Install completion script for a shell')
.option('--verbose', 'Show detailed installation output')
.action(async (shell, options) => {
try {
const completionCommand = new CompletionCommand();
await completionCommand.install({ shell, verbose: options?.verbose });
}
catch (error) {
console.log();
ora().fail(`Error: ${error.message}`);
process.exit(1);
}
});
completionCmd
.command('uninstall [shell]')
.description('Uninstall completion script for a shell')
.option('-y, --yes', 'Skip confirmation prompts')
.action(async (shell, options) => {
try {
const completionCommand = new CompletionCommand();
await completionCommand.uninstall({ shell, yes: options?.yes });
}
catch (error) {
console.log();
ora().fail(`Error: ${error.message}`);
process.exit(1);
}
});
// Hidden command for machine-readable completion data
program
.command('__complete <type>', { hidden: true })
.description('Output completion data in machine-readable format (internal use)')
.action(async (type) => {
try {
const completionCommand = new CompletionCommand();
await completionCommand.complete({ type });
}
catch (error) {
// Silently fail for graceful shell completion experience
process.exitCode = 1;
}
});
// ═══════════════════════════════════════════════════════════
// Workflow Commands (formerly experimental)
// ═══════════════════════════════════════════════════════════
// Status command
program
.command('status')
.description('Display artifact completion status for a change')
.option('--change <id>', 'Change name to show status for')
.option('--schema <name>', 'Schema override (auto-detected from config.yaml)')
.option('--json', 'Output as JSON')
.action(async (options) => {
try {
await statusCommand(options);
}
catch (error) {
console.log();
ora().fail(`Error: ${error.message}`);
process.exit(1);
}
});
// Instructions command
program
.command('instructions [artifact]')
.description('Output enriched instructions for creating an artifact or applying tasks')
.option('--change <id>', 'Change name')
.option('--schema <name>', 'Schema override (auto-detected from config.yaml)')
.option('--json', 'Output as JSON')
.action(async (artifactId, options) => {
try {
// Special case: "apply" is not an artifact, but a command to get apply instructions
if (artifactId === 'apply') {
await applyInstructionsCommand(options);
}
else {
await instructionsCommand(artifactId, options);
}
}
catch (error) {
console.log();
ora().fail(`Error: ${error.message}`);
process.exit(1);
}
});
// Templates command
program
.command('templates')
.description('Show resolved template paths for all artifacts in a schema')
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
.option('--json', 'Output as JSON mapping artifact IDs to template paths')
.action(async (options) => {
try {
await templatesCommand(options);
}
catch (error) {
console.log();
ora().fail(`Error: ${error.message}`);
process.exit(1);
}
});
// Schemas command
program
.command('schemas')
.description('List available workflow schemas with descriptions')
.option('--json', 'Output as JSON (for agent use)')
.action(async (options) => {
try {
await schemasCommand(options);
}
catch (error) {
console.log();
ora().fail(`Error: ${error.message}`);
process.exit(1);
}
});
// New command group with change subcommand
const newCmd = program.command('new').description('Create new items');
newCmd
.command('change <name>')
.description('Create a new change directory')
.option('--description <text>', 'Description to add to README.md')
.option('--schema <name>', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`)
.action(async (name, options) => {
try {
await newChangeCommand(name, options);
}
catch (error) {
console.log();
ora().fail(`Error: ${error.message}`);
process.exit(1);
}
});
program.parse();
//# sourceMappingURL=index.js.map