UNPKG

@fission-ai/openspec

Version:

AI-native system for spec-driven development

552 lines 21.9 kB
import { spawn, execSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { getGlobalConfigPath, getGlobalConfig, saveGlobalConfig, } from '../core/global-config.js'; import { getNestedValue, setNestedValue, deleteNestedValue, coerceValue, formatValueYaml, validateConfigKeyPath, validateConfig, DEFAULT_CONFIG, } from '../core/config-schema.js'; import { CORE_WORKFLOWS, ALL_WORKFLOWS, getProfileWorkflows } from '../core/profiles.js'; import { OPENSPEC_DIR_NAME } from '../core/config.js'; import { hasProjectConfigDrift } from '../core/profile-sync-drift.js'; const WORKFLOW_PROMPT_META = { propose: { name: 'Propose change', description: 'Create proposal, design, and tasks from a request', }, explore: { name: 'Explore ideas', description: 'Investigate a problem before implementation', }, new: { name: 'New change', description: 'Create a new change scaffold quickly', }, continue: { name: 'Continue change', description: 'Resume work on an existing change', }, apply: { name: 'Apply tasks', description: 'Implement tasks from the current change', }, ff: { name: 'Fast-forward', description: 'Run a faster implementation workflow', }, sync: { name: 'Sync specs', description: 'Sync change artifacts with specs', }, archive: { name: 'Archive change', description: 'Finalize and archive a completed change', }, 'bulk-archive': { name: 'Bulk archive', description: 'Archive multiple completed changes together', }, verify: { name: 'Verify change', description: 'Run verification checks against a change', }, onboard: { name: 'Onboard', description: 'Guided onboarding flow for OpenSpec', }, }; function isPromptCancellationError(error) { return (error instanceof Error && (error.name === 'ExitPromptError' || error.message.includes('force closed the prompt with SIGINT'))); } /** * Resolve the effective current profile state from global config defaults. */ export function resolveCurrentProfileState(config) { const profile = config.profile || 'core'; const delivery = config.delivery || 'both'; const workflows = [ ...getProfileWorkflows(profile, config.workflows ? [...config.workflows] : undefined), ]; return { profile, delivery, workflows }; } /** * Derive profile type from selected workflows. */ export function deriveProfileFromWorkflowSelection(selectedWorkflows) { const isCoreMatch = selectedWorkflows.length === CORE_WORKFLOWS.length && CORE_WORKFLOWS.every((w) => selectedWorkflows.includes(w)); return isCoreMatch ? 'core' : 'custom'; } /** * Format a compact workflow summary for the profile header. */ export function formatWorkflowSummary(workflows, profile) { return `${workflows.length} selected (${profile})`; } function stableWorkflowOrder(workflows) { const seen = new Set(); const ordered = []; for (const workflow of ALL_WORKFLOWS) { if (workflows.includes(workflow) && !seen.has(workflow)) { ordered.push(workflow); seen.add(workflow); } } const extras = workflows.filter((w) => !ALL_WORKFLOWS.includes(w)); extras.sort(); for (const extra of extras) { if (!seen.has(extra)) { ordered.push(extra); seen.add(extra); } } return ordered; } /** * Build a user-facing diff summary between two profile states. */ export function diffProfileState(before, after) { const lines = []; if (before.delivery !== after.delivery) { lines.push(`delivery: ${before.delivery} -> ${after.delivery}`); } if (before.profile !== after.profile) { lines.push(`profile: ${before.profile} -> ${after.profile}`); } const beforeOrdered = stableWorkflowOrder(before.workflows); const afterOrdered = stableWorkflowOrder(after.workflows); const beforeSet = new Set(beforeOrdered); const afterSet = new Set(afterOrdered); const added = afterOrdered.filter((w) => !beforeSet.has(w)); const removed = beforeOrdered.filter((w) => !afterSet.has(w)); if (added.length > 0 || removed.length > 0) { const tokens = []; if (added.length > 0) { tokens.push(`added ${added.join(', ')}`); } if (removed.length > 0) { tokens.push(`removed ${removed.join(', ')}`); } lines.push(`workflows: ${tokens.join('; ')}`); } return { hasChanges: lines.length > 0, lines, }; } function maybeWarnConfigDrift(projectDir, state, colorize) { const openspecDir = path.join(projectDir, OPENSPEC_DIR_NAME); if (!fs.existsSync(openspecDir)) { return; } if (!hasProjectConfigDrift(projectDir, state.workflows, state.delivery)) { return; } console.log(colorize('Warning: Global config is not applied to this project. Run `openspec update` to sync.')); } /** * Register the config command and all its subcommands. * * @param program - The Commander program instance */ export function registerConfigCommand(program) { const configCmd = program .command('config') .description('View and modify global OpenSpec configuration') .option('--scope <scope>', 'Config scope (only "global" supported currently)') .hook('preAction', (thisCommand) => { const opts = thisCommand.opts(); if (opts.scope && opts.scope !== 'global') { console.error('Error: Project-local config is not yet implemented'); process.exit(1); } }); // config path configCmd .command('path') .description('Show config file location') .action(() => { console.log(getGlobalConfigPath()); }); // config list configCmd .command('list') .description('Show all current settings') .option('--json', 'Output as JSON') .action((options) => { const config = getGlobalConfig(); if (options.json) { console.log(JSON.stringify(config, null, 2)); } else { // Read raw config to determine which values are explicit vs defaults const configPath = getGlobalConfigPath(); let rawConfig = {}; try { if (fs.existsSync(configPath)) { rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')); } } catch { // If reading fails, treat all as defaults } console.log(formatValueYaml(config)); // Annotate profile settings const profileSource = rawConfig.profile !== undefined ? '(explicit)' : '(default)'; const deliverySource = rawConfig.delivery !== undefined ? '(explicit)' : '(default)'; console.log(`\nProfile settings:`); console.log(` profile: ${config.profile} ${profileSource}`); console.log(` delivery: ${config.delivery} ${deliverySource}`); if (config.profile === 'core') { console.log(` workflows: ${CORE_WORKFLOWS.join(', ')} (from core profile)`); } else if (config.workflows && config.workflows.length > 0) { console.log(` workflows: ${config.workflows.join(', ')} (explicit)`); } else { console.log(` workflows: (none)`); } } }); // config get configCmd .command('get <key>') .description('Get a specific value (raw, scriptable)') .action((key) => { const config = getGlobalConfig(); const value = getNestedValue(config, key); if (value === undefined) { process.exitCode = 1; return; } if (typeof value === 'object' && value !== null) { console.log(JSON.stringify(value)); } else { console.log(String(value)); } }); // config set configCmd .command('set <key> <value>') .description('Set a value (auto-coerce types)') .option('--string', 'Force value to be stored as string') .option('--allow-unknown', 'Allow setting unknown keys') .action((key, value, options) => { const allowUnknown = Boolean(options.allowUnknown); const keyValidation = validateConfigKeyPath(key); if (!keyValidation.valid && !allowUnknown) { const reason = keyValidation.reason ? ` ${keyValidation.reason}.` : ''; console.error(`Error: Invalid configuration key "${key}".${reason}`); console.error('Use "openspec config list" to see available keys.'); console.error('Pass --allow-unknown to bypass this check.'); process.exitCode = 1; return; } const config = getGlobalConfig(); const coercedValue = coerceValue(value, options.string || false); // Create a copy to validate before saving const newConfig = JSON.parse(JSON.stringify(config)); setNestedValue(newConfig, key, coercedValue); // Validate the new config const validation = validateConfig(newConfig); if (!validation.success) { console.error(`Error: Invalid configuration - ${validation.error}`); process.exitCode = 1; return; } // Apply changes and save setNestedValue(config, key, coercedValue); saveGlobalConfig(config); const displayValue = typeof coercedValue === 'string' ? `"${coercedValue}"` : String(coercedValue); console.log(`Set ${key} = ${displayValue}`); }); // config unset configCmd .command('unset <key>') .description('Remove a key (revert to default)') .action((key) => { const config = getGlobalConfig(); const existed = deleteNestedValue(config, key); if (existed) { saveGlobalConfig(config); console.log(`Unset ${key} (reverted to default)`); } else { console.log(`Key "${key}" was not set`); } }); // config reset configCmd .command('reset') .description('Reset configuration to defaults') .option('--all', 'Reset all configuration (required)') .option('-y, --yes', 'Skip confirmation prompts') .action(async (options) => { if (!options.all) { console.error('Error: --all flag is required for reset'); console.error('Usage: openspec config reset --all [-y]'); process.exitCode = 1; return; } if (!options.yes) { const { confirm } = await import('@inquirer/prompts'); let confirmed; try { confirmed = await confirm({ message: 'Reset all configuration to defaults?', default: false, }); } catch (error) { if (isPromptCancellationError(error)) { console.log('Reset cancelled.'); process.exitCode = 130; return; } throw error; } if (!confirmed) { console.log('Reset cancelled.'); return; } } saveGlobalConfig({ ...DEFAULT_CONFIG }); console.log('Configuration reset to defaults'); }); // config edit configCmd .command('edit') .description('Open config in $EDITOR') .action(async () => { const editor = process.env.EDITOR || process.env.VISUAL; if (!editor) { console.error('Error: No editor configured'); console.error('Set the EDITOR or VISUAL environment variable to your preferred editor'); console.error('Example: export EDITOR=vim'); process.exitCode = 1; return; } const configPath = getGlobalConfigPath(); // Ensure config file exists with defaults if (!fs.existsSync(configPath)) { saveGlobalConfig({ ...DEFAULT_CONFIG }); } // Spawn editor and wait for it to close // Avoid shell parsing to correctly handle paths with spaces in both // the editor path and config path const child = spawn(editor, [configPath], { stdio: 'inherit', shell: false, }); await new Promise((resolve, reject) => { child.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`Editor exited with code ${code}`)); } }); child.on('error', reject); }); try { const rawConfig = fs.readFileSync(configPath, 'utf-8'); const parsedConfig = JSON.parse(rawConfig); const validation = validateConfig(parsedConfig); if (!validation.success) { console.error(`Error: Invalid configuration - ${validation.error}`); process.exitCode = 1; } } catch (error) { if (error.code === 'ENOENT') { console.error(`Error: Config file not found at ${configPath}`); } else if (error instanceof SyntaxError) { console.error(`Error: Invalid JSON in ${configPath}`); console.error(error.message); } else { console.error(`Error: Unable to validate configuration - ${error instanceof Error ? error.message : String(error)}`); } process.exitCode = 1; } }); // config profile [preset] configCmd .command('profile [preset]') .description('Configure workflow profile (interactive picker or preset shortcut)') .action(async (preset) => { // Preset shortcut: `openspec config profile core` if (preset === 'core') { const config = getGlobalConfig(); config.profile = 'core'; config.workflows = [...CORE_WORKFLOWS]; // Preserve delivery setting saveGlobalConfig(config); console.log('Config updated. Run `openspec update` in your projects to apply.'); return; } if (preset) { console.error(`Error: Unknown profile preset "${preset}". Available presets: core`); process.exitCode = 1; return; } // Non-interactive check if (!process.stdout.isTTY) { console.error('Interactive mode required. Use `openspec config profile core` or set config via environment/flags.'); process.exitCode = 1; return; } // Interactive picker const { select, checkbox, confirm } = await import('@inquirer/prompts'); const chalk = (await import('chalk')).default; try { const config = getGlobalConfig(); const currentState = resolveCurrentProfileState(config); console.log(chalk.bold('\nCurrent profile settings')); console.log(` Delivery: ${currentState.delivery}`); console.log(` Workflows: ${formatWorkflowSummary(currentState.workflows, currentState.profile)}`); console.log(chalk.dim(' Delivery = where workflows are installed (skills, commands, or both)')); console.log(chalk.dim(' Workflows = which actions are available (propose, explore, apply, etc.)')); console.log(); const action = await select({ message: 'What do you want to configure?', choices: [ { value: 'both', name: 'Delivery and workflows', description: 'Update install mode and available actions together', }, { value: 'delivery', name: 'Delivery only', description: 'Change where workflows are installed', }, { value: 'workflows', name: 'Workflows only', description: 'Change which workflow actions are available', }, { value: 'keep', name: 'Keep current settings (exit)', description: 'Leave configuration unchanged and exit', }, ], }); if (action === 'keep') { console.log('No config changes.'); maybeWarnConfigDrift(process.cwd(), currentState, chalk.yellow); return; } const nextState = { profile: currentState.profile, delivery: currentState.delivery, workflows: [...currentState.workflows], }; if (action === 'both' || action === 'delivery') { const deliveryChoices = [ { value: 'both', name: 'Both (skills + commands)', description: 'Install workflows as both skills and slash commands', }, { value: 'skills', name: 'Skills only', description: 'Install workflows only as skills', }, { value: 'commands', name: 'Commands only', description: 'Install workflows only as slash commands', }, ]; for (const choice of deliveryChoices) { if (choice.value === currentState.delivery) { choice.name += ' [current]'; } } nextState.delivery = await select({ message: 'Delivery mode (how workflows are installed):', choices: deliveryChoices, default: currentState.delivery, }); } if (action === 'both' || action === 'workflows') { const formatWorkflowChoice = (workflow) => { const metadata = WORKFLOW_PROMPT_META[workflow] ?? { name: workflow, description: `Workflow: ${workflow}`, }; return { value: workflow, name: metadata.name, description: metadata.description, short: metadata.name, checked: currentState.workflows.includes(workflow), }; }; const selectedWorkflows = await checkbox({ message: 'Select workflows to make available:', instructions: 'Space to toggle, Enter to confirm', pageSize: ALL_WORKFLOWS.length, theme: { icon: { checked: '[x]', unchecked: '[ ]', }, }, choices: ALL_WORKFLOWS.map(formatWorkflowChoice), }); nextState.workflows = selectedWorkflows; nextState.profile = deriveProfileFromWorkflowSelection(selectedWorkflows); } const diff = diffProfileState(currentState, nextState); if (!diff.hasChanges) { console.log('No config changes.'); maybeWarnConfigDrift(process.cwd(), nextState, chalk.yellow); return; } console.log(chalk.bold('\nConfig changes:')); for (const line of diff.lines) { console.log(` ${line}`); } console.log(); config.profile = nextState.profile; config.delivery = nextState.delivery; config.workflows = nextState.workflows; saveGlobalConfig(config); // Check if inside an OpenSpec project const projectDir = process.cwd(); const openspecDir = path.join(projectDir, OPENSPEC_DIR_NAME); if (fs.existsSync(openspecDir)) { const applyNow = await confirm({ message: 'Apply changes to this project now?', default: true, }); if (applyNow) { try { execSync('npx openspec update', { stdio: 'inherit', cwd: projectDir }); console.log('Run `openspec update` in your other projects to apply.'); } catch { console.error('`openspec update` failed. Please run it manually to apply the profile changes.'); process.exitCode = 1; } return; } } console.log('Config updated. Run `openspec update` in your projects to apply.'); } catch (error) { if (isPromptCancellationError(error)) { console.log('Config profile cancelled.'); process.exitCode = 130; return; } throw error; } }); } //# sourceMappingURL=config.js.map