UNPKG

@fission-ai/openspec

Version:

AI-native system for spec-driven development

482 lines 18.1 kB
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