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