From c76de92e82a3cc445bbfad03a14012e7070fc583 Mon Sep 17 00:00:00 2001 From: TabishB Date: Sat, 21 Feb 2026 05:33:06 -0800 Subject: [PATCH 1/2] Improve profile sync flows and add coverage for commands-only edge cases --- docs/cli.md | 32 ++ .../simplify-skill-installation/proposal.md | 2 +- .../specs/cli-init/spec.md | 28 +- .../specs/cli-update/spec.md | 7 + .../simplify-skill-installation/tasks.md | 6 +- openspec/project.md | 53 --- openspec/specs/cli-config/spec.md | 47 +++ src/commands/config.ts | 395 +++++++++++++++--- src/core/completions/command-registry.ts | 2 +- src/core/init.ts | 60 +-- src/core/migration.ts | 68 ++- src/core/profile-sync-drift.ts | 214 ++++++++++ src/core/update.ts | 260 ++++-------- src/prompts/searchable-multi-select.ts | 10 +- test/commands/config-profile.test.ts | 382 +++++++++++++++++ test/core/init.test.ts | 124 ++++++ test/core/migration.test.ts | 134 ++++++ test/core/profile-sync-drift.test.ts | 92 ++++ test/core/update.test.ts | 119 +++++- 19 files changed, 1682 insertions(+), 353 deletions(-) delete mode 100644 openspec/project.md create mode 100644 src/core/profile-sync-drift.ts create mode 100644 test/commands/config-profile.test.ts create mode 100644 test/core/migration.test.ts create mode 100644 test/core/profile-sync-drift.test.ts diff --git a/docs/cli.md b/docs/cli.md index e064e9dac..df12fe1ca 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -767,6 +767,7 @@ openspec config [options] | `unset ` | Remove a key | | `reset` | Reset to defaults | | `edit` | Open in `$EDITOR` | +| `profile [preset]` | Configure workflow profile interactively or via preset | **Examples:** @@ -794,6 +795,37 @@ openspec config reset --all --yes # Edit config in your editor openspec config edit + +# Configure profile with action-based wizard +openspec config profile + +# Fast preset: switch workflows to core (keeps delivery mode) +openspec config profile core +``` + +`openspec config profile` starts with a current-state summary, then lets you choose: +- Change delivery + workflows +- Change delivery only +- Change workflows only +- 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 files are out of sync with your global profile/delivery, OpenSpec will show a warning and suggest running `openspec update`. +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). + +**Interactive examples:** + +```bash +# Delivery-only update +openspec config profile +# choose: Change delivery only +# choose delivery: Skills only + +# Workflows-only update +openspec config profile +# choose: Change workflows only +# toggle workflows in the checklist, then confirm ``` --- diff --git a/openspec/changes/simplify-skill-installation/proposal.md b/openspec/changes/simplify-skill-installation/proposal.md index a8c60903b..1a002ab64 100644 --- a/openspec/changes/simplify-skill-installation/proposal.md +++ b/openspec/changes/simplify-skill-installation/proposal.md @@ -142,7 +142,7 @@ After migration, subsequent `init` and `update` commands respect the migrated co - Existing users' workflows are preserved exactly as-is (no `propose` added automatically) - Both `init` (re-init) and `update` trigger migration on existing projects if no profile is set - `openspec init` on a **new** project (no existing workflows) uses global config, defaulting to `core` -- `init` with a custom profile shows what will be installed and prompts to proceed or reconfigure +- `init` with a custom profile applies the configured workflows directly (no profile confirmation prompt) - `init` validates `--profile` values (`core` or `custom`) and errors on invalid input - Migration message mentions `propose` and suggests `openspec config profile core` to opt in - After migration, users can opt into `core` profile via `openspec config profile core` diff --git a/openspec/changes/simplify-skill-installation/specs/cli-init/spec.md b/openspec/changes/simplify-skill-installation/specs/cli-init/spec.md index 95176809f..b779e245d 100644 --- a/openspec/changes/simplify-skill-installation/specs/cli-init/spec.md +++ b/openspec/changes/simplify-skill-installation/specs/cli-init/spec.md @@ -148,32 +148,24 @@ The init command SHALL read and apply settings from global config. - **THEN** the system SHALL exit with code 1 - **THEN** the system SHALL display a validation error listing allowed profile values -### Requirement: Init shows profile confirmation for non-default profiles -The init command SHALL show what profile is being applied when it differs from `core`, allowing the user to adjust before proceeding. +### Requirement: Init applies configured profile without confirmation +The init command SHALL apply the resolved profile (`--profile` override or global config) directly without prompting for confirmation. #### Scenario: Init with custom profile (interactive) - **WHEN** user runs `openspec init` interactively - **AND** global config specifies `profile: "custom"` with workflows -- **THEN** the system SHALL display: "Applying custom profile ( workflows): " -- **THEN** the system SHALL prompt: "Proceed? (y/n) Or run 'openspec config profile' to change." -- **WHEN** user confirms -- **THEN** the system SHALL proceed with init using the custom profile - -#### Scenario: Init with custom profile — user declines -- **WHEN** user declines the profile confirmation prompt -- **THEN** the system SHALL display: "Run 'openspec config profile' to update your profile, then try again." -- **THEN** the system SHALL exit with code 0 (no error) - -#### Scenario: Init with core profile (no confirmation needed) -- **WHEN** user runs `openspec init` interactively -- **AND** profile is `core` (default) -- **THEN** the system SHALL NOT show a profile confirmation prompt -- **THEN** the system SHALL proceed directly +- **THEN** the system SHALL proceed directly using the custom profile workflows +- **AND** the system SHALL NOT show a profile confirmation prompt #### Scenario: Non-interactive init with custom profile - **WHEN** user runs `openspec init` non-interactively - **AND** global config specifies a custom profile -- **THEN** the system SHALL proceed without confirmation (CI assumes intentional config) +- **THEN** the system SHALL proceed without confirmation + +#### Scenario: Init with core profile +- **WHEN** user runs `openspec init` interactively +- **AND** profile is `core` (default) +- **THEN** the system SHALL proceed directly without a profile confirmation prompt ### Requirement: Init preserves existing workflows The init command SHALL NOT remove workflows that are already installed, but SHALL respect delivery setting. diff --git a/openspec/changes/simplify-skill-installation/specs/cli-update/spec.md b/openspec/changes/simplify-skill-installation/specs/cli-update/spec.md index cac0bfa4e..c38d26829 100644 --- a/openspec/changes/simplify-skill-installation/specs/cli-update/spec.md +++ b/openspec/changes/simplify-skill-installation/specs/cli-update/spec.md @@ -139,6 +139,13 @@ The update command SHALL notify the user if new AI tool directories are detected - **THEN** the system SHALL NOT automatically add the new tool - **THEN** the system SHALL proceed with update for currently configured tools only +#### Scenario: Multiple new tool directories detected +- **WHEN** user runs `openspec update` +- **AND** multiple new tool directories are detected (e.g., `.github/` and `.windsurf/` exist but neither tool is configured) +- **THEN** the system SHALL display one consolidated message listing all detected tools, for example: "Detected new tools: GitHub Copilot, Windsurf. Run 'openspec init' to add them." +- **THEN** the system SHALL NOT automatically add any new tools +- **THEN** the system SHALL proceed with update for currently configured tools only + #### Scenario: No new tool directories - **WHEN** user runs `openspec update` - **AND** no new tool directories are detected diff --git a/openspec/changes/simplify-skill-installation/tasks.md b/openspec/changes/simplify-skill-installation/tasks.md index caf3d9d5b..899347427 100644 --- a/openspec/changes/simplify-skill-installation/tasks.md +++ b/openspec/changes/simplify-skill-installation/tasks.md @@ -64,11 +64,11 @@ - [x] 7.2 Update init to read global config for profile/delivery defaults - [x] 7.3 Add migration check to init: call shared `migrateIfNeeded()` before profile resolution - [x] 7.4 Change tool selection to show pre-selected detected tools -- [x] 7.5 Add profile confirmation for non-default profiles: display what will be installed and prompt to proceed or reconfigure +- [x] 7.5 Apply configured profile directly in init (no profile confirmation prompt) - [x] 7.6 Update success message to show `/opsx:propose` prompt (only if propose is in the active profile) - [x] 7.7 Add `--profile` flag to override global config - [x] 7.8 Update non-interactive mode to use defaults without prompting -- [x] 7.9 Add tests for init flow with various scenarios (including migration on re-init, custom profile confirmation) +- [x] 7.9 Add tests for init flow with various scenarios (including migration on re-init and custom profile behavior) ## 8. Update Command (Profile Support + Migration) @@ -119,7 +119,7 @@ - [x] 12.4 Update CLI help text for new commands - [x] 12.5 Manual: interactive init — verify detected tools are pre-selected, confirm prompt works, success message is correct - [x] 12.6 Manual: `openspec config profile` picker — verify delivery toggle, workflow toggles, pre-selection of current values, core preset shortcut -- [x] 12.7 Manual: init with custom profile — verify confirmation prompt shows what will be installed +- [x] 12.7 Manual: init with custom profile — verify init proceeds without profile confirmation prompt - [x] 12.8 Manual: delivery change via update — verify correct files are deleted/created when switching between skills/commands/both - [x] 12.9 Manual: migration flow — run update on a pre-existing project with no profile in config, verify migration message and resulting config diff --git a/openspec/project.md b/openspec/project.md deleted file mode 100644 index 113a1d10b..000000000 --- a/openspec/project.md +++ /dev/null @@ -1,53 +0,0 @@ -# OpenSpec Project Overview - -A minimal CLI tool that helps developers set up OpenSpec file structures and keep AI instructions updated. The AI tools themselves handle all the change management complexity by working directly with markdown files. - -## Technology Stack -- Language: TypeScript -- Runtime: Node.js (≥20.19.0, ESM modules) -- Package Manager: pnpm -- CLI Framework: Commander.js -- User Interaction: @inquirer/prompts -- Distribution: npm package - -## Project Structure -``` -src/ -├── cli/ # CLI command implementations -├── core/ # Core OpenSpec logic (templates, structure) -└── utils/ # Shared utilities (file operations, rollback) - -dist/ # Compiled output (gitignored) -``` - -## Conventions -- TypeScript strict mode enabled -- Async/await for all asynchronous operations -- Minimal dependencies principle -- Clear separation of CLI, core logic, and utilities -- AI-friendly code with descriptive names - -## Error Handling -- Let errors bubble up to CLI level for consistent user messaging -- Use native Error types with descriptive messages -- Exit with appropriate codes: 0 (success), 1 (general error), 2 (misuse) -- No try-catch in utility functions, handle at command level - -## Logging -- Use console methods directly (no logging library) -- console.log() for normal output -- console.error() for errors (outputs to stderr) -- No verbose/debug modes initially (keep it simple) - -## Testing Strategy -- Manual testing via `pnpm link` during development -- Smoke tests for critical paths only (init, help commands) -- No unit tests initially - add when complexity grows -- Test commands: `pnpm test:smoke` (when added) - -## Development Workflow -- Use pnpm for all package management -- Run `pnpm run build` to compile TypeScript -- Run `pnpm run dev` for development mode -- Test locally with `pnpm link` -- Follow OpenSpec's own change-driven development process \ No newline at end of file diff --git a/openspec/specs/cli-config/spec.md b/openspec/specs/cli-config/spec.md index fb2b8380a..8b87d110a 100644 --- a/openspec/specs/cli-config/spec.md +++ b/openspec/specs/cli-config/spec.md @@ -167,6 +167,53 @@ The config command SHALL open the config file in the user's editor. - **THEN** display error message suggesting to set `$EDITOR` - **AND** exit with code 1 +### Requirement: Profile Configuration Flow + +The `openspec config profile` command SHALL provide an action-first interactive flow that allows users to modify delivery and workflow settings independently. + +#### Scenario: Current profile summary appears first + +- **WHEN** user runs `openspec config profile` in an interactive terminal +- **THEN** display a current-state header with: + - current delivery value + - workflow count with profile label (core or custom) + +#### Scenario: Action-first menu offers skippable paths + +- **WHEN** user runs `openspec config profile` interactively +- **THEN** the first prompt SHALL offer: + - `Change delivery + workflows` + - `Change delivery only` + - `Change workflows only` + - `Keep current settings (exit)` + +#### Scenario: Delivery prompt marks current selection + +- **WHEN** delivery selection is shown in `openspec config profile` +- **THEN** the currently configured delivery option SHALL include `[current]` in its label +- **AND** that value SHALL be preselected by default + +#### Scenario: No-op exits without saving or apply prompt + +- **WHEN** user chooses `Keep current settings (exit)` OR makes selections that do not change effective config values +- **THEN** the command SHALL print `No config changes.` +- **AND** SHALL NOT write config changes +- **AND** SHALL NOT ask to apply updates to the current project + +#### Scenario: No-op warns when current project is out of sync + +- **WHEN** `openspec config profile` exits with `No config changes.` inside an OpenSpec project +- **AND** project files are out of sync with the current global profile/delivery +- **THEN** display a non-blocking warning that global config is not yet applied to this project +- **AND** include guidance to run `openspec update` to sync project files + +#### Scenario: Apply prompt is gated on actual changes + +- **WHEN** config values were changed and saved +- **AND** current directory is an OpenSpec project +- **THEN** prompt `Apply changes to this project now?` +- **AND** if confirmed, run `openspec update` for the current project + ### Requirement: Key Naming Convention The config command SHALL use camelCase keys matching the JSON structure. diff --git a/src/commands/config.ts b/src/commands/config.ts index c9d800c3e..42c736d14 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -21,6 +21,185 @@ import { } 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'; + +type ProfileAction = 'both' | 'delivery' | 'workflows' | 'keep'; + +interface ProfileState { + profile: Profile; + delivery: Delivery; + workflows: string[]; +} + +interface ProfileStateDiff { + hasChanges: boolean; + lines: string[]; +} + +interface WorkflowPromptMeta { + name: string; + description: string; +} + +const WORKFLOW_PROMPT_META: Record = { + 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: unknown): boolean { + 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: GlobalConfig): ProfileState { + 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: string[]): Profile { + 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: readonly string[], profile: Profile): string { + return `${workflows.length} selected (${profile})`; +} + +function stableWorkflowOrder(workflows: readonly string[]): string[] { + const seen = new Set(); + const ordered: string[] = []; + + 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 as (typeof ALL_WORKFLOWS)[number])); + 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: ProfileState, after: ProfileState): ProfileStateDiff { + const lines: string[] = []; + + 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: string[] = []; + 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: string, + state: ProfileState, + colorize: (message: string) => string +): void { + 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. @@ -182,10 +361,20 @@ export function registerConfigCommand(program: Command): void { if (!options.yes) { const { confirm } = await import('@inquirer/prompts'); - const confirmed = await confirm({ - message: 'Reset all configuration to defaults?', - default: false, - }); + let confirmed: boolean; + 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.'); @@ -290,67 +479,167 @@ export function registerConfigCommand(program: Command): void { } // Interactive picker - const { select, checkbox } = await import('@inquirer/prompts'); + const { select, checkbox, confirm } = await import('@inquirer/prompts'); + const chalk = (await import('chalk')).default; - const config = getGlobalConfig(); + 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', + }, + ], + }); - // Delivery selection - const delivery = await select({ - message: 'Delivery mode (how workflows are installed):', - choices: [ - { value: 'both' as Delivery, name: 'Both (skills and commands)' }, - { value: 'skills' as Delivery, name: 'Skills only' }, - { value: 'commands' as Delivery, name: 'Commands only' }, - ], - default: config.delivery || 'both', - }); + if (action === 'keep') { + console.log('No config changes.'); + maybeWarnConfigDrift(process.cwd(), currentState, chalk.yellow); + return; + } - // Workflow toggles - use getProfileWorkflows to resolve current active workflows - const currentWorkflows = getProfileWorkflows(config.profile || 'core', config.workflows ? [...config.workflows] : undefined); - const selectedWorkflows = await checkbox({ - message: 'Select workflows to install:', - choices: ALL_WORKFLOWS.map((w) => ({ - value: w, - name: w, - checked: currentWorkflows.includes(w), - })), - }); + const nextState: ProfileState = { + profile: currentState.profile, + delivery: currentState.delivery, + workflows: [...currentState.workflows], + }; + + if (action === 'both' || action === 'delivery') { + const deliveryChoices: { value: Delivery; name: string; description: string }[] = [ + { + value: 'both' as Delivery, + name: 'Both (skills + commands)', + description: 'Install workflows as both skills and slash commands', + }, + { + value: 'skills' as Delivery, + name: 'Skills only', + description: 'Install workflows only as skills', + }, + { + value: 'commands' as Delivery, + name: 'Commands only', + description: 'Install workflows only as slash commands', + }, + ]; + for (const choice of deliveryChoices) { + if (choice.value === currentState.delivery) { + choice.name += ' [current]'; + } + } - // Determine profile based on selection - const isCoreMatch = - selectedWorkflows.length === CORE_WORKFLOWS.length && - CORE_WORKFLOWS.every((w) => selectedWorkflows.includes(w)); + nextState.delivery = await select({ + message: 'Delivery mode (how workflows are installed):', + choices: deliveryChoices, + default: currentState.delivery, + }); + } - const profile: Profile = isCoreMatch ? 'core' : 'custom'; + if (action === 'both' || action === 'workflows') { + const formatWorkflowChoice = (workflow: string) => { + 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); + } - config.profile = profile; - config.delivery = delivery; - config.workflows = selectedWorkflows; + const diff = diffProfileState(currentState, nextState); + if (!diff.hasChanges) { + console.log('No config changes.'); + maybeWarnConfigDrift(process.cwd(), nextState, chalk.yellow); + return; + } - saveGlobalConfig(config); + console.log(chalk.bold('\nConfig changes:')); + for (const line of diff.lines) { + console.log(` ${line}`); + } + console.log(); - // Check if inside an OpenSpec project - const projectDir = process.cwd(); - const openspecDir = path.join(projectDir, OPENSPEC_DIR_NAME); - if (fs.existsSync(openspecDir)) { - const { confirm } = await import('@inquirer/prompts'); - const applyNow = await confirm({ - message: 'Apply to this project now?', - default: true, - }); + config.profile = nextState.profile; + config.delivery = nextState.delivery; + config.workflows = nextState.workflows; + saveGlobalConfig(config); - 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; + // 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; } - - console.log('Config updated. Run `openspec update` in your projects to apply.'); }); } diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 8d67d20f3..09c9ecc8d 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -379,7 +379,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, { name: 'profile', - description: 'Configure workflow profile', + description: 'Configure workflow profile (interactive picker or preset shortcut)', flags: [], }, ], diff --git a/src/core/init.ts b/src/core/init.ts index 0d18dbfe2..a718ed40b 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -128,24 +128,8 @@ export class InitCommand { await showWelcomeScreen(); } - // Resolve profile (--profile flag overrides global config) (task 7.7) - const globalConfig = getGlobalConfig(); - const profile: Profile = this.resolveProfileOverride() ?? globalConfig.profile ?? 'core'; - const workflows = getProfileWorkflows(profile, globalConfig.workflows); - - // Profile confirmation for non-default profiles (task 7.5) - if (canPrompt && profile === 'custom' && workflows.length > 0) { - console.log(`Applying custom profile (${workflows.length} workflows): ${[...workflows].join(', ')}`); - const { confirm } = await import('@inquirer/prompts'); - const proceed = await confirm({ - message: "Proceed? Or run 'openspec config profile' to change.", - default: true, - }); - if (!proceed) { - console.log("Run 'openspec config profile' to update your profile, then try again."); - return; - } - } + // Resolve profile override early so invalid values fail before tool setup. + this.resolveProfileOverride(); // Get tool states before processing const toolStates = getToolStates(projectPath); @@ -286,6 +270,12 @@ export class InitCommand { const validTools = getToolsWithSkillsDir(); const detectedToolIds = new Set(detectedTools.map((t) => t.value)); + const configuredToolIds = new Set( + [...toolStates.entries()] + .filter(([, status]) => status.configured) + .map(([toolId]) => toolId) + ); + const shouldPreselectDetected = !extendMode && configuredToolIds.size === 0; const canPrompt = this.canPromptInteractively(); // Non-interactive mode: use detected tools as fallback (task 7.8) @@ -307,7 +297,7 @@ export class InitCommand { // Interactive mode: show searchable multi-select const { searchableMultiSelect } = await import('../prompts/searchable-multi-select.js'); - // Build choices: pre-select detected tools AND configured tools (task 7.4) + // Build choices: pre-select configured tools; keep detected tools visible but unselected. const sortedChoices = validTools .map((toolId) => { const tool = AI_TOOLS.find((t) => t.value === toolId); @@ -319,20 +309,36 @@ export class InitCommand { name: tool?.name || toolId, value: toolId, configured, - preSelected: configured || detected, // Pre-select both configured and detected tools + detected: detected && !configured, + preSelected: configured || (shouldPreselectDetected && detected && !configured), }; }) .sort((a, b) => { - // Pre-selected tools first (configured or detected) - if (a.preSelected && !b.preSelected) return -1; - if (!a.preSelected && b.preSelected) return 1; + // Configured tools first, then detected (not configured), then everything else. + if (a.configured && !b.configured) return -1; + if (!a.configured && b.configured) return 1; + if (a.detected && !b.detected) return -1; + if (!a.detected && b.detected) return 1; return 0; }); - // Show detected tools if any - if (detectedToolIds.size > 0) { - const detectedNames = detectedTools.map((t) => t.name).join(', '); - console.log(`Detected: ${detectedNames}`); + const configuredNames = validTools + .filter((toolId) => configuredToolIds.has(toolId)) + .map((toolId) => AI_TOOLS.find((t) => t.value === toolId)?.name || toolId); + + if (configuredNames.length > 0) { + console.log(`OpenSpec configured: ${configuredNames.join(', ')} (pre-selected)`); + } + + const detectedOnlyNames = detectedTools + .filter((tool) => !configuredToolIds.has(tool.value)) + .map((tool) => tool.name); + + if (detectedOnlyNames.length > 0) { + const detectionLabel = shouldPreselectDetected + ? 'pre-selected for first-time setup' + : 'not pre-selected'; + console.log(`Detected tool directories: ${detectedOnlyNames.join(', ')} (${detectionLabel})`); } const selectedTools = await searchableMultiSelect({ diff --git a/src/core/migration.ts b/src/core/migration.ts index dc6e9c585..ca3b38df5 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -6,14 +6,16 @@ */ import type { AIToolOption } from './config.js'; -import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig } from './global-config.js'; +import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } from './global-config.js'; +import { CommandAdapterRegistry } from './command-generation/index.js'; +import { ALL_WORKFLOWS } from './profiles.js'; import path from 'path'; import * as fs from 'fs'; /** * Maps workflow IDs to their skill directory names for scanning. */ -const WORKFLOW_TO_SKILL_DIR: Record = { +const WORKFLOW_TO_SKILL_DIR: Record<(typeof ALL_WORKFLOWS)[number], string> = { 'propose': 'openspec-propose', 'explore': 'openspec-explore', 'new': 'openspec-new-change', @@ -27,12 +29,19 @@ const WORKFLOW_TO_SKILL_DIR: Record = { 'onboard': 'openspec-onboard', }; -/** - * Scans installed workflow files across all detected tools and returns - * the union of installed workflow IDs. - */ -export function scanInstalledWorkflows(projectPath: string, tools: AIToolOption[]): string[] { +interface InstalledWorkflowArtifacts { + workflows: string[]; + hasSkills: boolean; + hasCommands: boolean; +} + +function scanInstalledWorkflowArtifacts( + projectPath: string, + tools: AIToolOption[] +): InstalledWorkflowArtifacts { const installed = new Set(); + let hasSkills = false; + let hasCommands = false; for (const tool of tools) { if (!tool.skillsDir) continue; @@ -42,11 +51,48 @@ export function scanInstalledWorkflows(projectPath: string, tools: AIToolOption[ const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md'); if (fs.existsSync(skillFile)) { installed.add(workflowId); + hasSkills = true; + } + } + + const adapter = CommandAdapterRegistry.get(tool.value); + if (!adapter) continue; + + for (const workflowId of ALL_WORKFLOWS) { + const commandPath = adapter.getFilePath(workflowId); + const fullPath = path.isAbsolute(commandPath) + ? commandPath + : path.join(projectPath, commandPath); + if (fs.existsSync(fullPath)) { + installed.add(workflowId); + hasCommands = true; } } } - return [...installed]; + return { + workflows: ALL_WORKFLOWS.filter((workflowId) => installed.has(workflowId)), + hasSkills, + hasCommands, + }; +} + +/** + * Scans installed workflow files across all detected tools and returns + * the union of installed workflow IDs. + */ +export function scanInstalledWorkflows(projectPath: string, tools: AIToolOption[]): string[] { + return scanInstalledWorkflowArtifacts(projectPath, tools).workflows; +} + +function inferDelivery(artifacts: InstalledWorkflowArtifacts): Delivery { + if (artifacts.hasSkills && artifacts.hasCommands) { + return 'both'; + } + if (artifacts.hasCommands) { + return 'commands'; + } + return 'skills'; } /** @@ -79,7 +125,8 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi } // Scan for installed workflows - const installedWorkflows = scanInstalledWorkflows(projectPath, tools); + const artifacts = scanInstalledWorkflowArtifacts(projectPath, tools); + const installedWorkflows = artifacts.workflows; if (installedWorkflows.length === 0) { // No workflows installed, new user — defaults will apply @@ -89,6 +136,9 @@ export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): voi // Migrate: set profile to custom with detected workflows config.profile = 'custom'; config.workflows = installedWorkflows; + if (rawConfig.delivery === undefined) { + config.delivery = inferDelivery(artifacts); + } saveGlobalConfig(config); console.log(`Migrated: custom profile with ${installedWorkflows.length} workflows`); diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts new file mode 100644 index 000000000..13f2c946b --- /dev/null +++ b/src/core/profile-sync-drift.ts @@ -0,0 +1,214 @@ +import path from 'path'; +import * as fs from 'fs'; +import { AI_TOOLS } from './config.js'; +import type { Delivery } from './global-config.js'; +import { ALL_WORKFLOWS } from './profiles.js'; +import { CommandAdapterRegistry } from './command-generation/index.js'; +import { COMMAND_IDS, getConfiguredTools } from './shared/index.js'; + +type WorkflowId = (typeof ALL_WORKFLOWS)[number]; + +/** + * Maps workflow IDs to their skill directory names. + */ +export const WORKFLOW_TO_SKILL_DIR: Record = { + 'explore': 'openspec-explore', + 'new': 'openspec-new-change', + 'continue': 'openspec-continue-change', + 'apply': 'openspec-apply-change', + 'ff': 'openspec-ff-change', + 'sync': 'openspec-sync-specs', + 'archive': 'openspec-archive-change', + 'bulk-archive': 'openspec-bulk-archive-change', + 'verify': 'openspec-verify-change', + 'onboard': 'openspec-onboard', + 'propose': 'openspec-propose', +}; + +function toKnownWorkflows(workflows: readonly string[]): WorkflowId[] { + return workflows.filter( + (workflow): workflow is WorkflowId => + (ALL_WORKFLOWS as readonly string[]).includes(workflow) + ); +} + +/** + * Checks whether a tool has at least one generated OpenSpec command file. + */ +export function toolHasAnyConfiguredCommand(projectPath: string, toolId: string): boolean { + const adapter = CommandAdapterRegistry.get(toolId); + if (!adapter) return false; + + for (const commandId of COMMAND_IDS) { + const cmdPath = adapter.getFilePath(commandId); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + if (fs.existsSync(fullPath)) { + return true; + } + } + + return false; +} + +/** + * Returns tools with at least one generated command file on disk. + */ +export function getCommandConfiguredTools(projectPath: string): string[] { + return AI_TOOLS + .filter((tool) => { + if (!tool.skillsDir) return false; + const toolDir = path.join(projectPath, tool.skillsDir); + try { + return fs.statSync(toolDir).isDirectory(); + } catch { + return false; + } + }) + .map((tool) => tool.value) + .filter((toolId) => toolHasAnyConfiguredCommand(projectPath, toolId)); +} + +/** + * Returns tools that are configured via either skills or commands. + */ +export function getConfiguredToolsForProfileSync(projectPath: string): string[] { + const skillConfigured = getConfiguredTools(projectPath); + const commandConfigured = getCommandConfiguredTools(projectPath); + return [...new Set([...skillConfigured, ...commandConfigured])]; +} + +/** + * Detects if a single tool has profile/delivery drift against the desired state. + */ +export function hasToolProfileOrDeliveryDrift( + projectPath: string, + toolId: string, + desiredWorkflows: readonly string[], + delivery: Delivery +): boolean { + const tool = AI_TOOLS.find((t) => t.value === toolId); + if (!tool?.skillsDir) return false; + + const knownDesiredWorkflows = toKnownWorkflows(desiredWorkflows); + const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const adapter = CommandAdapterRegistry.get(toolId); + const shouldGenerateSkills = delivery !== 'commands'; + const shouldGenerateCommands = delivery !== 'skills'; + + if (shouldGenerateSkills) { + for (const workflow of knownDesiredWorkflows) { + const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; + const skillFile = path.join(skillsDir, dirName, 'SKILL.md'); + if (!fs.existsSync(skillFile)) { + return true; + } + } + } else { + for (const workflow of ALL_WORKFLOWS) { + const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; + const skillDir = path.join(skillsDir, dirName); + if (fs.existsSync(skillDir)) { + return true; + } + } + } + + if (shouldGenerateCommands && adapter) { + for (const workflow of knownDesiredWorkflows) { + const cmdPath = adapter.getFilePath(workflow); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + if (!fs.existsSync(fullPath)) { + return true; + } + } + } else if (!shouldGenerateCommands && adapter) { + for (const workflow of ALL_WORKFLOWS) { + const cmdPath = adapter.getFilePath(workflow); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + if (fs.existsSync(fullPath)) { + return true; + } + } + } + + return false; +} + +/** + * Returns configured tools that currently need a profile/delivery sync. + */ +export function getToolsNeedingProfileSync( + projectPath: string, + desiredWorkflows: readonly string[], + delivery: Delivery, + configuredTools?: readonly string[] +): string[] { + const tools = configuredTools ? [...new Set(configuredTools)] : getConfiguredToolsForProfileSync(projectPath); + return tools.filter((toolId) => + hasToolProfileOrDeliveryDrift(projectPath, toolId, desiredWorkflows, delivery) + ); +} + +function getInstalledWorkflowsForTool( + projectPath: string, + toolId: string, + options: { includeSkills: boolean; includeCommands: boolean } +): WorkflowId[] { + const tool = AI_TOOLS.find((t) => t.value === toolId); + if (!tool?.skillsDir) return []; + + const installed = new Set(); + const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + + if (options.includeSkills) { + for (const workflow of ALL_WORKFLOWS) { + const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; + const skillFile = path.join(skillsDir, dirName, 'SKILL.md'); + if (fs.existsSync(skillFile)) { + installed.add(workflow); + } + } + } + + if (options.includeCommands) { + const adapter = CommandAdapterRegistry.get(toolId); + if (adapter) { + for (const workflow of ALL_WORKFLOWS) { + const cmdPath = adapter.getFilePath(workflow); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + if (fs.existsSync(fullPath)) { + installed.add(workflow); + } + } + } + } + + return [...installed]; +} + +/** + * Detects whether the current project has any profile/delivery drift. + */ +export function hasProjectConfigDrift( + projectPath: string, + desiredWorkflows: readonly string[], + delivery: Delivery +): boolean { + const configuredTools = getConfiguredToolsForProfileSync(projectPath); + if (getToolsNeedingProfileSync(projectPath, desiredWorkflows, delivery, configuredTools).length > 0) { + return true; + } + + const desiredSet = new Set(toKnownWorkflows(desiredWorkflows)); + const includeSkills = delivery !== 'commands'; + const includeCommands = delivery !== 'skills'; + + for (const toolId of configuredTools) { + const installed = getInstalledWorkflowsForTool(projectPath, toolId, { includeSkills, includeCommands }); + if (installed.some((workflow) => !desiredSet.has(workflow))) { + return true; + } + } + + return false; +} diff --git a/src/core/update.ts b/src/core/update.ts index 614dfda86..87ddb73ac 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -18,8 +18,6 @@ import { CommandAdapterRegistry, } from './command-generation/index.js'; import { - COMMAND_IDS, - getConfiguredTools, getToolVersionStatus, getSkillTemplates, getCommandContents, @@ -39,6 +37,12 @@ import { isInteractive } from '../utils/interactive.js'; import { getGlobalConfig, type Delivery } from './global-config.js'; import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js'; import { getAvailableTools } from './available-tools.js'; +import { + WORKFLOW_TO_SKILL_DIR, + getCommandConfiguredTools, + getConfiguredToolsForProfileSync, + getToolsNeedingProfileSync, +} from './profile-sync-drift.js'; import { scanInstalledWorkflows as scanInstalledWorkflowsShared, migrateIfNeeded as migrateIfNeededShared, @@ -47,23 +51,6 @@ import { const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); -/** - * Maps workflow IDs to their skill directory names (used for delivery file cleanup). - */ -const WORKFLOW_TO_SKILL_DIR: Record = { - 'explore': 'openspec-explore', - 'new': 'openspec-new-change', - 'continue': 'openspec-continue-change', - 'apply': 'openspec-apply-change', - 'ff': 'openspec-ff-change', - 'sync': 'openspec-sync-specs', - 'archive': 'openspec-archive-change', - 'bulk-archive': 'openspec-bulk-archive-change', - 'verify': 'openspec-verify-change', - 'onboard': 'openspec-onboard', - 'propose': 'openspec-propose', -}; - /** * Options for the update command. */ @@ -73,7 +60,7 @@ export interface UpdateCommandOptions { } /** - * Scans installed workflow skill directories across all configured tools in the project. + * Scans installed workflow artifacts (skills and managed commands) across all configured tools. * Returns the union of detected workflow IDs that match ALL_WORKFLOWS. * * Wrapper around the shared migration module's scanInstalledWorkflows that accepts tool IDs. @@ -101,26 +88,12 @@ export class UpdateCommand { throw new Error(`No OpenSpec directory found. Run 'openspec init' first.`); } - // 2. Detect and handle legacy artifacts + upgrade legacy tools to new skills - const newlyConfiguredTools = await this.handleLegacyCleanup(resolvedProjectPath); - - // 3. Find configured tools - const configuredTools = this.getConfiguredToolsForUpdate(resolvedProjectPath); - - if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) { - console.log(chalk.yellow('No configured tools found.')); - console.log(chalk.dim('Run "openspec init" to set up tools.')); - return; - } - - // 4. Perform one-time migration if needed (uses shared migration module) - const allToolIds = [...new Set([...configuredTools, ...newlyConfiguredTools])]; - const allTools = allToolIds - .map((id) => AI_TOOLS.find((t) => t.value === id)) - .filter((t): t is NonNullable => t != null); - migrateIfNeededShared(resolvedProjectPath, allTools); + // 2. Perform one-time migration if needed before any legacy upgrade generation. + // Use detected tool directories to preserve existing opsx skills/commands. + const detectedTools = getAvailableTools(resolvedProjectPath); + migrateIfNeededShared(resolvedProjectPath, detectedTools); - // 5. Read global config for profile/delivery + // 3. Read global config for profile/delivery const globalConfig = getGlobalConfig(); const profile = globalConfig.profile ?? 'core'; const delivery: Delivery = globalConfig.delivery ?? 'both'; @@ -131,8 +104,24 @@ export class UpdateCommand { const shouldGenerateSkills = delivery !== 'commands'; const shouldGenerateCommands = delivery !== 'skills'; + // 4. Detect and handle legacy artifacts + upgrade legacy tools using effective config + const newlyConfiguredTools = await this.handleLegacyCleanup( + resolvedProjectPath, + desiredWorkflows, + delivery + ); + + // 5. Find configured tools + const configuredTools = getConfiguredToolsForProfileSync(resolvedProjectPath); + + if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) { + console.log(chalk.yellow('No configured tools found.')); + console.log(chalk.dim('Run "openspec init" to set up tools.')); + return; + } + // 6. Check version status for all configured tools - const commandConfiguredTools = this.getCommandConfiguredTools(resolvedProjectPath); + const commandConfiguredTools = getCommandConfiguredTools(resolvedProjectPath); const commandConfiguredSet = new Set(commandConfiguredTools); const toolStatuses = configuredTools.map((toolId) => { const status = getToolVersionStatus(resolvedProjectPath, toolId, OPENSPEC_VERSION); @@ -147,14 +136,11 @@ export class UpdateCommand { const toolsNeedingVersionUpdate = toolStatuses .filter((s) => s.needsUpdate) .map((s) => s.toolId); - const toolsNeedingConfigSync = configuredTools.filter((toolId) => - this.hasProfileOrDeliveryDrift( - resolvedProjectPath, - toolId, - desiredWorkflows, - shouldGenerateSkills, - shouldGenerateCommands - ) + const toolsNeedingConfigSync = getToolsNeedingProfileSync( + resolvedProjectPath, + desiredWorkflows, + delivery, + configuredTools ); const toolsToUpdateSet = new Set([ ...toolsNeedingVersionUpdate, @@ -273,11 +259,13 @@ export class UpdateCommand { console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`); } + const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])]; + // 13. Detect new tool directories not currently configured - this.detectNewTools(resolvedProjectPath, configuredTools); + this.detectNewTools(resolvedProjectPath, configuredAndNewTools); // 14. Display note about extra workflows not in profile - this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows); + this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows); // 15. List affected tools if (updatedTools.length > 0) { @@ -325,108 +313,6 @@ export class UpdateCommand { } } - /** - * Returns tools that are configured via either skills or commands. - */ - private getConfiguredToolsForUpdate(projectPath: string): string[] { - const skillConfigured = getConfiguredTools(projectPath); - const commandConfigured = this.getCommandConfiguredTools(projectPath); - return [...new Set([...skillConfigured, ...commandConfigured])]; - } - - /** - * Returns tools with at least one generated command file on disk. - */ - private getCommandConfiguredTools(projectPath: string): string[] { - return AI_TOOLS - .filter((tool) => { - if (!tool.skillsDir) return false; - const toolDir = path.join(projectPath, tool.skillsDir); - try { - return fs.statSync(toolDir).isDirectory(); - } catch { - return false; - } - }) - .map((tool) => tool.value) - .filter((toolId) => this.toolHasAnyConfiguredCommand(projectPath, toolId)); - } - - /** - * Checks whether a tool has at least one generated OpenSpec command file. - */ - private toolHasAnyConfiguredCommand(projectPath: string, toolId: string): boolean { - const adapter = CommandAdapterRegistry.get(toolId); - if (!adapter) return false; - - for (const commandId of COMMAND_IDS) { - const cmdPath = adapter.getFilePath(commandId); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - if (fs.existsSync(fullPath)) { - return true; - } - } - - return false; - } - - /** - * Detects if profile or delivery settings require a file-level sync. - */ - private hasProfileOrDeliveryDrift( - projectPath: string, - toolId: string, - profileWorkflows: readonly (typeof ALL_WORKFLOWS)[number][], - shouldGenerateSkills: boolean, - shouldGenerateCommands: boolean - ): boolean { - const tool = AI_TOOLS.find((t) => t.value === toolId); - if (!tool?.skillsDir) return false; - - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); - const adapter = CommandAdapterRegistry.get(toolId); - - if (shouldGenerateSkills) { - for (const workflow of profileWorkflows) { - const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; - if (!dirName) continue; - const skillFile = path.join(skillsDir, dirName, 'SKILL.md'); - if (!fs.existsSync(skillFile)) { - return true; - } - } - } else { - for (const workflow of ALL_WORKFLOWS) { - const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; - if (!dirName) continue; - const skillDir = path.join(skillsDir, dirName); - if (fs.existsSync(skillDir)) { - return true; - } - } - } - - if (shouldGenerateCommands && adapter) { - for (const workflow of profileWorkflows) { - const cmdPath = adapter.getFilePath(workflow); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - if (!fs.existsSync(fullPath)) { - return true; - } - } - } else if (!shouldGenerateCommands && adapter) { - for (const workflow of ALL_WORKFLOWS) { - const cmdPath = adapter.getFilePath(workflow); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - if (fs.existsSync(fullPath)) { - return true; - } - } - } - - return false; - } - /** * Detects new tool directories that aren't currently configured and displays a hint. */ @@ -437,10 +323,16 @@ export class UpdateCommand { const newTools = availableTools.filter((t) => !configuredSet.has(t.value)); if (newTools.length > 0) { + const newToolNames = newTools.map((tool) => tool.name); + const isSingleTool = newToolNames.length === 1; + const toolNoun = isSingleTool ? 'tool' : 'tools'; + const pronoun = isSingleTool ? 'it' : 'them'; console.log(); - for (const tool of newTools) { - console.log(chalk.yellow(`Detected new tool: ${tool.name}. Run 'openspec init' to add it.`)); - } + console.log( + chalk.yellow( + `Detected new ${toolNoun}: ${newToolNames.join(', ')}. Run 'openspec init' to add ${pronoun}.` + ) + ); } } @@ -521,7 +413,11 @@ export class UpdateCommand { * Unlike init, update warns but continues if legacy files found in non-interactive mode. * Returns array of tool IDs that were newly configured during legacy upgrade. */ - private async handleLegacyCleanup(projectPath: string): Promise { + private async handleLegacyCleanup( + projectPath: string, + desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][], + delivery: Delivery + ): Promise { // Detect legacy artifacts const detection = await detectLegacyArtifacts(projectPath); @@ -540,7 +436,7 @@ export class UpdateCommand { // --force flag: proceed with cleanup automatically await this.performLegacyCleanup(projectPath, detection); // Then upgrade legacy tools to new skills - return this.upgradeLegacyTools(projectPath, detection, canPrompt); + return this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery); } if (!canPrompt) { @@ -561,7 +457,7 @@ export class UpdateCommand { if (shouldCleanup) { await this.performLegacyCleanup(projectPath, detection); // Then upgrade legacy tools to new skills - return this.upgradeLegacyTools(projectPath, detection, canPrompt); + return this.upgradeLegacyTools(projectPath, detection, canPrompt, desiredWorkflows, delivery); } else { console.log(chalk.dim('Skipping legacy cleanup. Continuing with skill update...')); console.log(); @@ -595,7 +491,9 @@ export class UpdateCommand { private async upgradeLegacyTools( projectPath: string, detection: LegacyDetectionResult, - canPrompt: boolean + canPrompt: boolean, + desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][], + delivery: Delivery ): Promise { // Get tools that had legacy artifacts const legacyTools = getToolsFromLegacyArtifacts(detection); @@ -605,7 +503,7 @@ export class UpdateCommand { } // Get currently configured tools - const configuredTools = this.getConfiguredToolsForUpdate(projectPath); + const configuredTools = getConfiguredToolsForProfileSync(projectPath); const configuredSet = new Set(configuredTools); // Filter to tools that aren't already configured @@ -665,10 +563,12 @@ export class UpdateCommand { } } - // Create skills for selected tools + // Create skills/commands for selected tools using effective profile+delivery. const newlyConfigured: string[] = []; - const skillTemplates = getSkillTemplates(); - const commandContents = getCommandContents(); + const shouldGenerateSkills = delivery !== 'commands'; + const shouldGenerateCommands = delivery !== 'skills'; + const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : []; + const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : []; for (const toolId of selectedTools) { const tool = AI_TOOLS.find((t) => t.value === toolId); @@ -679,25 +579,29 @@ export class UpdateCommand { try { const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); - // Create skill files - for (const { template, dirName } of skillTemplates) { - const skillDir = path.join(skillsDir, dirName); - const skillFile = path.join(skillDir, 'SKILL.md'); + // Create skill files when delivery includes skills + if (shouldGenerateSkills) { + for (const { template, dirName } of skillTemplates) { + const skillDir = path.join(skillsDir, dirName); + const skillFile = path.join(skillDir, 'SKILL.md'); - // Use hyphen-based command references for OpenCode - const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined; - const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); - await FileSystemUtils.writeFile(skillFile, skillContent); + // Use hyphen-based command references for OpenCode + const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined; + const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); + await FileSystemUtils.writeFile(skillFile, skillContent); + } } - // Create commands - const adapter = CommandAdapterRegistry.get(tool.value); - if (adapter) { - const generatedCommands = generateCommands(commandContents, adapter); + // Create commands when delivery includes commands + if (shouldGenerateCommands) { + const adapter = CommandAdapterRegistry.get(tool.value); + if (adapter) { + const generatedCommands = generateCommands(commandContents, adapter); - for (const cmd of generatedCommands) { - const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path); - await FileSystemUtils.writeFile(commandFile, cmd.fileContent); + for (const cmd of generatedCommands) { + const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path); + await FileSystemUtils.writeFile(commandFile, cmd.fileContent); + } } } diff --git a/src/prompts/searchable-multi-select.ts b/src/prompts/searchable-multi-select.ts index b0569b8e1..f4de429c0 100644 --- a/src/prompts/searchable-multi-select.ts +++ b/src/prompts/searchable-multi-select.ts @@ -5,6 +5,7 @@ interface Choice { value: string; description?: string; configured?: boolean; + detected?: boolean; configuredLabel?: string; preSelected?: boolean; } @@ -175,9 +176,16 @@ async function createSearchableMultiSelect(): Promise< const arrow = isActive ? chalk.cyan('›') : ' '; const name = isActive ? chalk.cyan(item.name) : item.name; const isRefresh = selected && item.configured; + const statusLabel = !selected + ? item.configured + ? ' (configured)' + : item.detected + ? ' (detected)' + : '' + : ''; const suffix = selected ? chalk.dim(isRefresh ? ' (refresh)' : ' (selected)') - : ''; + : chalk.dim(statusLabel); lines.push(` ${arrow} ${icon} ${name}${suffix}`); } diff --git a/test/commands/config-profile.test.ts b/test/commands/config-profile.test.ts new file mode 100644 index 000000000..af78fd6e8 --- /dev/null +++ b/test/commands/config-profile.test.ts @@ -0,0 +1,382 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Command } from 'commander'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +vi.mock('@inquirer/prompts', () => ({ + select: vi.fn(), + checkbox: vi.fn(), + confirm: vi.fn(), +})); + +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]); +} + +async function getPromptMocks(): Promise<{ + select: ReturnType; + checkbox: ReturnType; + confirm: ReturnType; +}> { + const prompts = await import('@inquirer/prompts'); + return { + select: prompts.select as unknown as ReturnType, + checkbox: prompts.checkbox as unknown as ReturnType, + confirm: prompts.confirm as unknown as ReturnType, + }; +} + +describe('diffProfileState workflow formatting', () => { + it('uses explicit "removed" wording when workflows are deleted', async () => { + const { diffProfileState } = await import('../../src/commands/config.js'); + + const diff = diffProfileState( + { profile: 'custom', delivery: 'both', workflows: ['propose', 'sync'] }, + { profile: 'custom', delivery: 'both', workflows: ['propose'] }, + ); + + expect(diff.hasChanges).toBe(true); + expect(diff.lines).toEqual(['workflows: removed sync']); + }); + + it('uses explicit labels when workflows are added and removed', async () => { + const { diffProfileState } = await import('../../src/commands/config.js'); + + const diff = diffProfileState( + { profile: 'custom', delivery: 'both', workflows: ['propose', 'sync'] }, + { profile: 'custom', delivery: 'both', workflows: ['propose', 'verify'] }, + ); + + expect(diff.hasChanges).toBe(true); + expect(diff.lines).toEqual(['workflows: added verify; removed sync']); + }); +}); + +describe('config profile interactive flow', () => { + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + let originalCwd: string; + let originalTTY: boolean | undefined; + let originalExitCode: number | undefined; + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + function setupDriftedProjectArtifacts(projectDir: string): void { + fs.mkdirSync(path.join(projectDir, 'openspec'), { recursive: true }); + const exploreSkillPath = path.join(projectDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'); + fs.mkdirSync(path.dirname(exploreSkillPath), { recursive: true }); + fs.writeFileSync(exploreSkillPath, 'name: openspec-explore\n', 'utf-8'); + } + + function setupSyncedCoreBothArtifacts(projectDir: string): void { + fs.mkdirSync(path.join(projectDir, 'openspec'), { recursive: true }); + const coreSkillDirs = [ + 'openspec-propose', + 'openspec-explore', + 'openspec-apply-change', + 'openspec-archive-change', + ]; + for (const dirName of coreSkillDirs) { + const skillPath = path.join(projectDir, '.claude', 'skills', dirName, 'SKILL.md'); + fs.mkdirSync(path.dirname(skillPath), { recursive: true }); + fs.writeFileSync(skillPath, `name: ${dirName}\n`, 'utf-8'); + } + + const coreCommands = ['propose', 'explore', 'apply', 'archive']; + for (const commandId of coreCommands) { + const commandPath = path.join(projectDir, '.claude', 'commands', 'opsx', `${commandId}.md`); + fs.mkdirSync(path.dirname(commandPath), { recursive: true }); + fs.writeFileSync(commandPath, `# ${commandId}\n`, 'utf-8'); + } + } + + function addExtraSyncWorkflowArtifacts(projectDir: string): void { + const syncSkillPath = path.join(projectDir, '.claude', 'skills', 'openspec-sync-specs', 'SKILL.md'); + fs.mkdirSync(path.dirname(syncSkillPath), { recursive: true }); + fs.writeFileSync(syncSkillPath, 'name: openspec-sync-specs\n', 'utf-8'); + + const syncCommandPath = path.join(projectDir, '.claude', 'commands', 'opsx', 'sync.md'); + fs.mkdirSync(path.dirname(syncCommandPath), { recursive: true }); + fs.writeFileSync(syncCommandPath, '# sync\n', 'utf-8'); + } + + beforeEach(() => { + vi.resetModules(); + + tempDir = path.join(os.tmpdir(), `openspec-config-profile-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); + + originalEnv = { ...process.env }; + originalCwd = process.cwd(); + originalTTY = (process.stdout as NodeJS.WriteStream & { isTTY?: boolean }).isTTY; + originalExitCode = process.exitCode; + + process.env.XDG_CONFIG_HOME = tempDir; + process.chdir(tempDir); + (process.stdout as NodeJS.WriteStream & { isTTY?: boolean }).isTTY = true; + process.exitCode = undefined; + + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = originalEnv; + process.chdir(originalCwd); + (process.stdout as NodeJS.WriteStream & { isTTY?: boolean }).isTTY = originalTTY; + process.exitCode = originalExitCode; + fs.rmSync(tempDir, { recursive: true, force: true }); + + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + vi.clearAllMocks(); + }); + + it('delivery-only action should not invoke workflow checkbox prompt', async () => { + const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); + const { select, checkbox } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + select.mockResolvedValueOnce('delivery'); + select.mockResolvedValueOnce('skills'); + + await runConfigCommand(['profile']); + + expect(checkbox).not.toHaveBeenCalled(); + expect(select).toHaveBeenCalledTimes(2); + expect(getGlobalConfig().delivery).toBe('skills'); + }); + + it('action picker should use configure wording and describe each path', async () => { + const { saveGlobalConfig } = await import('../../src/core/global-config.js'); + const { select } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + select.mockResolvedValueOnce('keep'); + + await runConfigCommand(['profile']); + + const firstCall = select.mock.calls[0][0]; + expect(firstCall.message).toBe('What do you want to configure?'); + expect(firstCall.choices).toEqual(expect.arrayContaining([ + expect.objectContaining({ + value: 'delivery', + description: 'Change where workflows are installed', + }), + expect.objectContaining({ + value: 'workflows', + description: 'Change which workflow actions are available', + }), + expect.objectContaining({ + value: 'keep', + name: 'Keep current settings (exit)', + }), + ])); + }); + + it('workflows-only action should not invoke delivery prompt', async () => { + const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); + const { ALL_WORKFLOWS } = await import('../../src/core/profiles.js'); + const { select, checkbox } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + select.mockResolvedValueOnce('workflows'); + checkbox.mockResolvedValueOnce(['propose', 'explore']); + + await runConfigCommand(['profile']); + + expect(select).toHaveBeenCalledTimes(1); + expect(checkbox).toHaveBeenCalledTimes(1); + const checkboxCall = checkbox.mock.calls[0][0]; + expect(checkboxCall.pageSize).toBe(ALL_WORKFLOWS.length); + expect(checkboxCall.theme).toEqual({ + icon: { + checked: '[x]', + unchecked: '[ ]', + }, + }); + const proposeChoice = checkboxCall.choices.find((choice: { value: string }) => choice.value === 'propose'); + const onboardChoice = checkboxCall.choices.find((choice: { value: string }) => choice.value === 'onboard'); + expect(proposeChoice.checked).toBe(true); + expect(onboardChoice.checked).toBe(false); + expect(getGlobalConfig().workflows).toEqual(['propose', 'explore']); + }); + + it('delivery picker should mark current option inline', async () => { + const { saveGlobalConfig } = await import('../../src/core/global-config.js'); + const { select } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'custom', delivery: 'commands', workflows: ['explore'] }); + select.mockResolvedValueOnce('delivery'); + select.mockResolvedValueOnce('commands'); + + await runConfigCommand(['profile']); + + expect(select).toHaveBeenCalledTimes(2); + const secondCall = select.mock.calls[1][0]; + expect(secondCall.choices).toEqual(expect.arrayContaining([ + expect.objectContaining({ value: 'commands', name: 'Commands only [current]' }), + ])); + }); + + it('workflow picker should use friendly names with descriptions', async () => { + const { saveGlobalConfig } = await import('../../src/core/global-config.js'); + const { select, checkbox } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + select.mockResolvedValueOnce('workflows'); + checkbox.mockResolvedValueOnce(['propose', 'explore', 'apply', 'archive']); + + await runConfigCommand(['profile']); + + const checkboxCall = checkbox.mock.calls[0][0]; + expect(checkboxCall.message).toBe('Select workflows to make available:'); + expect(checkboxCall.choices).toEqual(expect.arrayContaining([ + expect.objectContaining({ + value: 'propose', + name: 'Propose change', + description: 'Create proposal, design, and tasks from a request', + }), + expect.objectContaining({ + value: 'verify', + name: 'Verify change', + description: 'Run verification checks against a change', + }), + ])); + }); + + it('selecting current values only should be a no-op and should not ask apply', async () => { + const { saveGlobalConfig, getGlobalConfigPath } = await import('../../src/core/global-config.js'); + const { select, confirm } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + const configPath = getGlobalConfigPath(); + const beforeContent = fs.readFileSync(configPath, 'utf-8'); + + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + select.mockResolvedValueOnce('delivery'); + select.mockResolvedValueOnce('both'); + + await runConfigCommand(['profile']); + + const afterContent = fs.readFileSync(configPath, 'utf-8'); + expect(afterContent).toBe(beforeContent); + expect(confirm).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith('No config changes.'); + }); + + it('keep action should warn when project files drift from global config', async () => { + const { saveGlobalConfig } = await import('../../src/core/global-config.js'); + const { select } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + setupDriftedProjectArtifacts(tempDir); + select.mockResolvedValueOnce('keep'); + + await runConfigCommand(['profile']); + + expect(consoleLogSpy).toHaveBeenCalledWith('No config changes.'); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Warning: Global config is not applied to this project.')); + }); + + it('keep action should not warn when project files are already synced', async () => { + const { saveGlobalConfig } = await import('../../src/core/global-config.js'); + const { select } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + setupSyncedCoreBothArtifacts(tempDir); + select.mockResolvedValueOnce('keep'); + + 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); + }); + + it('effective no-op after prompts should warn when project files drift', async () => { + const { saveGlobalConfig } = await import('../../src/core/global-config.js'); + const { select, confirm } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + setupDriftedProjectArtifacts(tempDir); + select.mockResolvedValueOnce('delivery'); + select.mockResolvedValueOnce('both'); + + await runConfigCommand(['profile']); + + expect(consoleLogSpy).toHaveBeenCalledWith('No config changes.'); + expect(confirm).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Warning: Global config is not applied to this project.')); + }); + + it('keep action should warn when project has extra workflows beyond global config', async () => { + const { saveGlobalConfig } = await import('../../src/core/global-config.js'); + const { select } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + setupSyncedCoreBothArtifacts(tempDir); + addExtraSyncWorkflowArtifacts(tempDir); + select.mockResolvedValueOnce('keep'); + + await runConfigCommand(['profile']); + + expect(consoleLogSpy).toHaveBeenCalledWith('No config changes.'); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Warning: Global config is not applied to this project.')); + }); + + it('changed config should save and ask apply when inside project', async () => { + const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); + const { select, confirm } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'core', delivery: 'both', workflows: ['propose', 'explore', 'apply', 'archive'] }); + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + + select.mockResolvedValueOnce('delivery'); + select.mockResolvedValueOnce('skills'); + confirm.mockResolvedValueOnce(false); + + await runConfigCommand(['profile']); + + expect(getGlobalConfig().delivery).toBe('skills'); + expect(confirm).toHaveBeenCalledWith({ + message: 'Apply changes to this project now?', + default: true, + }); + }); + + it('core preset should preserve delivery setting', async () => { + const { saveGlobalConfig, getGlobalConfig } = await import('../../src/core/global-config.js'); + const { select, checkbox, confirm } = await getPromptMocks(); + + saveGlobalConfig({ featureFlags: {}, profile: 'custom', delivery: 'skills', workflows: ['explore'] }); + + await runConfigCommand(['profile', 'core']); + + const config = getGlobalConfig(); + expect(config.profile).toBe('core'); + expect(config.delivery).toBe('skills'); + expect(config.workflows).toEqual(['propose', 'explore', 'apply', 'archive']); + expect(select).not.toHaveBeenCalled(); + expect(checkbox).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + }); + + 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'); + cancellationError.name = 'ExitPromptError'; + + select.mockRejectedValueOnce(cancellationError); + + await expect(runConfigCommand(['profile'])).resolves.toBeUndefined(); + + expect(consoleLogSpy).toHaveBeenCalledWith('Config profile cancelled.'); + expect(process.exitCode).toBe(130); + expect(checkbox).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + }); +}); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index f8882a6b3..0f6fb42b7 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -5,6 +5,24 @@ import os from 'os'; import { InitCommand } from '../../src/core/init.js'; import { saveGlobalConfig, getGlobalConfig } from '../../src/core/global-config.js'; +const { confirmMock, showWelcomeScreenMock, searchableMultiSelectMock } = vi.hoisted(() => ({ + confirmMock: vi.fn(), + showWelcomeScreenMock: vi.fn().mockResolvedValue(undefined), + searchableMultiSelectMock: vi.fn(), +})); + +vi.mock('@inquirer/prompts', () => ({ + confirm: confirmMock, +})); + +vi.mock('../../src/ui/welcome-screen.js', () => ({ + showWelcomeScreen: showWelcomeScreenMock, +})); + +vi.mock('../../src/prompts/searchable-multi-select.js', () => ({ + searchableMultiSelect: searchableMultiSelectMock, +})); + describe('InitCommand', () => { let testDir: string; let configTempDir: string; @@ -21,6 +39,10 @@ describe('InitCommand', () => { // Mock console.log to suppress output during tests vi.spyOn(console, 'log').mockImplementation(() => { }); + confirmMock.mockReset(); + confirmMock.mockResolvedValue(true); + showWelcomeScreenMock.mockClear(); + searchableMultiSelectMock.mockReset(); }); afterEach(async () => { @@ -455,6 +477,10 @@ describe('InitCommand - profile and detection features', () => { await fs.mkdir(configTempDir, { recursive: true }); process.env.XDG_CONFIG_HOME = configTempDir; vi.spyOn(console, 'log').mockImplementation(() => {}); + confirmMock.mockReset(); + confirmMock.mockResolvedValue(true); + showWelcomeScreenMock.mockClear(); + searchableMultiSelectMock.mockReset(); }); afterEach(async () => { @@ -510,6 +536,54 @@ describe('InitCommand - profile and detection features', () => { expect(await fileExists(skillFile)).toBe(true); }); + it('should preselect configured tools but not directory-detected tools in extend mode', async () => { + // Simulate existing OpenSpec project (extend mode). + await fs.mkdir(path.join(testDir, 'openspec'), { recursive: true }); + + // Configured with OpenSpec + const claudeSkillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore'); + await fs.mkdir(claudeSkillDir, { recursive: true }); + await fs.writeFile(path.join(claudeSkillDir, 'SKILL.md'), 'configured'); + + // Directory detected only (not configured with OpenSpec) + await fs.mkdir(path.join(testDir, '.github'), { recursive: true }); + + searchableMultiSelectMock.mockResolvedValue(['claude']); + + const initCommand = new InitCommand({ force: true }); + vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true); + + await initCommand.execute(testDir); + + expect(searchableMultiSelectMock).toHaveBeenCalledTimes(1); + const [{ choices }] = searchableMultiSelectMock.mock.calls[0] as [{ choices: Array<{ value: string; preSelected?: boolean; detected?: boolean }> }]; + + const claude = choices.find((choice) => choice.value === 'claude'); + const githubCopilot = choices.find((choice) => choice.value === 'github-copilot'); + + expect(claude?.preSelected).toBe(true); + expect(githubCopilot?.preSelected).toBe(false); + expect(githubCopilot?.detected).toBe(true); + }); + + it('should preselect detected tools for first-time interactive setup', async () => { + // First-time init: no openspec/ directory and no configured OpenSpec skills. + await fs.mkdir(path.join(testDir, '.github'), { recursive: true }); + + searchableMultiSelectMock.mockResolvedValue(['github-copilot']); + + const initCommand = new InitCommand({ force: true }); + vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true); + + await initCommand.execute(testDir); + + expect(searchableMultiSelectMock).toHaveBeenCalledTimes(1); + const [{ choices }] = searchableMultiSelectMock.mock.calls[0] as [{ choices: Array<{ value: string; preSelected?: boolean }> }]; + const githubCopilot = choices.find((choice) => choice.value === 'github-copilot'); + + expect(githubCopilot?.preSelected).toBe(true); + }); + it('should respect custom profile from global config', async () => { saveGlobalConfig({ featureFlags: {}, @@ -532,6 +606,56 @@ describe('InitCommand - profile and detection features', () => { expect(await fileExists(proposeSkill)).toBe(false); }); + it('should migrate commands-only extend mode to custom profile without injecting propose', async () => { + await fs.mkdir(path.join(testDir, 'openspec'), { recursive: true }); + await fs.mkdir(path.join(testDir, '.claude', 'commands', 'opsx'), { recursive: true }); + await fs.writeFile(path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md'), '# explore\n'); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const config = getGlobalConfig(); + expect(config.profile).toBe('custom'); + expect(config.delivery).toBe('commands'); + expect(config.workflows).toEqual(['explore']); + + const exploreCommand = path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md'); + const proposeCommand = path.join(testDir, '.claude', 'commands', 'opsx', 'propose.md'); + expect(await fileExists(exploreCommand)).toBe(true); + expect(await fileExists(proposeCommand)).toBe(false); + + const exploreSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'); + const proposeSkill = path.join(testDir, '.claude', 'skills', 'openspec-propose', 'SKILL.md'); + expect(await fileExists(exploreSkill)).toBe(false); + expect(await fileExists(proposeSkill)).toBe(false); + }); + + it('should not prompt for confirmation when applying custom profile in interactive init', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'both', + workflows: ['explore', 'new'], + }); + + const initCommand = new InitCommand({ force: true }); + vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true); + vi.spyOn(initCommand as any, 'getSelectedTools').mockResolvedValue(['claude']); + + await initCommand.execute(testDir); + + expect(showWelcomeScreenMock).toHaveBeenCalled(); + expect(confirmMock).not.toHaveBeenCalled(); + + const exploreSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'); + const newChangeSkill = path.join(testDir, '.claude', 'skills', 'openspec-new-change', 'SKILL.md'); + expect(await fileExists(exploreSkill)).toBe(true); + expect(await fileExists(newChangeSkill)).toBe(true); + + const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String); + expect(logCalls.some((entry) => entry.includes('Applying custom profile'))).toBe(false); + }); + it('should respect delivery=skills setting (no commands)', async () => { saveGlobalConfig({ featureFlags: {}, diff --git a/test/core/migration.test.ts b/test/core/migration.test.ts new file mode 100644 index 000000000..ed4746670 --- /dev/null +++ b/test/core/migration.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import fs from 'node:fs'; +import { promises as fsp } from 'node:fs'; +import { AI_TOOLS, type AIToolOption } from '../../src/core/config.js'; +import { CommandAdapterRegistry } from '../../src/core/command-generation/index.js'; +import { saveGlobalConfig, getGlobalConfigPath } from '../../src/core/global-config.js'; +import { migrateIfNeeded, scanInstalledWorkflows } from '../../src/core/migration.js'; + +const CLAUDE_TOOL = AI_TOOLS.find((tool) => tool.value === 'claude') as AIToolOption | undefined; + +function ensureClaudeTool(): AIToolOption { + if (!CLAUDE_TOOL) { + throw new Error('Claude tool definition not found'); + } + return CLAUDE_TOOL; +} + +async function writeSkill(projectPath: string, dirName: string): Promise { + const skillFile = path.join(projectPath, '.claude', 'skills', dirName, 'SKILL.md'); + await fsp.mkdir(path.dirname(skillFile), { recursive: true }); + await fsp.writeFile(skillFile, 'name: test\n', 'utf-8'); +} + +async function writeManagedCommand(projectPath: string, workflowId: string): Promise { + const adapter = CommandAdapterRegistry.get('claude'); + if (!adapter) { + throw new Error('Claude adapter not found'); + } + const commandPath = adapter.getFilePath(workflowId); + const fullPath = path.isAbsolute(commandPath) + ? commandPath + : path.join(projectPath, commandPath); + await fsp.mkdir(path.dirname(fullPath), { recursive: true }); + await fsp.writeFile(fullPath, '# command\n', 'utf-8'); +} + +function readRawConfig(): Record { + return JSON.parse(fs.readFileSync(getGlobalConfigPath(), 'utf-8')) as Record; +} + +describe('migration', () => { + let projectDir: string; + let configHome: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(async () => { + projectDir = path.join(os.tmpdir(), `openspec-migration-project-${randomUUID()}`); + configHome = path.join(os.tmpdir(), `openspec-migration-config-${randomUUID()}`); + await fsp.mkdir(projectDir, { recursive: true }); + await fsp.mkdir(configHome, { recursive: true }); + originalEnv = { ...process.env }; + process.env.XDG_CONFIG_HOME = configHome; + }); + + afterEach(async () => { + process.env = originalEnv; + await fsp.rm(projectDir, { recursive: true, force: true }); + await fsp.rm(configHome, { recursive: true, force: true }); + }); + + it('migrates to custom skills delivery when only managed skills are detected', async () => { + await writeSkill(projectDir, 'openspec-explore'); + await writeSkill(projectDir, 'openspec-apply-change'); + + migrateIfNeeded(projectDir, [ensureClaudeTool()]); + + const config = readRawConfig(); + expect(config.profile).toBe('custom'); + expect(config.delivery).toBe('skills'); + expect(config.workflows).toEqual(['explore', 'apply']); + }); + + it('migrates to custom commands delivery when only managed commands are detected', async () => { + await writeManagedCommand(projectDir, 'explore'); + await writeManagedCommand(projectDir, 'archive'); + + migrateIfNeeded(projectDir, [ensureClaudeTool()]); + + const config = readRawConfig(); + expect(config.profile).toBe('custom'); + expect(config.delivery).toBe('commands'); + expect(config.workflows).toEqual(['explore', 'archive']); + }); + + it('migrates to custom both delivery when managed skills and commands are detected', async () => { + await writeSkill(projectDir, 'openspec-explore'); + await writeManagedCommand(projectDir, 'apply'); + + migrateIfNeeded(projectDir, [ensureClaudeTool()]); + + const config = readRawConfig(); + expect(config.profile).toBe('custom'); + expect(config.delivery).toBe('both'); + expect(config.workflows).toEqual(['explore', 'apply']); + }); + + it('does not migrate when profile is already explicitly configured', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + await writeSkill(projectDir, 'openspec-explore'); + + migrateIfNeeded(projectDir, [ensureClaudeTool()]); + + const config = readRawConfig(); + expect(config.profile).toBe('core'); + expect(config.delivery).toBe('both'); + expect(config.workflows).toBeUndefined(); + }); + + it('does not migrate when no managed workflow artifacts are detected', async () => { + migrateIfNeeded(projectDir, [ensureClaudeTool()]); + + expect(fs.existsSync(getGlobalConfigPath())).toBe(false); + }); + + it('ignores unknown custom skill and command files when scanning workflows', async () => { + await writeSkill(projectDir, 'my-custom-skill'); + const customCommandPath = path.join(projectDir, '.claude', 'commands', 'opsx', 'my-custom.md'); + await fsp.mkdir(path.dirname(customCommandPath), { recursive: true }); + await fsp.writeFile(customCommandPath, '# custom\n', 'utf-8'); + + const workflows = scanInstalledWorkflows(projectDir, [ensureClaudeTool()]); + expect(workflows).toEqual([]); + + migrateIfNeeded(projectDir, [ensureClaudeTool()]); + expect(fs.existsSync(getGlobalConfigPath())).toBe(false); + }); +}); diff --git a/test/core/profile-sync-drift.test.ts b/test/core/profile-sync-drift.test.ts new file mode 100644 index 000000000..a911f06bb --- /dev/null +++ b/test/core/profile-sync-drift.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + hasProjectConfigDrift, + WORKFLOW_TO_SKILL_DIR, +} from '../../src/core/profile-sync-drift.js'; +import { CORE_WORKFLOWS } from '../../src/core/profiles.js'; +import { CommandAdapterRegistry } from '../../src/core/command-generation/index.js'; + +function writeSkill(projectDir: string, workflowId: string): void { + const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId as keyof typeof WORKFLOW_TO_SKILL_DIR]; + const skillPath = path.join(projectDir, '.claude', 'skills', skillDirName, 'SKILL.md'); + fs.mkdirSync(path.dirname(skillPath), { recursive: true }); + fs.writeFileSync(skillPath, `name: ${skillDirName}\n`); +} + +function writeCommand(projectDir: string, workflowId: string): void { + const adapter = CommandAdapterRegistry.get('claude'); + if (!adapter) throw new Error('Claude adapter unavailable in test environment'); + const cmdPath = adapter.getFilePath(workflowId); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectDir, cmdPath); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, `# ${workflowId}\n`); +} + +function setupCoreSkills(projectDir: string): void { + for (const workflow of CORE_WORKFLOWS) { + writeSkill(projectDir, workflow); + } +} + +function setupCoreCommands(projectDir: string): void { + for (const workflow of CORE_WORKFLOWS) { + writeCommand(projectDir, workflow); + } +} + +describe('profile sync drift detection', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = path.join(os.tmpdir(), `openspec-profile-sync-drift-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(path.join(tempDir, 'openspec'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('detects drift for skills-only delivery when commands still exist', () => { + setupCoreSkills(tempDir); + setupCoreCommands(tempDir); + + const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'skills'); + expect(hasDrift).toBe(true); + }); + + it('detects drift for commands-only delivery when skills still exist', () => { + setupCoreCommands(tempDir); + setupCoreSkills(tempDir); + + const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'commands'); + expect(hasDrift).toBe(true); + }); + + it('detects drift when required profile workflow files are missing', () => { + writeSkill(tempDir, 'explore'); + + const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'both'); + expect(hasDrift).toBe(true); + }); + + it('returns false when project files match core profile and delivery', () => { + setupCoreSkills(tempDir); + setupCoreCommands(tempDir); + + const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'both'); + expect(hasDrift).toBe(false); + }); + + it('detects drift when extra workflows are installed for both delivery', () => { + setupCoreSkills(tempDir); + setupCoreCommands(tempDir); + writeSkill(tempDir, 'sync'); + writeCommand(tempDir, 'sync'); + + const hasDrift = hasProjectConfigDrift(tempDir, CORE_WORKFLOWS, 'both'); + expect(hasDrift).toBe(true); + }); +}); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index c678e51c3..a916967c9 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1306,7 +1306,7 @@ More user content after markers. consoleSpy.mockRestore(); }); - it('should create core profile skills when upgrading legacy tools', async () => { + it('should create only effective profile skills when upgrading legacy tools', async () => { // Create legacy command directory await fs.mkdir(path.join(testDir, '.claude', 'commands', 'openspec'), { recursive: true }); await fs.writeFile( @@ -1318,17 +1318,12 @@ More user content after markers. const forceUpdateCommand = new UpdateCommand({ force: true }); await forceUpdateCommand.execute(testDir); - // Legacy upgrade uses unfiltered templates (all skills), verify all exist + // Default profile is core, so only core workflows should be generated. const skillNames = [ + 'openspec-propose', 'openspec-explore', - 'openspec-new-change', - 'openspec-continue-change', 'openspec-apply-change', - 'openspec-ff-change', - 'openspec-sync-specs', 'openspec-archive-change', - 'openspec-bulk-archive-change', - 'openspec-verify-change', ]; const skillsDir = path.join(testDir, '.claude', 'skills'); @@ -1337,6 +1332,9 @@ More user content after markers. const exists = await FileSystemUtils.fileExists(skillFile); expect(exists).toBe(true); } + + const nonCoreSkill = path.join(skillsDir, 'openspec-new-change', 'SKILL.md'); + expect(await FileSystemUtils.fileExists(nonCoreSkill)).toBe(false); }); it('should create commands when upgrading legacy tools', async () => { @@ -1357,6 +1355,40 @@ More user content after markers. const exists = await FileSystemUtils.fileExists(exploreCmd); expect(exists).toBe(true); }); + + it('should not inject non-profile workflows when upgrading legacy tools', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'both', + workflows: ['explore'], + }); + + await fs.mkdir(path.join(testDir, '.claude', 'commands', 'openspec'), { recursive: true }); + await fs.writeFile( + path.join(testDir, '.claude', 'commands', 'openspec', 'proposal.md'), + 'content' + ); + + const forceUpdateCommand = new UpdateCommand({ force: true }); + await forceUpdateCommand.execute(testDir); + + const skillsDir = path.join(testDir, '.claude', 'skills'); + 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); + + 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, 'propose.md') + )).toBe(false); + }); }); describe('profile-aware updates', () => { @@ -1443,6 +1475,32 @@ More user content after markers. )).toBe(false); }); + it('should remove skills for configured tools without command adapters in commands-only delivery', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const { AI_TOOLS } = await import('../../src/core/config.js'); + const { CommandAdapterRegistry } = await import('../../src/core/command-generation/index.js'); + const adapterlessTool = AI_TOOLS.find((tool) => tool.skillsDir && !CommandAdapterRegistry.get(tool.value)); + expect(adapterlessTool).toBeDefined(); + if (!adapterlessTool?.skillsDir) { + return; + } + + const skillsDir = path.join(testDir, adapterlessTool.skillsDir, 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + await expect(updateCommand.execute(testDir)).resolves.toBeUndefined(); + + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-explore', 'SKILL.md') + )).toBe(false); + }); + it('should apply config sync when templates are up to date', async () => { setMockConfig({ featureFlags: {}, @@ -1562,13 +1620,47 @@ content call.map(arg => String(arg)).join(' ') ); const hasNewToolMessage = calls.some(call => - call.includes('Detected new tool') && call.includes('Cursor') + call.includes("Detected new tool: Cursor. Run 'openspec init' to add it.") ); expect(hasNewToolMessage).toBe(true); consoleSpy.mockRestore(); }); + it('should consolidate multiple new tools into one message', async () => { + // Set up a configured Claude tool + const claudeSkillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(claudeSkillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(claudeSkillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + // Create two unconfigured tool directories + await fs.mkdir(path.join(testDir, '.github'), { recursive: true }); + await fs.mkdir(path.join(testDir, '.windsurf'), { recursive: true }); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const calls = consoleSpy.mock.calls.map(call => + call.map(arg => String(arg)).join(' ') + ); + + const consolidatedCalls = calls.filter(call => + call.includes('Detected new tools:') + ); + expect(consolidatedCalls).toHaveLength(1); + expect(consolidatedCalls[0]).toContain('GitHub Copilot'); + expect(consolidatedCalls[0]).toContain('Windsurf'); + expect(consolidatedCalls[0]).toContain("Run 'openspec init' to add them."); + + const repeatedSingularCalls = calls.filter(call => + call.includes('Detected new tool:') + ); + expect(repeatedSingularCalls).toHaveLength(0); + + consoleSpy.mockRestore(); + }); + it('should not show new tool message when no new tools detected', async () => { // Set up a configured tool (only Claude, no other tool directories) const skillsDir = path.join(testDir, '.claude', 'skills'); @@ -1636,6 +1728,15 @@ content const workflows = scanInstalledWorkflows(testDir, ['claude']); expect(workflows).toHaveLength(0); }); + + it('should detect installed workflows from managed command files', async () => { + const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); + await fs.mkdir(commandsDir, { recursive: true }); + await fs.writeFile(path.join(commandsDir, 'explore.md'), 'content'); + + const workflows = scanInstalledWorkflows(testDir, ['claude']); + expect(workflows).toContain('explore'); + }); }); describe('tools output', () => { From 2c2a099b6174a7d8fc1b490b07b7a323d865334d Mon Sep 17 00:00:00 2001 From: TabishB Date: Sat, 21 Feb 2026 05:46:19 -0800 Subject: [PATCH 2/2] Fix migration workflow preservation and add coverage --- src/core/init.ts | 3 ++- src/core/migration.ts | 21 +++------------------ src/core/profile-sync-drift.ts | 6 ++++++ test/commands/config-profile.test.ts | 17 +++++++++++++++++ test/core/migration.test.ts | 16 ++++++++++++++++ 5 files changed, 44 insertions(+), 19 deletions(-) diff --git a/src/core/init.ts b/src/core/init.ts index a718ed40b..cf72a5b6f 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -128,7 +128,8 @@ export class InitCommand { await showWelcomeScreen(); } - // Resolve profile override early so invalid values fail before tool setup. + // Validate profile override early so invalid values fail before tool setup. + // The resolved value is consumed later when generation reads effective config. this.resolveProfileOverride(); // Get tool states before processing diff --git a/src/core/migration.ts b/src/core/migration.ts index ca3b38df5..48aaa41ee 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -8,27 +8,11 @@ import type { AIToolOption } from './config.js'; import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } from './global-config.js'; import { CommandAdapterRegistry } from './command-generation/index.js'; +import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js'; import { ALL_WORKFLOWS } from './profiles.js'; import path from 'path'; import * as fs from 'fs'; -/** - * Maps workflow IDs to their skill directory names for scanning. - */ -const WORKFLOW_TO_SKILL_DIR: Record<(typeof ALL_WORKFLOWS)[number], string> = { - 'propose': 'openspec-propose', - 'explore': 'openspec-explore', - 'new': 'openspec-new-change', - 'continue': 'openspec-continue-change', - 'apply': 'openspec-apply-change', - 'ff': 'openspec-ff-change', - 'sync': 'openspec-sync-specs', - 'archive': 'openspec-archive-change', - 'bulk-archive': 'openspec-bulk-archive-change', - 'verify': 'openspec-verify-change', - 'onboard': 'openspec-onboard', -}; - interface InstalledWorkflowArtifacts { workflows: string[]; hasSkills: boolean; @@ -47,7 +31,8 @@ function scanInstalledWorkflowArtifacts( if (!tool.skillsDir) continue; const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); - for (const [workflowId, skillDirName] of Object.entries(WORKFLOW_TO_SKILL_DIR)) { + for (const workflowId of ALL_WORKFLOWS) { + const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId]; const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md'); if (fs.existsSync(skillFile)) { installed.add(workflowId); diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 13f2c946b..d615d818d 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -79,6 +79,12 @@ export function getConfiguredToolsForProfileSync(projectPath: string): string[] /** * Detects if a single tool has profile/delivery drift against the desired state. + * + * Note: this function is intentionally scoped to "required artifacts missing" + * and "artifacts that should not exist for the selected delivery mode". + * Extra workflows that are outside the desired profile are handled by + * `hasProjectConfigDrift`, which compares installed workflow IDs against + * the desired workflow set. */ export function hasToolProfileOrDeliveryDrift( projectPath: string, diff --git a/test/commands/config-profile.test.ts b/test/commands/config-profile.test.ts index af78fd6e8..ef116693a 100644 --- a/test/commands/config-profile.test.ts +++ b/test/commands/config-profile.test.ts @@ -56,6 +56,23 @@ describe('diffProfileState workflow formatting', () => { }); }); +describe('deriveProfileFromWorkflowSelection', () => { + it('returns custom for an empty workflow selection', async () => { + const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js'); + expect(deriveProfileFromWorkflowSelection([])).toBe('custom'); + }); + + it('returns custom when selection is a superset of core workflows', async () => { + const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js'); + expect(deriveProfileFromWorkflowSelection(['propose', 'explore', 'apply', 'archive', 'new'])).toBe('custom'); + }); + + it('returns core when selection has exactly core workflows in different order', async () => { + const { deriveProfileFromWorkflowSelection } = await import('../../src/commands/config.js'); + expect(deriveProfileFromWorkflowSelection(['archive', 'apply', 'explore', 'propose'])).toBe('core'); + }); +}); + describe('config profile interactive flow', () => { let tempDir: string; let originalEnv: NodeJS.ProcessEnv; diff --git a/test/core/migration.test.ts b/test/core/migration.test.ts index ed4746670..409206e94 100644 --- a/test/core/migration.test.ts +++ b/test/core/migration.test.ts @@ -113,6 +113,22 @@ describe('migration', () => { expect(config.workflows).toBeUndefined(); }); + it('preserves explicit delivery value during migration', async () => { + // Raw config has explicit delivery but no profile yet. + saveGlobalConfig({ + featureFlags: {}, + delivery: 'both', + }); + await writeSkill(projectDir, 'openspec-explore'); + + migrateIfNeeded(projectDir, [ensureClaudeTool()]); + + const config = readRawConfig(); + expect(config.profile).toBe('custom'); + expect(config.delivery).toBe('both'); + expect(config.workflows).toEqual(['explore']); + }); + it('does not migrate when no managed workflow artifacts are detected', async () => { migrateIfNeeded(projectDir, [ensureClaudeTool()]);