diff --git a/docs/cli.md b/docs/cli.md index f48d1bc28..990ec61f9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -138,7 +138,12 @@ openspec/ ### `openspec update` -Update OpenSpec instruction files after upgrading the CLI. Re-generates AI tool configuration files using your current global profile, selected workflows, and delivery mode. +Update OpenSpec instruction files after upgrading the CLI. Re-generates AI tool configuration files using effective profile settings resolved by precedence: + +- CLI scope override (`--scope`) when provided +- Project config (`openspec/config.yaml` or `openspec/config.yml`) +- User config +- Built-in defaults (`profile: core`, `delivery: both`) ``` openspec update [path] [options] @@ -155,6 +160,7 @@ openspec update [path] [options] | Option | Description | |--------|-------------| | `--force` | Force update even when files are up to date | +| `--scope ` | Resolution scope override: `user` or `project` | **Example:** @@ -162,6 +168,12 @@ openspec update [path] [options] # Update instruction files after npm upgrade npm update @fission-ai/openspec openspec update + +# Force user-only profile resolution for this run +openspec update --scope user + +# Force project-prioritized resolution for this run +openspec update --scope project ``` --- @@ -933,7 +945,10 @@ spec-driven resolves from: package ### `openspec config` -View and modify global OpenSpec configuration. +View and modify OpenSpec configuration. + +Default scope is `user`. Use `--scope project` to read/write project-scoped profile settings (`profile`, `delivery`, `workflows`) in `openspec/config.yaml` (or existing `config.yml`). +If you already use user-level config only, no migration is required: existing commands keep user-level behavior unless you explicitly opt into `--scope project`. ``` openspec config [options] @@ -984,6 +999,15 @@ openspec config profile # Fast preset: switch workflows to core (keeps delivery mode) openspec config profile core + +# Set project-scoped profile override +openspec config --scope project set profile custom + +# Read project-scoped profile key +openspec config --scope project get profile + +# Run profile wizard against project scope +openspec config --scope project profile ``` `openspec config profile` starts with a current-state summary, then lets you choose: @@ -993,9 +1017,9 @@ openspec config profile core - Keep current settings (exit) If you keep current settings, no changes are written and no update prompt is shown. -If there are no config changes but the current project or workspace files are out of sync with your global profile/delivery, OpenSpec will show a warning and suggest `openspec update` for repo-local projects or `openspec workspace update` for workspace-local skills. +If there are no config changes but the current project or workspace files are out of sync with the active scope settings, OpenSpec will show a warning and suggest `openspec update` for repo-local projects or `openspec workspace update` for workspace-local skills. Pressing `Ctrl+C` also cancels the flow cleanly (no stack trace) and exits with code `130`. -In the workflow checklist, `[x]` means the workflow is selected in global config. To apply those selections to project files, run `openspec update` (or choose `Apply changes to this project now?` when prompted inside a project). From inside a workspace, use `openspec workspace update` to refresh workspace-local skills; this remains skills-only and does not generate workspace slash commands. +In the workflow checklist, `[x]` means the workflow is selected in the effective scope state for this command. To apply those selections to project files, run `openspec update` (or choose `Apply changes to this project now?` when prompted inside a project). From inside a workspace, use `openspec workspace update` to refresh workspace-local skills; this remains skills-only and does not generate workspace slash commands. **Interactive examples:** diff --git a/docs/customization.md b/docs/customization.md index 3c20a1d65..db5497cec 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -17,6 +17,7 @@ The `openspec/config.yaml` file is the easiest way to customize OpenSpec for you - **Set a default schema** - Skip `--schema` on every command - **Inject project context** - AI sees your tech stack, conventions, etc. - **Add per-artifact rules** - Custom rules for specific artifacts +- **Override profile settings per project** - Set `profile`, `delivery`, and `workflows` locally ### Quick Setup @@ -30,6 +31,15 @@ This walks you through creating a config interactively. Or create one manually: # openspec/config.yaml schema: spec-driven +# Optional: project-scoped profile settings +profile: custom +delivery: both +workflows: + - propose + - explore + - apply + - archive + context: | Tech stack: TypeScript, React, Node.js, PostgreSQL API style: RESTful, documented in docs/api.md @@ -80,6 +90,17 @@ Tech stack: TypeScript, React, Node.js, PostgreSQL - **Context** appears in ALL artifacts - **Rules** ONLY appear for the matching artifact +### Profile Resolution Order + +For profile-driven behavior (for example `openspec update`), OpenSpec resolves settings in this order: + +1. CLI scope override (if `--scope` is provided) +2. Project config (`openspec/config.yaml` or existing `openspec/config.yml`) +3. User-level config (`openspec config ...`) +4. Defaults (`profile: core`, `delivery: both`, profile-derived workflows) + +This is key-by-key fallback, so partial project settings are valid. Example: if project config only sets `profile`, `delivery` can still come from user-level config. + ### Schema Resolution Order When OpenSpec needs a schema, it checks in this order: diff --git a/src/cli/index.ts b/src/cli/index.ts index baa3e48fa..0f7fd7f04 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -162,8 +162,13 @@ program .command('update [path]') .description('Update OpenSpec instruction files') .option('--force', 'Force update even when tools are up to date') - .action(async (targetPath = '.', options?: { force?: boolean }) => { + .option('--scope ', 'Profile resolution scope override (user or project)') + .action(async (targetPath = '.', options?: { force?: boolean; scope?: string }) => { try { + if (options?.scope && options.scope !== 'user' && options.scope !== 'project') { + throw new Error(`Invalid scope "${options.scope}". Use "user" or "project".`); + } + const resolvedPath = path.resolve(targetPath); const workspaceRoot = await findWorkspaceRoot(resolvedPath); if (workspaceRoot) { @@ -171,7 +176,10 @@ program return; } - const updateCommand = new UpdateCommand({ force: options?.force }); + const updateCommand = new UpdateCommand({ + force: options?.force, + scope: options?.scope as 'user' | 'project' | undefined, + }); await updateCommand.execute(resolvedPath); } catch (error) { console.log(); // Empty line for spacing diff --git a/src/commands/config.ts b/src/commands/config.ts index 25ddf4858..dadfb8554 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { spawn, execSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { getGlobalConfigPath, getGlobalConfig, @@ -27,6 +28,12 @@ import { hasWorkspaceSkillProfileDrift, readOptionalWorkspaceLocalState, } from '../core/workspace/index.js'; +import { readProjectConfig } from '../core/project-config.js'; +import { + resolveEffectiveProfileSettings, + type ConfigScope, + type ProfileValueSource, +} from '../core/profile-resolution.js'; type ProfileAction = 'both' | 'delivery' | 'workflows' | 'keep'; @@ -51,6 +58,12 @@ interface WorkspaceConfigProfileContext { commandCwd: string; } +interface ProjectConfigFile { + path: string; + exists: boolean; + content: Record; +} + const WORKFLOW_PROMPT_META: Record = { propose: { name: 'Propose change', @@ -98,6 +111,157 @@ const WORKFLOW_PROMPT_META: Record = { }, }; +const DEFAULT_PROJECT_SCHEMA = 'spec-driven'; +const PROJECT_PROFILE_KEYS = new Set(['profile', 'delivery', 'workflows']); + +export function getProjectConfigFilePaths(projectDir: string): { + yamlPath: string; + ymlPath: string; +} { + return { + yamlPath: path.join(projectDir, OPENSPEC_DIR_NAME, 'config.yaml'), + ymlPath: path.join(projectDir, OPENSPEC_DIR_NAME, 'config.yml'), + }; +} + +function resolveProjectConfigFilePath(projectDir: string): { path: string; exists: boolean } { + const { yamlPath, ymlPath } = getProjectConfigFilePaths(projectDir); + if (fs.existsSync(yamlPath)) { + return { path: yamlPath, exists: true }; + } + if (fs.existsSync(ymlPath)) { + return { path: ymlPath, exists: true }; + } + return { path: yamlPath, exists: false }; +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readProjectConfigFile(projectDir: string): ProjectConfigFile { + const resolved = resolveProjectConfigFilePath(projectDir); + + if (!resolved.exists) { + return { + path: resolved.path, + exists: false, + content: {}, + }; + } + + const fileContent = fs.readFileSync(resolved.path, 'utf-8'); + const parsed = parseYaml(fileContent); + + if (parsed == null) { + return { + path: resolved.path, + exists: true, + content: {}, + }; + } + + if (!isObjectRecord(parsed)) { + throw new Error(`Invalid YAML object in ${path.relative(projectDir, resolved.path)}`); + } + + return { + path: resolved.path, + exists: true, + content: { ...parsed }, + }; +} + +function writeProjectConfigFile(file: ProjectConfigFile): void { + fs.mkdirSync(path.dirname(file.path), { recursive: true }); + const serialized = stringifyYaml(file.content); + const contentWithNewline = serialized.endsWith('\n') ? serialized : `${serialized}\n`; + fs.writeFileSync(file.path, contentWithNewline, 'utf-8'); +} + +function ensureProjectConfigForWrite(projectDir: string): ProjectConfigFile { + const file = readProjectConfigFile(projectDir); + if (!file.exists && file.content.schema === undefined) { + file.content.schema = DEFAULT_PROJECT_SCHEMA; + } + return file; +} + +function parseScope(rawScope: unknown): ConfigScope | null { + if (rawScope === undefined || rawScope === null || rawScope === 'user') { + return 'user'; + } + if (rawScope === 'project') { + return 'project'; + } + console.error(`Error: Invalid scope "${String(rawScope)}". Use "user" or "project".`); + process.exitCode = 1; + return null; +} + +function isSupportedProjectProfileKey(key: string): boolean { + return !key.includes('.') && PROJECT_PROFILE_KEYS.has(key); +} + +function validateProjectProfileValue(key: string, value: unknown): { valid: boolean; error?: string } { + if (key === 'profile') { + if (value === 'core' || value === 'custom') { + return { valid: true }; + } + return { valid: false, error: 'profile must be "core" or "custom"' }; + } + + if (key === 'delivery') { + if (value === 'both' || value === 'skills' || value === 'commands') { + return { valid: true }; + } + return { valid: false, error: 'delivery must be "both", "skills", or "commands"' }; + } + + if (key === 'workflows') { + if (Array.isArray(value) && value.every((item) => typeof item === 'string')) { + return { valid: true }; + } + return { valid: false, error: 'workflows must be an array of strings' }; + } + + return { valid: false, error: `Unsupported project config key "${key}"` }; +} + +function coerceProjectScopedValue(key: string, value: string, forceString: boolean): unknown { + if (key === 'workflows' && !forceString) { + const trimmed = value.trim(); + if (trimmed === '') { + return []; + } + + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed; + } + } catch { + // Fall back to comma-delimited parsing below. + } + } + + return trimmed + .split(',') + .map((workflow) => workflow.trim()) + .filter((workflow) => workflow.length > 0); + } + + return coerceValue(value, forceString); +} + +function formatSource(source: ProfileValueSource): string { + if (source === 'project') return 'project'; + if (source === 'user') return 'user'; + if (source === 'cli') return 'CLI override'; + return 'default'; +} + function isPromptCancellationError(error: unknown): boolean { return ( error instanceof Error && @@ -106,7 +270,7 @@ function isPromptCancellationError(error: unknown): boolean { } /** - * Resolve the effective current profile state from global config defaults. + * Resolve the effective current profile state from user config defaults. */ export function resolveCurrentProfileState(config: GlobalConfig): ProfileState { const profile = config.profile || 'core'; @@ -213,7 +377,8 @@ async function resolveWorkspaceConfigProfileContext( function maybeWarnProjectConfigDrift( projectDir: string, state: ProfileState, - colorize: (message: string) => string + colorize: (message: string) => string, + scope: ConfigScope = 'user' ): void { const openspecDir = path.join(projectDir, OPENSPEC_DIR_NAME); if (!fs.existsSync(openspecDir)) { @@ -222,14 +387,19 @@ function maybeWarnProjectConfigDrift( 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.')); + const message = + scope === 'project' + ? 'Warning: Project config is not applied to this project. Run `openspec update` to sync.' + : 'Warning: User config is not applied to this project. Run `openspec update` to sync.'; + console.log(colorize(message)); } async function maybeWarnConfigDrift( state: ProfileState, - colorize: (message: string) => string + colorize: (message: string) => string, + scope: ConfigScope = 'user' ): Promise { - const workspaceContext = await resolveWorkspaceConfigProfileContext(); + const workspaceContext = scope === 'user' ? await resolveWorkspaceConfigProfileContext() : null; if (workspaceContext) { let localState = null; try { @@ -248,7 +418,7 @@ async function maybeWarnConfigDrift( return; } - maybeWarnProjectConfigDrift(process.cwd(), state, colorize); + maybeWarnProjectConfigDrift(process.cwd(), state, colorize, scope); } function printConfigProfileApplyGuidance(workspaceContext: WorkspaceConfigProfileContext | null): void { @@ -268,22 +438,25 @@ function printConfigProfileApplyGuidance(workspaceContext: WorkspaceConfigProfil export function registerConfigCommand(program: Command): void { const configCmd = program .command('config') - .description('View and modify global OpenSpec configuration') - .option('--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); - } - }); + .description('View and modify OpenSpec configuration') + .option('--scope ', 'Config scope ("user" or "project")', 'user'); // config path configCmd .command('path') .description('Show config file location') .action(() => { - console.log(getGlobalConfigPath()); + const scope = parseScope(configCmd.opts<{ scope?: string }>().scope); + if (!scope) { + return; + } + + if (scope === 'user') { + console.log(getGlobalConfigPath()); + return; + } + + console.log(resolveProjectConfigFilePath(process.cwd()).path); }); // config list @@ -292,11 +465,19 @@ export function registerConfigCommand(program: Command): void { .description('Show all current settings') .option('--json', 'Output as JSON') .action((options: { json?: boolean }) => { - const config = getGlobalConfig(); + const scope = parseScope(configCmd.opts<{ scope?: string }>().scope); + if (!scope) { + return; + } + + if (scope === 'user') { + const config = getGlobalConfig(); + + if (options.json) { + console.log(JSON.stringify(config, null, 2)); + return; + } - 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: Record = {}; @@ -323,6 +504,47 @@ export function registerConfigCommand(program: Command): void { } else { console.log(` workflows: (none)`); } + + return; + } + + const projectDir = process.cwd(); + + let projectFile: ProjectConfigFile; + try { + projectFile = readProjectConfigFile(projectDir); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + return; + } + + const projectConfig = readProjectConfig(projectDir) ?? {}; + const effective = resolveEffectiveProfileSettings({ + projectConfig, + globalConfig: getGlobalConfig(), + }); + + if (options.json) { + console.log(JSON.stringify(projectFile.content, null, 2)); + return; + } + + console.log( + Object.keys(projectFile.content).length > 0 + ? formatValueYaml(projectFile.content) + : '{}' + ); + + console.log(`\nProfile settings (effective):`); + console.log(` profile: ${effective.profile} (${formatSource(effective.sources.profile)})`); + console.log(` delivery: ${effective.delivery} (${formatSource(effective.sources.delivery)})`); + if (effective.profile === 'core') { + console.log(` workflows: ${CORE_WORKFLOWS.join(', ')} (from core profile)`); + } else if (effective.workflows.length > 0) { + console.log(` workflows: ${effective.workflows.join(', ')} (${formatSource(effective.sources.workflows)})`); + } else { + console.log(` workflows: (none)`); } }); @@ -331,8 +553,33 @@ export function registerConfigCommand(program: Command): void { .command('get ') .description('Get a specific value (raw, scriptable)') .action((key: string) => { - const config = getGlobalConfig(); - const value = getNestedValue(config as Record, key); + const scope = parseScope(configCmd.opts<{ scope?: string }>().scope); + if (!scope) { + return; + } + + if (scope === 'project' && !isSupportedProjectProfileKey(key)) { + console.error( + `Error: Project scope only supports profile-related keys: ${Array.from(PROJECT_PROFILE_KEYS).join(', ')}` + ); + process.exitCode = 1; + return; + } + + let config: Record; + if (scope === 'user') { + config = getGlobalConfig() as Record; + } else { + try { + config = readProjectConfigFile(process.cwd()).content; + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + return; + } + } + + const value = getNestedValue(config, key); if (value === undefined) { process.exitCode = 1; @@ -353,38 +600,84 @@ export function registerConfigCommand(program: Command): void { .option('--string', 'Force value to be stored as string') .option('--allow-unknown', 'Allow setting unknown keys') .action((key: string, value: string, options: { string?: boolean; allowUnknown?: boolean }) => { + const scope = parseScope(configCmd.opts<{ scope?: string }>().scope); + if (!scope) { + return; + } + 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.'); + + if (scope === 'user') { + 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() as Record; + 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 as GlobalConfig); + + const displayValue = + typeof coercedValue === 'string' ? `"${coercedValue}"` : String(coercedValue); + console.log(`Set ${key} = ${displayValue}`); + return; + } + + if (!allowUnknown && !isSupportedProjectProfileKey(key)) { + console.error( + `Error: Project scope only supports profile-related keys: ${Array.from(PROJECT_PROFILE_KEYS).join(', ')}` + ); console.error('Pass --allow-unknown to bypass this check.'); process.exitCode = 1; return; } - const config = getGlobalConfig() as Record; - const coercedValue = coerceValue(value, options.string || false); + const coercedValue = coerceProjectScopedValue(key, value, options.string || false); - // Create a copy to validate before saving - const newConfig = JSON.parse(JSON.stringify(config)); - setNestedValue(newConfig, key, coercedValue); + if (isSupportedProjectProfileKey(key)) { + const validation = validateProjectProfileValue(key, coercedValue); + if (!validation.valid) { + console.error(`Error: Invalid configuration - ${validation.error}`); + process.exitCode = 1; + return; + } + } - // Validate the new config - const validation = validateConfig(newConfig); - if (!validation.success) { - console.error(`Error: Invalid configuration - ${validation.error}`); + let projectFile: ProjectConfigFile; + try { + projectFile = ensureProjectConfigForWrite(process.cwd()); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; return; } - // Apply changes and save - setNestedValue(config, key, coercedValue); - saveGlobalConfig(config as GlobalConfig); + const nextConfig = JSON.parse(JSON.stringify(projectFile.content)) as Record; + setNestedValue(nextConfig, key, coercedValue); + writeProjectConfigFile({ ...projectFile, content: nextConfig }); const displayValue = - typeof coercedValue === 'string' ? `"${coercedValue}"` : String(coercedValue); + typeof coercedValue === 'string' ? `"${coercedValue}"` : JSON.stringify(coercedValue); console.log(`Set ${key} = ${displayValue}`); }); @@ -393,12 +686,47 @@ export function registerConfigCommand(program: Command): void { .command('unset ') .description('Remove a key (revert to default)') .action((key: string) => { - const config = getGlobalConfig() as Record; - const existed = deleteNestedValue(config, key); + const scope = parseScope(configCmd.opts<{ scope?: string }>().scope); + if (!scope) { + return; + } + + if (scope === 'user') { + const config = getGlobalConfig() as Record; + const existed = deleteNestedValue(config, key); + + if (existed) { + saveGlobalConfig(config as GlobalConfig); + console.log(`Unset ${key} (reverted to default)`); + } else { + console.log(`Key "${key}" was not set`); + } + return; + } + + if (!isSupportedProjectProfileKey(key)) { + console.error( + `Error: Project scope only supports profile-related keys: ${Array.from(PROJECT_PROFILE_KEYS).join(', ')}` + ); + process.exitCode = 1; + return; + } + + let projectFile: ProjectConfigFile; + try { + projectFile = readProjectConfigFile(process.cwd()); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + return; + } + + const nextConfig = JSON.parse(JSON.stringify(projectFile.content)) as Record; + const existed = deleteNestedValue(nextConfig, key); if (existed) { - saveGlobalConfig(config as GlobalConfig); - console.log(`Unset ${key} (reverted to default)`); + writeProjectConfigFile({ ...projectFile, content: nextConfig }); + console.log(`Unset ${key} (reverted to fallback)`); } else { console.log(`Key "${key}" was not set`); } @@ -411,6 +739,17 @@ export function registerConfigCommand(program: Command): void { .option('--all', 'Reset all configuration (required)') .option('-y, --yes', 'Skip confirmation prompts') .action(async (options: { all?: boolean; yes?: boolean }) => { + const scope = parseScope(configCmd.opts<{ scope?: string }>().scope); + if (!scope) { + return; + } + + if (scope === 'project') { + console.error('Error: config reset is only supported for user scope'); + process.exitCode = 1; + return; + } + if (!options.all) { console.error('Error: --all flag is required for reset'); console.error('Usage: openspec config reset --all [-y]'); @@ -450,6 +789,17 @@ export function registerConfigCommand(program: Command): void { .command('edit') .description('Open config in $EDITOR') .action(async () => { + const scope = parseScope(configCmd.opts<{ scope?: string }>().scope); + if (!scope) { + return; + } + + if (scope === 'project') { + console.error('Error: config edit is only supported for user scope'); + process.exitCode = 1; + return; + } + const editor = process.env.EDITOR || process.env.VISUAL; if (!editor) { @@ -513,14 +863,35 @@ export function registerConfigCommand(program: Command): void { .command('profile [preset]') .description('Configure workflow profile (interactive picker or preset shortcut)') .action(async (preset?: string) => { + const scope = parseScope(configCmd.opts<{ scope?: string }>().scope); + if (!scope) { + return; + } + // Preset shortcut: `openspec config profile core` if (preset === 'core') { - const config = getGlobalConfig(); - config.profile = 'core'; - config.workflows = [...CORE_WORKFLOWS]; - // Preserve delivery setting - saveGlobalConfig(config); - const workspaceContext = await resolveWorkspaceConfigProfileContext(); + if (scope === 'user') { + const config = getGlobalConfig(); + config.profile = 'core'; + config.workflows = [...CORE_WORKFLOWS]; + // Preserve delivery setting + saveGlobalConfig(config); + } else { + let projectFile: ProjectConfigFile; + try { + projectFile = ensureProjectConfigForWrite(process.cwd()); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + return; + } + + const nextConfig = JSON.parse(JSON.stringify(projectFile.content)) as Record; + nextConfig.profile = 'core'; + nextConfig.workflows = [...CORE_WORKFLOWS]; + writeProjectConfigFile({ ...projectFile, content: nextConfig }); + } + const workspaceContext = scope === 'user' ? await resolveWorkspaceConfigProfileContext() : null; printConfigProfileApplyGuidance(workspaceContext); return; } @@ -543,8 +914,17 @@ export function registerConfigCommand(program: Command): void { const chalk = (await import('chalk')).default; try { - const config = getGlobalConfig(); - const currentState = resolveCurrentProfileState(config); + const globalConfig = getGlobalConfig(); + const projectConfig = scope === 'project' ? (readProjectConfig(process.cwd()) ?? {}) : null; + const effective = resolveEffectiveProfileSettings({ + projectConfig, + globalConfig, + }); + const currentState: ProfileState = { + profile: effective.profile, + delivery: effective.delivery, + workflows: [...effective.workflows], + }; console.log(chalk.bold('\nCurrent profile settings')); console.log(` Delivery: ${currentState.delivery}`); @@ -581,7 +961,7 @@ export function registerConfigCommand(program: Command): void { if (action === 'keep') { console.log('No config changes.'); - await maybeWarnConfigDrift(currentState, chalk.yellow); + await maybeWarnConfigDrift(currentState, chalk.yellow, scope); return; } @@ -656,7 +1036,7 @@ export function registerConfigCommand(program: Command): void { const diff = diffProfileState(currentState, nextState); if (!diff.hasChanges) { console.log('No config changes.'); - await maybeWarnConfigDrift(nextState, chalk.yellow); + await maybeWarnConfigDrift(nextState, chalk.yellow, scope); return; } @@ -666,12 +1046,34 @@ export function registerConfigCommand(program: Command): void { } console.log(); - config.profile = nextState.profile; - config.delivery = nextState.delivery; - config.workflows = nextState.workflows; - saveGlobalConfig(config); + if (scope === 'user') { + const config = getGlobalConfig(); + config.profile = nextState.profile; + config.delivery = nextState.delivery; + config.workflows = nextState.workflows; + saveGlobalConfig(config); + } else { + let projectFile: ProjectConfigFile; + try { + projectFile = ensureProjectConfigForWrite(process.cwd()); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + return; + } + + const nextConfig = JSON.parse(JSON.stringify(projectFile.content)) as Record; + if (action === 'both' || action === 'workflows') { + nextConfig.profile = nextState.profile; + nextConfig.workflows = nextState.workflows; + } + if (action === 'both' || action === 'delivery') { + nextConfig.delivery = nextState.delivery; + } + writeProjectConfigFile({ ...projectFile, content: nextConfig }); + } - const workspaceContext = await resolveWorkspaceConfigProfileContext(); + const workspaceContext = scope === 'user' ? await resolveWorkspaceConfigProfileContext() : null; if (workspaceContext) { const applyNow = await confirm({ message: 'Apply changes to this workspace now?', @@ -707,7 +1109,9 @@ export function registerConfigCommand(program: Command): void { if (applyNow) { try { - execSync('npx openspec update', { stdio: 'inherit', cwd: projectDir }); + const updateCommand = + scope === 'project' ? 'npx openspec update --scope project' : 'npx openspec update --scope user'; + execSync(updateCommand, { 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.'); diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 9b629b5f7..789605630 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -51,7 +51,18 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Update OpenSpec instruction files', acceptsPositional: true, positionalType: 'path', - flags: [], + flags: [ + { + name: 'force', + description: 'Force update even when tools are up to date', + }, + { + name: 'scope', + description: 'Profile resolution scope override (user or project)', + takesValue: true, + values: ['user', 'project'], + }, + ], }, { name: 'list', @@ -473,13 +484,13 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, { name: 'config', - description: 'View and modify global OpenSpec configuration', + description: 'View and modify OpenSpec configuration', flags: [ { name: 'scope', - description: 'Config scope (only "global" supported currently)', + description: 'Config scope ("user" or "project")', takesValue: true, - values: ['global'], + values: ['user', 'project'], }, ], subcommands: [ diff --git a/src/core/profile-resolution.ts b/src/core/profile-resolution.ts new file mode 100644 index 000000000..d49794c7f --- /dev/null +++ b/src/core/profile-resolution.ts @@ -0,0 +1,105 @@ +import { + getGlobalConfig, + type Delivery, + type GlobalConfig, + type Profile, +} from './global-config.js'; +import type { ProjectProfileConfig } from './project-config.js'; +import { getProfileWorkflows } from './profiles.js'; + +export type ConfigScope = 'user' | 'project'; +export type ProfileValueSource = 'cli' | 'project' | 'user' | 'default'; + +interface ResolvedValue { + value: T; + source: ProfileValueSource; +} + +function resolveValue(options: { + cliValue?: T; + projectValue?: T; + userValue?: T; + defaultValue: T; + scopeOverride?: ConfigScope; +}): ResolvedValue { + if (options.cliValue !== undefined) { + return { value: options.cliValue, source: 'cli' }; + } + + if (options.scopeOverride !== 'user' && options.projectValue !== undefined) { + return { value: options.projectValue, source: 'project' }; + } + + if (options.userValue !== undefined) { + return { value: options.userValue, source: 'user' }; + } + + return { value: options.defaultValue, source: 'default' }; +} + +export interface ResolveEffectiveProfileSettingsOptions { + scopeOverride?: ConfigScope; + cliOverrides?: ProjectProfileConfig; + projectConfig?: ProjectProfileConfig | null; + globalConfig?: GlobalConfig; +} + +export interface EffectiveProfileSettings { + profile: Profile; + delivery: Delivery; + workflows: string[]; + sources: { + profile: ProfileValueSource; + delivery: ProfileValueSource; + workflows: ProfileValueSource; + }; +} + +/** + * Resolve effective profile settings with deterministic precedence: + * CLI override > project config > user config > defaults. + * + * Resolution is key-by-key to support partial project config fallback. + */ +export function resolveEffectiveProfileSettings( + options: ResolveEffectiveProfileSettingsOptions = {} +): EffectiveProfileSettings { + const globalConfig = options.globalConfig ?? getGlobalConfig(); + const projectConfig = options.projectConfig ?? null; + const cli = options.cliOverrides ?? {}; + + const profile = resolveValue({ + cliValue: cli.profile, + projectValue: projectConfig?.profile, + userValue: globalConfig.profile, + defaultValue: 'core', + scopeOverride: options.scopeOverride, + }); + + const delivery = resolveValue({ + cliValue: cli.delivery, + projectValue: projectConfig?.delivery, + userValue: globalConfig.delivery, + defaultValue: 'both', + scopeOverride: options.scopeOverride, + }); + + const configuredWorkflows = resolveValue({ + cliValue: cli.workflows, + projectValue: projectConfig?.workflows, + userValue: globalConfig.workflows, + defaultValue: undefined, + scopeOverride: options.scopeOverride, + }); + + return { + profile: profile.value, + delivery: delivery.value, + workflows: [...getProfileWorkflows(profile.value, configuredWorkflows.value)], + sources: { + profile: profile.source, + delivery: delivery.source, + workflows: profile.value === 'core' ? profile.source : configuredWorkflows.source, + }, + }; +} diff --git a/src/core/project-config.ts b/src/core/project-config.ts index 6c1ea04a5..1ca837840 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from 'fs'; import path from 'path'; import { parse as parseYaml } from 'yaml'; import { z } from 'zod'; +import type { Delivery, Profile } from './global-config.js'; /** * Zod schema for project configuration. @@ -38,9 +39,30 @@ export const ProjectConfigSchema = z.object({ ) .optional() .describe('Per-artifact rules, keyed by artifact ID'), + + // Optional: profile-related settings scoped to this project + profile: z + .enum(['core', 'custom']) + .optional() + .describe('Workflow profile override for this project'), + + delivery: z + .enum(['both', 'skills', 'commands']) + .optional() + .describe('Workflow delivery override for this project'), + + workflows: z + .array(z.string()) + .optional() + .describe('Workflow selection override for this project'), }); export type ProjectConfig = z.infer; +export type ProjectProfileConfig = { + profile?: Profile; + delivery?: Delivery; + workflows?: string[]; +}; const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit @@ -63,7 +85,7 @@ const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit * @param projectRoot - The root directory of the project (where `openspec/` lives) * @returns Parsed config or null if file doesn't exist */ -export function readProjectConfig(projectRoot: string): ProjectConfig | null { +export function readProjectConfig(projectRoot: string): Partial | null { // Try both .yaml and .yml, prefer .yaml let configPath = path.join(projectRoot, 'openspec', 'config.yaml'); if (!existsSync(configPath)) { @@ -152,8 +174,35 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null { } } + if (raw.profile !== undefined) { + const profileResult = z.enum(['core', 'custom']).safeParse(raw.profile); + if (profileResult.success) { + config.profile = profileResult.data; + } else { + console.warn(`Invalid 'profile' field in config (must be one of: core, custom)`); + } + } + + if (raw.delivery !== undefined) { + const deliveryResult = z.enum(['both', 'skills', 'commands']).safeParse(raw.delivery); + if (deliveryResult.success) { + config.delivery = deliveryResult.data; + } else { + console.warn(`Invalid 'delivery' field in config (must be one of: both, skills, commands)`); + } + } + + if (raw.workflows !== undefined) { + const workflowsResult = z.array(z.string()).safeParse(raw.workflows); + if (workflowsResult.success) { + config.workflows = workflowsResult.data; + } else { + console.warn(`Invalid 'workflows' field in config (must be an array of strings)`); + } + } + // Return partial config even if some fields failed - return Object.keys(config).length > 0 ? (config as ProjectConfig) : null; + return Object.keys(config).length > 0 ? config : null; } catch (error) { console.warn(`Failed to parse openspec/config.yaml:`, error); return null; diff --git a/src/core/update.ts b/src/core/update.ts index e1582cd5b..20f20d668 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -43,6 +43,11 @@ import { getConfiguredToolsForProfileSync, getToolsNeedingProfileSync, } from './profile-sync-drift.js'; +import { readProjectConfig } from './project-config.js'; +import { + resolveEffectiveProfileSettings, + type ConfigScope, +} from './profile-resolution.js'; import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, @@ -58,6 +63,8 @@ const OLD_CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'archive'] as const; export interface UpdateCommandOptions { /** Force update even when tools are up to date */ force?: boolean; + /** Optional config-scope override for effective profile resolution */ + scope?: ConfigScope; } /** @@ -75,9 +82,11 @@ export function scanInstalledWorkflows(projectPath: string, toolIds: string[]): export class UpdateCommand { private readonly force: boolean; + private readonly scope?: ConfigScope; constructor(options: UpdateCommandOptions = {}) { this.force = options.force ?? false; + this.scope = options.scope; } async execute(projectPath: string): Promise { @@ -94,12 +103,16 @@ export class UpdateCommand { const detectedTools = getAvailableTools(resolvedProjectPath); migrateIfNeededShared(resolvedProjectPath, detectedTools); - // 3. Read global config for profile/delivery + // 3. Resolve effective profile/delivery/workflows by scope-aware precedence const globalConfig = getGlobalConfig(); - const profile = globalConfig.profile ?? 'core'; - const delivery: Delivery = globalConfig.delivery ?? 'both'; - const profileWorkflows = getProfileWorkflows(profile, globalConfig.workflows); - const desiredWorkflows = profileWorkflows.filter((workflow): workflow is (typeof ALL_WORKFLOWS)[number] => + const effective = resolveEffectiveProfileSettings({ + scopeOverride: this.scope, + globalConfig, + projectConfig: readProjectConfig(resolvedProjectPath), + }); + const profile = effective.profile; + const delivery: Delivery = effective.delivery; + const desiredWorkflows = effective.workflows.filter((workflow): workflow is (typeof ALL_WORKFLOWS)[number] => (ALL_WORKFLOWS as readonly string[]).includes(workflow) ); const shouldGenerateSkills = delivery !== 'commands'; diff --git a/test/commands/config-profile.test.ts b/test/commands/config-profile.test.ts index 3cd40b3ac..531557e88 100644 --- a/test/commands/config-profile.test.ts +++ b/test/commands/config-profile.test.ts @@ -4,6 +4,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { execSync } from 'node:child_process'; +import { parse as parseYaml } from 'yaml'; vi.mock('node:child_process', async () => { const actual = await vi.importActual('node:child_process'); @@ -351,7 +352,7 @@ describe('config profile interactive flow', () => { await runConfigCommand(['profile']); expect(consoleLogSpy).toHaveBeenCalledWith('No config changes.'); - expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Warning: Global config is not applied to this project.')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Warning: User config is not applied to this project.')); }); it('keep action should not warn when project files are already synced', async () => { @@ -365,7 +366,7 @@ describe('config profile interactive flow', () => { await runConfigCommand(['profile']); const allLogs = consoleLogSpy.mock.calls.map((args) => args.map(String).join(' ')); - expect(allLogs.some((line) => line.includes('Warning: Global config is not applied to this project.'))).toBe(false); + expect(allLogs.some((line) => line.includes('Warning: User config is not applied to this project.'))).toBe(false); }); it('effective no-op after prompts should warn when project files drift', async () => { @@ -381,7 +382,7 @@ describe('config profile interactive flow', () => { expect(consoleLogSpy).toHaveBeenCalledWith('No config changes.'); expect(confirm).not.toHaveBeenCalled(); - expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Warning: Global config is not applied to this project.')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Warning: User config is not applied to this project.')); }); it('keep action should warn when project has extra workflows beyond global config', async () => { @@ -396,7 +397,7 @@ describe('config profile interactive flow', () => { await runConfigCommand(['profile']); expect(consoleLogSpy).toHaveBeenCalledWith('No config changes.'); - expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Warning: Global config is not applied to this project.')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Warning: User config is not applied to this project.')); }); it('changed config should save and ask apply when inside project', async () => { @@ -515,6 +516,97 @@ describe('config profile interactive flow', () => { expect(consoleLogSpy).toHaveBeenCalledWith('Config updated. Run `openspec workspace update` to apply it to workspace-local skills.'); }); + it('project scope core preset should write project config and preserve project delivery', async () => { + const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); + const { select, checkbox, confirm } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'custom', delivery: 'skills', workflows: ['verify'] }); + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: spec-driven +delivery: commands +` + ); + + await runConfigCommand(['--scope', 'project', 'profile', 'core']); + + const parsed = parseYaml( + fs.readFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8') + ) as Record; + + expect(parsed.profile).toBe('core'); + expect(parsed.delivery).toBe('commands'); + expect(parsed.workflows).toEqual(['propose', 'explore', 'apply', 'sync', 'archive']); + expect(getGlobalConfig()).toMatchObject({ + profile: 'custom', + delivery: 'skills', + workflows: ['verify'], + }); + expect(select).not.toHaveBeenCalled(); + expect(checkbox).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('project scope interactive delivery-only change should persist delivery without forcing profile keys', async () => { + const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); + const { select, checkbox, confirm } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + select.mockResolvedValueOnce('delivery'); + select.mockResolvedValueOnce('commands'); + confirm.mockResolvedValueOnce(false); + + await runConfigCommand(['--scope', 'project', 'profile']); + + const parsed = parseYaml( + fs.readFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8') + ) as Record; + + expect(parsed).toMatchObject({ + schema: 'spec-driven', + delivery: 'commands', + }); + expect(parsed.profile).toBeUndefined(); + expect(parsed.workflows).toBeUndefined(); + expect(getGlobalConfig()).toMatchObject({ + profile: 'core', + delivery: 'both', + }); + expect(checkbox).not.toHaveBeenCalled(); + }); + + it('project scope interactive workflows-only change should persist profile and workflows', async () => { + const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); + const { select, checkbox, confirm } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'sync', 'archive'] }); + select.mockResolvedValueOnce('workflows'); + checkbox.mockResolvedValueOnce(['explore', 'verify']); + confirm.mockResolvedValueOnce(false); + + await runConfigCommand(['--scope', 'project', 'profile']); + + const parsed = parseYaml( + fs.readFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8') + ) as Record; + + expect(parsed).toMatchObject({ + schema: 'spec-driven', + profile: 'custom', + workflows: ['explore', 'verify'], + }); + expect(parsed.delivery).toBeUndefined(); + expect(getGlobalConfig()).toMatchObject({ + profile: 'core', + delivery: 'both', + }); + expect(confirm).toHaveBeenCalledWith({ + message: 'Apply changes to this project now?', + default: true, + }); + }); + it('Ctrl+C should cancel without stack trace and set interrupted exit code', async () => { const { select, checkbox, confirm } = await getPromptMocks(); const cancellationError = new Error('User force closed the prompt with SIGINT'); diff --git a/test/commands/config.test.ts b/test/commands/config.test.ts index 6e65068b8..0602026e4 100644 --- a/test/commands/config.test.ts +++ b/test/commands/config.test.ts @@ -2,6 +2,15 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; +import { Command } from 'commander'; +import { parse as parseYaml } from 'yaml'; + +async function runConfigCommand(args: string[]): Promise { + const { registerConfigCommand } = await import('../../src/commands/config.js'); + const program = new Command(); + registerConfigCommand(program); + await program.parseAsync(['node', 'openspec', 'config', ...args]); +} describe('config command integration', () => { // These tests use real file system operations with XDG_CONFIG_HOME override @@ -91,13 +100,138 @@ describe('config command integration', () => { }); }); +describe('config command project scope', () => { + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + let originalCwd: string; + let originalExitCode: number | undefined; + let consoleErrorSpy: ReturnType; + let consoleLogSpy: ReturnType; + + beforeEach(() => { + tempDir = path.join(os.tmpdir(), `openspec-config-project-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); + originalEnv = { ...process.env }; + originalCwd = process.cwd(); + originalExitCode = process.exitCode; + + process.env.XDG_CONFIG_HOME = tempDir; + process.chdir(tempDir); + process.exitCode = undefined; + + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = originalEnv; + process.chdir(originalCwd); + process.exitCode = originalExitCode; + fs.rmSync(tempDir, { recursive: true, force: true }); + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + vi.resetModules(); + }); + + it('set/get with --scope project writes project config without mutating global config', async () => { + const { getGlobalConfig } = await import('../../src/core/global-config.js'); + + await runConfigCommand(['--scope', 'project', 'set', 'profile', 'custom']); + + const projectConfigPath = path.join(tempDir, 'openspec', 'config.yaml'); + expect(fs.existsSync(projectConfigPath)).toBe(true); + + const parsed = parseYaml(fs.readFileSync(projectConfigPath, 'utf-8')) as Record; + expect(parsed).toMatchObject({ + schema: 'spec-driven', + profile: 'custom', + }); + + expect(getGlobalConfig().profile).toBe('core'); + + consoleLogSpy.mockClear(); + await runConfigCommand(['--scope', 'project', 'get', 'profile']); + expect(consoleLogSpy).toHaveBeenCalledWith('custom'); + }); + + it('project-scoped writes preserve existing schema/context/rules fields', async () => { + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: spec-driven +context: Keep me +rules: + proposal: + - Keep this +` + ); + + await runConfigCommand(['--scope', 'project', 'set', 'delivery', 'commands']); + + const parsed = parseYaml( + fs.readFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8') + ) as Record; + + expect(parsed).toMatchObject({ + schema: 'spec-driven', + context: 'Keep me', + delivery: 'commands', + }); + expect(parsed.rules).toEqual({ proposal: ['Keep this'] }); + }); + + it('unset with --scope project removes key without mutating global config', async () => { + const { getGlobalConfig, saveGlobalConfig } = await import('../../src/core/global-config.js'); + saveGlobalConfig({ featureFlags: {}, profile: 'custom', delivery: 'both', workflows: ['explore'] }); + + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'openspec', 'config.yaml'), + `schema: spec-driven +profile: custom +` + ); + + await runConfigCommand(['--scope', 'project', 'unset', 'profile']); + + const parsed = parseYaml( + fs.readFileSync(path.join(tempDir, 'openspec', 'config.yaml'), 'utf-8') + ) as Record; + expect(parsed).toEqual({ schema: 'spec-driven' }); + expect(getGlobalConfig().profile).toBe('custom'); + }); + + it('rejects unsupported project-scoped keys', async () => { + await runConfigCommand(['--scope', 'project', 'set', 'schema', 'spec-driven']); + + expect(process.exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Project scope only supports profile-related keys') + ); + }); + + it('builds project config paths with cross-platform join semantics (including win32-style roots)', async () => { + const { getProjectConfigFilePaths } = await import('../../src/commands/config.js'); + + const windowsLikeRoot = 'C:\\repo\\sample-project'; + const paths = getProjectConfigFilePaths(windowsLikeRoot); + + expect(path.win32.normalize(paths.yamlPath)).toBe( + path.win32.join(windowsLikeRoot, 'openspec', 'config.yaml') + ); + expect(path.win32.normalize(paths.ymlPath)).toBe( + path.win32.join(windowsLikeRoot, 'openspec', 'config.yml') + ); + }); +}); + describe('config command shell completion registry', () => { it('should have config command in registry', async () => { const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config'); expect(configCmd).toBeDefined(); - expect(configCmd?.description).toBe('View and modify global OpenSpec configuration'); + expect(configCmd?.description).toBe('View and modify OpenSpec configuration'); }); it('should have all config subcommands in registry', async () => { @@ -151,9 +285,22 @@ describe('config command shell completion registry', () => { const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); const configCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'config'); - const flagNames = configCmd?.flags?.map((f) => f.name) ?? []; + const scopeFlag = configCmd?.flags?.find((f) => f.name === 'scope'); + + expect(scopeFlag).toBeDefined(); + expect(scopeFlag?.values).toEqual(['user', 'project']); + }); + + it('should include update scope override flag in registry', async () => { + const { COMMAND_REGISTRY } = await import('../../src/core/completions/command-registry.js'); + + const updateCmd = COMMAND_REGISTRY.find((cmd) => cmd.name === 'update'); + const scopeFlag = updateCmd?.flags?.find((f) => f.name === 'scope'); + const forceFlag = updateCmd?.flags?.find((f) => f.name === 'force'); - expect(flagNames).toContain('scope'); + expect(forceFlag).toBeDefined(); + expect(scopeFlag).toBeDefined(); + expect(scopeFlag?.values).toEqual(['user', 'project']); }); }); diff --git a/test/core/profile-resolution.test.ts b/test/core/profile-resolution.test.ts new file mode 100644 index 000000000..d84cfb7a7 --- /dev/null +++ b/test/core/profile-resolution.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from 'vitest'; +import { resolveEffectiveProfileSettings } from '../../src/core/profile-resolution.js'; + +describe('profile-resolution', () => { + it('uses defaults when project and global values are absent', () => { + const resolved = resolveEffectiveProfileSettings({ + globalConfig: { featureFlags: {} }, + projectConfig: null, + }); + + expect(resolved).toEqual({ + profile: 'core', + delivery: 'both', + workflows: ['propose', 'explore', 'apply', 'sync', 'archive'], + sources: { + profile: 'default', + delivery: 'default', + workflows: 'default', + }, + }); + }); + + it('applies project values over global values by default', () => { + const resolved = resolveEffectiveProfileSettings({ + globalConfig: { + featureFlags: {}, + profile: 'core', + delivery: 'both', + }, + projectConfig: { + profile: 'custom', + delivery: 'skills', + workflows: ['explore', 'verify'], + }, + }); + + expect(resolved.profile).toBe('custom'); + expect(resolved.delivery).toBe('skills'); + expect(resolved.workflows).toEqual(['explore', 'verify']); + expect(resolved.sources).toEqual({ + profile: 'project', + delivery: 'project', + workflows: 'project', + }); + }); + + it('falls back key-by-key for partial project config', () => { + const resolved = resolveEffectiveProfileSettings({ + globalConfig: { + featureFlags: {}, + profile: 'custom', + delivery: 'commands', + workflows: ['new'], + }, + projectConfig: { + profile: 'custom', + }, + }); + + expect(resolved).toEqual({ + profile: 'custom', + delivery: 'commands', + workflows: ['new'], + sources: { + profile: 'project', + delivery: 'user', + workflows: 'user', + }, + }); + }); + + it('ignores project config when scope override is user', () => { + const resolved = resolveEffectiveProfileSettings({ + scopeOverride: 'user', + globalConfig: { + featureFlags: {}, + profile: 'custom', + delivery: 'commands', + workflows: ['continue'], + }, + projectConfig: { + profile: 'core', + delivery: 'skills', + workflows: ['explore'], + }, + }); + + expect(resolved).toEqual({ + profile: 'custom', + delivery: 'commands', + workflows: ['continue'], + sources: { + profile: 'user', + delivery: 'user', + workflows: 'user', + }, + }); + }); + + it('applies CLI overrides before project and global values', () => { + const resolved = resolveEffectiveProfileSettings({ + cliOverrides: { + profile: 'custom', + delivery: 'skills', + workflows: ['verify'], + }, + globalConfig: { + featureFlags: {}, + profile: 'core', + delivery: 'both', + }, + projectConfig: { + profile: 'custom', + delivery: 'commands', + workflows: ['explore'], + }, + }); + + expect(resolved).toEqual({ + profile: 'custom', + delivery: 'skills', + workflows: ['verify'], + sources: { + profile: 'cli', + delivery: 'cli', + workflows: 'cli', + }, + }); + }); + + it('derives core workflows from core profile even if workflow keys exist', () => { + const resolved = resolveEffectiveProfileSettings({ + globalConfig: { + featureFlags: {}, + profile: 'core', + delivery: 'both', + workflows: ['verify'], + }, + projectConfig: { + workflows: ['sync'], + }, + }); + + expect(resolved.workflows).toEqual(['propose', 'explore', 'apply', 'sync', 'archive']); + expect(resolved.sources.workflows).toBe('user'); + }); +}); diff --git a/test/core/project-config.test.ts b/test/core/project-config.test.ts index 88944659d..1614b78cc 100644 --- a/test/core/project-config.test.ts +++ b/test/core/project-config.test.ts @@ -68,6 +68,31 @@ rules: expect(consoleWarnSpy).not.toHaveBeenCalled(); }); + it('should parse valid profile fields from project config', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +profile: custom +delivery: skills +workflows: + - explore + - apply +` + ); + + const config = readProjectConfig(tempDir); + + expect(config).toEqual({ + schema: 'spec-driven', + profile: 'custom', + delivery: 'skills', + workflows: ['explore', 'apply'], + }); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + it('should return partial config when schema is invalid', () => { const configDir = path.join(tempDir, 'openspec'); fs.mkdirSync(configDir, { recursive: true }); @@ -120,6 +145,52 @@ rules: ); }); + it('should keep valid sibling profile fields when profile is invalid', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +profile: invalid +delivery: commands +` + ); + + const config = readProjectConfig(tempDir); + + expect(config).toEqual({ + schema: 'spec-driven', + delivery: 'commands', + }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid 'profile' field") + ); + }); + + it('should keep valid profile and delivery when workflows is invalid', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yaml'), + `schema: spec-driven +profile: custom +delivery: both +workflows: invalid +` + ); + + const config = readProjectConfig(tempDir); + + expect(config).toEqual({ + schema: 'spec-driven', + profile: 'custom', + delivery: 'both', + }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Invalid 'workflows' field") + ); + }); + it('should return partial config when rules is not an object', () => { const configDir = path.join(tempDir, 'openspec'); fs.mkdirSync(configDir, { recursive: true }); @@ -397,6 +468,29 @@ context: | expect(config?.context).toBe('from yml'); }); + it('should parse profile fields from .yml files', () => { + const configDir = path.join(tempDir, 'openspec'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'config.yml'), + `schema: custom-schema +profile: custom +delivery: commands +workflows: + - verify +` + ); + + const config = readProjectConfig(tempDir); + + expect(config).toEqual({ + schema: 'custom-schema', + profile: 'custom', + delivery: 'commands', + workflows: ['verify'], + }); + }); + it('should return null when neither .yaml nor .yml exist', () => { const configDir = path.join(tempDir, 'openspec'); fs.mkdirSync(configDir, { recursive: true }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index ea7f66a7e..f6710651d 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1462,6 +1462,101 @@ More user content after markers. consoleSpy.mockRestore(); }); + it('should let project profile settings override global settings by default', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + await fs.writeFile( + path.join(testDir, 'openspec', 'config.yaml'), + `schema: spec-driven +profile: custom +workflows: + - explore +` + ); + + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + await updateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-explore', 'SKILL.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-propose', 'SKILL.md') + )).toBe(false); + }); + + it('should ignore project profile settings when scope override is user', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + await fs.writeFile( + path.join(testDir, 'openspec', 'config.yaml'), + `schema: spec-driven +profile: custom +workflows: + - explore +` + ); + + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + const scopedUpdateCommand = new UpdateCommand({ scope: 'user' }); + await scopedUpdateCommand.execute(testDir); + + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-propose', 'SKILL.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-apply-change', 'SKILL.md') + )).toBe(true); + }); + + it('should support explicit project scope with fallback for missing keys', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'commands', + workflows: ['verify'], + }); + + await fs.writeFile( + path.join(testDir, 'openspec', 'config.yaml'), + `schema: spec-driven +profile: custom +workflows: + - explore +` + ); + + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + const scopedUpdateCommand = new UpdateCommand({ scope: 'project' }); + await scopedUpdateCommand.execute(testDir); + + // Delivery falls back to global commands-only when project delivery is absent. + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-explore', 'SKILL.md') + )).toBe(false); + + const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); + expect(await FileSystemUtils.fileExists(path.join(commandsDir, 'explore.md'))).toBe(true); + expect(await FileSystemUtils.fileExists(path.join(commandsDir, 'verify.md'))).toBe(false); + }); + it('should respect skills-only delivery setting', async () => { setMockConfig({ featureFlags: {},