diff --git a/openspec/changes/add-qa-smoke-harness/.openspec.yaml b/openspec/changes/add-qa-smoke-harness/.openspec.yaml new file mode 100644 index 000000000..d0ec88b29 --- /dev/null +++ b/openspec/changes/add-qa-smoke-harness/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-20 diff --git a/openspec/changes/add-qa-smoke-harness/proposal.md b/openspec/changes/add-qa-smoke-harness/proposal.md new file mode 100644 index 000000000..a280eb41a --- /dev/null +++ b/openspec/changes/add-qa-smoke-harness/proposal.md @@ -0,0 +1,45 @@ +## Why + +We need a faster, more reliable way to manually validate CLI behavior changes like profile/delivery sync, migration behavior, and tool-detection UX. + +Today, manual review is mostly ad hoc: each developer sets up state differently, runs a different command order, and checks outputs informally. This makes regressions easy to miss and slows iteration on CLI UX work. + +An 80/20 solution is to add a lightweight smoke harness for deterministic non-interactive flows, plus a short manual checklist for interactive prompt behavior. + +## What Changes + +- Add a lightweight QA smoke harness for OpenSpec CLI behavior with isolated per-run sandbox state +- Use `Makefile` targets as the primary entrypoint: + - `make qa` (default local QA entrypoint) + - `make qa-smoke` (deterministic non-interactive suite) + - `make qa-interactive` (prints/opens manual interactive checklist) +- Implement smoke logic in a script (invoked by Make targets), not in Make itself +- Ensure each scenario runs in an isolated sandbox with temporary `HOME`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, and `CODEX_HOME` +- Capture scenario artifacts for inspection (command output, exit code, and before/after filesystem state) +- Add a focused scenario set for high-risk behavior: + - init core output generation + - non-interactive detected-tool behavior + - migration when profile is unset + - delivery cleanup (`both -> skills`, `both -> commands`) + - commands-only update detection + - new tool directory detection messaging + - invalid profile override validation +- Add a short interactive checklist for keypress/prompt UX verification (Space toggle, Enter confirm, detected pre-selection) +- Wire CI to run the smoke suite on Linux as a fast regression gate + +## Capabilities + +### New Capabilities + +- `qa-smoke-harness`: Deterministic, sandboxed CLI smoke validation with a single developer entrypoint + +### Modified Capabilities + +- `developer-qa-workflow`: Standardized local/CI QA flow for CLI behavior and migration-sensitive scenarios + +## Impact + +- `Makefile` - Add `qa`, `qa-smoke`, and `qa-interactive` targets +- `scripts/qa-smoke.sh` (or equivalent) - Implement sandbox setup, scenario execution, and assertions +- `docs/` - Add/update contributor-facing QA instructions and interactive checklist usage +- CI workflow - Add smoke target execution as a lightweight regression gate diff --git a/openspec/changes/add-qa-smoke-harness/specs/developer-qa-workflow/spec.md b/openspec/changes/add-qa-smoke-harness/specs/developer-qa-workflow/spec.md new file mode 100644 index 000000000..30ee529d5 --- /dev/null +++ b/openspec/changes/add-qa-smoke-harness/specs/developer-qa-workflow/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: Makefile QA Entry Point + +The repository SHALL provide Makefile targets as the primary developer entrypoint for CLI QA flows. + +#### Scenario: Default QA target runs smoke suite + +- **WHEN** a developer runs `make qa` +- **THEN** the command SHALL execute the non-interactive smoke suite +- **AND** exit with status code 0 only when all smoke scenarios pass + +#### Scenario: Smoke suite target is directly invokable + +- **WHEN** a developer runs `make qa-smoke` +- **THEN** the command SHALL execute the same smoke suite used by `make qa` +- **AND** return a non-zero exit code on assertion failure + +#### Scenario: Interactive checklist target exists + +- **WHEN** a developer runs `make qa-interactive` +- **THEN** the command SHALL provide the manual interactive verification checklist +- **AND** SHALL NOT run interactive prompt automation by default + +### Requirement: Sandboxed Smoke Scenario Runner + +The smoke suite SHALL run CLI scenarios in isolated sandboxes so tests are repeatable and do not depend on machine-global state. + +#### Scenario: Scenario execution is environment-isolated + +- **WHEN** a smoke scenario runs +- **THEN** it SHALL use temporary values for `HOME`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, and `CODEX_HOME` +- **AND** global config from the host machine SHALL NOT affect scenario outcomes + +#### Scenario: Scenario artifacts are captured for review + +- **WHEN** a smoke scenario completes +- **THEN** the runner SHALL capture command output and exit status +- **AND** SHALL capture enough filesystem state to inspect before/after behavior + +#### Scenario: High-risk workflow coverage exists + +- **WHEN** the smoke suite executes +- **THEN** it SHALL include scenarios covering profile/delivery behavior and migration-sensitive flows +- **AND** include at least: + - non-interactive tool detection + - migration when profile is unset + - delivery cleanup (`both -> skills`, `both -> commands`) + - commands-only update detection diff --git a/openspec/changes/simplify-skill-installation/design.md b/openspec/changes/simplify-skill-installation/design.md index f302b7428..a51ea9193 100644 --- a/openspec/changes/simplify-skill-installation/design.md +++ b/openspec/changes/simplify-skill-installation/design.md @@ -132,10 +132,76 @@ What's installed in `.claude/skills/` (etc.) is the source of truth, not config. - Extra workflows (not in profile) are preserved - Delivery changes are applied: switching to `skills` removes commands, switching to `commands` removes skills +**Why not a separate tool manifest?** + +Tool selection (which assistants a project uses) is per-user AND per-project, but the two config locations are per-user-only (global config) or per-project-shared (checked-in project config). A separate manifest was explored and rejected: + +- *Path-keyed global config* (`projects: { "/path": { tools: [...] } }`): Fragile on directory move/rename/delete, symlink ambiguity, and project behavior depends on invisible external state. +- *Gitignored local file* (`.openspec.local`): Lost on fresh clone, adds file management overhead. +- *Checked-in project config* (`openspec/config.yaml` with `tools` field): Forces tool choices on the whole team — Alice uses Claude Code, Bob uses Cursor, neither wants the other's tools mandated. + +The filesystem approach avoids all three problems. For teams, it's actually beneficial: checked-in skill files mean `openspec update` from any team member refreshes skills for all tools the project supports. The generated files serve as both the deliverable and the implicit tool manifest. + +Known gap: a tool that stores config outside the project tree (no local directory to scan) would need tool-specific handling, since there's nothing in the project to scan. Address if/when such a tool is supported. + **When to use init vs update:** - `init`: First time setup, or when you want to change which tools are configured - `update`: After changing config, or to refresh templates to latest version +### 8. Existing User Migration + +When `openspec init` or `openspec update` encounters a project with existing workflows but no `profile` field in global config, it performs a one-time migration to preserve the user's current setup. + +**Rationale:** Without migration, existing users would default to `core` profile, causing `propose` to be added on top of their 10 workflows — making things worse, not better. Migration ensures existing users keep exactly what they have. + +**Triggered by:** Both `init` (re-init on existing project) and `update`. The migration check is a shared function called early in both commands, before profile resolution. + +**Detection logic:** +```typescript +// Shared migration check, called by both init and update: +function migrateIfNeeded(projectPath: string, tools: AiTool[]): void { + const globalConfig = readGlobalConfig(); + if (globalConfig.profile) return; // already migrated or explicitly set + + const installedWorkflows = scanInstalledWorkflows(projectPath, tools); + if (installedWorkflows.length === 0) return; // new user, use core defaults + + // Existing user — migrate to custom profile + writeGlobalConfig({ + ...globalConfig, + profile: 'custom', + delivery: 'both', + workflows: installedWorkflows, + }); +} +``` + +**Scanning logic:** +- Scan all tool directories (`.claude/skills/`, `.cursor/skills/`, etc.) for workflow directories/files +- Match only against `ALL_WORKFLOWS` constant — ignore user-created custom skills/commands +- Map directory names back to workflow IDs (e.g., `openspec-explore/` → `explore`, `opsx-explore.md` → `explore`) +- Take the union of detected workflow names across all tools + +**Edge cases:** +- **User manually deleted some workflows:** Migration scans what's actually installed, respecting their choices +- **Multiple projects with different workflow sets:** First project to trigger migration sets global config; subsequent projects use it +- **User has custom (non-OpenSpec) skills in the directory:** Ignored — scanner only matches known workflow IDs from `ALL_WORKFLOWS` +- **Migration is idempotent:** If `profile` is already set in config, no re-migration occurs +- **Non-interactive (CI):** Same migration logic, no confirmation needed — it's preserving existing state + +**Alternatives considered:** +- Migrate during `init` instead of `update`: Init already has its own flow (tool selection, etc.). Mixing migration with init creates confusing UX +- Don't migrate, just default to core: Breaks existing users by adding `propose` and showing "extra workflows" warnings +- Migrate at global config read time: Too implicit, hard to show feedback to user + +### 9. Generic Next-Step Guidance in Templates + +Workflow templates use generic, concept-based next-step guidance rather than referencing specific workflow commands. For example, instead of "run `/opsx:propose`", templates say "create a change proposal". + +**Rationale:** Conditional cross-referencing (where each template checks which other workflows are installed and renders different command names) adds significant complexity to template generation, testing, and maintenance. Generic guidance avoids this entirely while still being useful — users already know their installed workflows. + +**Note:** If we find that users consistently struggle to map concepts to commands, we can revisit this with conditional cross-references. For now, simplicity wins. + ### 7. Fix Multi-Select Keybindings Change from tab-to-confirm to industry-standard space/enter. @@ -144,6 +210,35 @@ Change from tab-to-confirm to industry-standard space/enter. **Implementation:** Modify `src/prompts/searchable-multi-select.ts` keybinding configuration. +### 10. Update Sync Must Consider Config Drift, Not Just Version Drift + +`openspec update` cannot rely only on `generatedBy` version checks for deciding whether work is needed. + +**Rationale:** profile and delivery changes can require file add/remove operations even when existing skill templates are current. If we only check template versions, update may incorrectly return "up to date" and skip required sync. + +**Implementation:** +- Keep version checks for template refresh decisions +- Add file-state drift checks for profile/delivery (missing expected files or stale files from removed delivery mode) +- Treat either version drift OR config drift as update-required + +### 11. Tool Configuration Detection Includes Commands-Only Installs + +Configured-tool detection for update must include command files, not only skill files. + +**Rationale:** with `delivery: commands`, a project can be fully configured without skill files. Skill-only detection incorrectly reports "No configured tools found." + +**Implementation:** +- For update flows, treat a tool as configured if it has either generated skills or generated commands +- Keep migration workflow scanning behavior unchanged (skills remain the migration source of truth) + +### 12. Init Profile Override Is Strictly Validated + +`openspec init --profile` must validate allowed values before proceeding. + +**Rationale:** silently accepting unknown profile values hides user errors and produces implicit fallback behavior. + +**Implementation:** accept only `core` and `custom`; throw a clear CLI error for invalid values. + ## Risks / Trade-offs **Risk: Breaking existing user workflows** diff --git a/openspec/changes/simplify-skill-installation/proposal.md b/openspec/changes/simplify-skill-installation/proposal.md index 0cff8eb3a..a8c60903b 100644 --- a/openspec/changes/simplify-skill-installation/proposal.md +++ b/openspec/changes/simplify-skill-installation/proposal.md @@ -127,30 +127,43 @@ Workflows: (space to toggle, enter to save) [ ] onboard ``` -### 8. Backwards Compatibility - -- Existing users with all workflows keep them (extra workflows not in profile are preserved) -- `openspec init` sets up new projects using current profile config -- `openspec update` applies config changes to existing projects (adds missing workflows, refreshes templates) +### 8. Backwards Compatibility & Migration + +**Existing users keep their current setup.** When `openspec update` runs on a project with existing workflows and no `profile` in global config, it performs a one-time migration: + +1. Scans installed workflow files across all tool directories in the project +2. Writes `profile: "custom"`, `delivery: "both"`, `workflows: []` to global config +3. Refreshes templates but does NOT add or remove any workflows +4. Displays: "Migrated: custom profile with N existing workflows" + +After migration, subsequent `init` and `update` commands respect the migrated config. + +**Key behaviors:** +- 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` 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` +- Workflow templates conditionally reference only installed workflows in "next steps" guidance - Delivery changes are applied: switching to `skills` removes command files, switching to `commands` removes skill files +- Re-running `init` applies delivery cleanup on existing projects (removes files that no longer match delivery) +- `update` treats profile/delivery drift as update-required even when template versions are already current +- `update` treats command-only installs as configured tools - All workflows remain available via custom profile ## Capabilities ### New Capabilities -- `profiles`: Support for workflow profiles (core, custom) with interactive configuration -- `delivery-config`: User preference for delivery method (skills, commands, both) +- `profiles`: Workflow profiles (core, custom), delivery preferences, global config storage, interactive picker - `propose-workflow`: Combined workflow that creates change + generates all artifacts -- `user-config`: Extend existing global config with profile/delivery settings -- `available-tools`: Detect what AI tools the user has from existing directories ### Modified Capabilities -- `cli-init`: Smart defaults with auto-detection and confirmation -- `tool-selection-ux`: Space to select, Enter to confirm -- `skill-generation`: Conditional based on profile and delivery settings -- `command-generation`: Conditional based on profile and delivery settings +- `cli-init`: Smart defaults with tool auto-detection, profile-based skill/command generation +- `cli-update`: Profile support, delivery changes, one-time migration for existing users ## Impact @@ -166,7 +179,7 @@ Workflows: (space to toggle, enter to save) - `src/core/shared/skill-generation.ts` - Filter by profile, respect delivery - `src/core/shared/tool-detection.ts` - Update SKILL_NAMES and COMMAND_IDS to include propose - `src/commands/config.ts` - Add `profile` subcommand with interactive picker -- `src/commands/update.ts` - Add profile/delivery support, file deletion for delivery changes +- `src/core/update.ts` - Add profile/delivery support, file deletion for delivery changes - `src/prompts/searchable-multi-select.ts` - Fix keybindings (space/enter) ### Global Config Schema Extension diff --git a/openspec/changes/simplify-skill-installation/specs/available-tools/spec.md b/openspec/changes/simplify-skill-installation/specs/available-tools/spec.md deleted file mode 100644 index fb27c3988..000000000 --- a/openspec/changes/simplify-skill-installation/specs/available-tools/spec.md +++ /dev/null @@ -1,48 +0,0 @@ -## Purpose - -Available tools detection SHALL enable smart defaults init by auto-discovering which AI tools the user has installed, reducing setup friction while maintaining user control. - -## ADDED Requirements - -### Requirement: Detect tools from directories -The system SHALL detect installed AI tools by scanning for their configuration directories. - -#### Scenario: Detect Claude Code -- **WHEN** `.claude/` directory exists in project root -- **THEN** the system SHALL detect Claude Code as an installed tool - -#### Scenario: Detect Cursor -- **WHEN** `.cursor/` directory exists in project root -- **THEN** the system SHALL detect Cursor as an installed tool - -#### Scenario: Detect Windsurf -- **WHEN** `.windsurf/` directory exists in project root -- **THEN** the system SHALL detect Windsurf as an installed tool - -#### Scenario: Detect multiple tools -- **WHEN** multiple tool directories exist (e.g., `.claude/`, `.cursor/`) -- **THEN** the system SHALL detect all matching tools - -#### Scenario: No tools detected -- **WHEN** no tool directories exist in project root -- **THEN** the system SHALL return an empty list of detected tools - -### Requirement: Detection covers all supported tools -The system SHALL check for all tools defined in `AI_TOOLS` that have a `skillsDir` property. - -#### Scenario: Detection mapping -- **WHEN** scanning for tools -- **THEN** the system SHALL check for each tool's `skillsDir` value (e.g., `.claude`, `.cursor`, `.windsurf`) - -### Requirement: Cross-platform directory detection -The system SHALL use cross-platform path handling for directory detection. - -#### Scenario: Path construction -- **WHEN** checking for tool directories -- **THEN** the system SHALL use `path.join()` to construct paths - -#### Scenario: Case sensitivity on different platforms -- **WHEN** checking for directories on any filesystem -- **THEN** the system SHALL use `fs.existsSync()` with exact directory names from AI_TOOLS -- **THEN** the operating system's filesystem SHALL handle case sensitivity natively -- **THEN** the system SHALL NOT perform manual case normalization 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 00e5f0b62..95176809f 100644 --- a/openspec/changes/simplify-skill-installation/specs/cli-init/spec.md +++ b/openspec/changes/simplify-skill-installation/specs/cli-init/spec.md @@ -16,6 +16,10 @@ The init command SHALL generate skills based on the active profile, not a fixed - **WHEN** user runs init with profile `custom` - **THEN** the system SHALL generate skills only for workflows listed in config `workflows` array +#### Scenario: Propose workflow included in skill templates +- **WHEN** generating skills +- **THEN** the system SHALL include the `propose` workflow as an available skill template + ### Requirement: Command generation per tool (REPLACES fixed 9-command mandate) The init command SHALL generate commands based on profile AND delivery settings. @@ -31,6 +35,26 @@ The init command SHALL generate commands based on profile AND delivery settings. - **WHEN** delivery is set to `both` - **THEN** the system SHALL generate both skill and command files for profile workflows +#### Scenario: Propose workflow included in command templates +- **WHEN** generating commands +- **THEN** the system SHALL include the `propose` workflow as an available command template + +### Requirement: Tool auto-detection +The init command SHALL detect installed AI tools by scanning for their configuration directories in the project root. + +#### Scenario: Detection from directories +- **WHEN** scanning for tools +- **THEN** the system SHALL check for directories matching each supported AI tool's configuration directory (e.g., `.claude/`, `.cursor/`, `.windsurf/`) +- **THEN** all tools with a matching directory SHALL be returned as detected + +#### Scenario: Detection covers all supported tools +- **WHEN** scanning for tools +- **THEN** the system SHALL check for all tools defined in the supported tools configuration that have a configuration directory + +#### Scenario: No tools detected +- **WHEN** no tool configuration directories exist in project root +- **THEN** the system SHALL return an empty list of detected tools + ### Requirement: Smart defaults init flow The init command SHALL work with sensible defaults and tool confirmation, minimizing required user input. @@ -68,12 +92,40 @@ The init command SHALL work with sensible defaults and tool confirmation, minimi - **THEN** the system SHALL NOT prompt for tool selection - **THEN** the system SHALL proceed with default profile and delivery -#### Scenario: Init success message +#### Scenario: Init success message (propose installed) - **WHEN** init completes successfully +- **AND** `propose` is in the active profile - **THEN** the system SHALL display a tool-appropriate success message - **THEN** for tools using colon syntax (Claude Code): "Start your first change: /opsx:propose \"your idea\"" - **THEN** for tools using hyphen syntax (Cursor, others): "Start your first change: /opsx-propose \"your idea\"" +#### Scenario: Init success message (propose not installed, new installed) +- **WHEN** init completes successfully +- **AND** `propose` is NOT in the active profile +- **AND** `new` is in the active profile +- **THEN** for tools using colon syntax: "Start your first change: /opsx:new \"your idea\"" +- **THEN** for tools using hyphen syntax: "Start your first change: /opsx-new \"your idea\"" + +#### Scenario: Init success message (neither propose nor new) +- **WHEN** init completes successfully +- **AND** neither `propose` nor `new` is in the active profile +- **THEN** the system SHALL display: "Done. Run 'openspec config profile' to configure your workflows." + +### Requirement: Init performs migration on existing projects +The init command SHALL perform one-time migration when re-initializing an existing project, using the same shared migration logic as the update command. + +#### Scenario: Re-init on existing project (no profile set) +- **WHEN** user runs `openspec init` on a project with existing workflow files +- **AND** global config does not contain a `profile` field +- **THEN** the system SHALL perform one-time migration before proceeding (see `specs/cli-update/spec.md`) +- **THEN** the system SHALL proceed with init using the migrated config + +#### Scenario: Init on new project (no existing workflows) +- **WHEN** user runs `openspec init` on a project with no existing workflow files +- **AND** global config does not contain a `profile` field +- **THEN** the system SHALL NOT perform migration +- **THEN** the system SHALL use `core` profile defaults + ### Requirement: Init respects global config The init command SHALL read and apply settings from global config. @@ -90,6 +142,39 @@ The init command SHALL read and apply settings from global config. - **THEN** the system SHALL use the flag value instead of config value - **THEN** the system SHALL NOT update the global config +#### Scenario: Invalid profile override +- **WHEN** user runs `openspec init --profile ` +- **AND** `` is not one of `core` or `custom` +- **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. + +#### 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 + +#### 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) + ### Requirement: Init preserves existing workflows The init command SHALL NOT remove workflows that are already installed, but SHALL respect delivery setting. @@ -105,6 +190,13 @@ The init command SHALL NOT remove workflows that are already installed, but SHAL - **THEN** the system SHALL delete files that don't match delivery (e.g., commands removed if `skills`) - **THEN** this applies to all workflows, including extras not in profile +#### Scenario: Re-init applies delivery cleanup even when templates are current +- **WHEN** user runs `openspec init` on an existing project +- **AND** existing files are already on current template versions +- **AND** delivery changed since the previous init +- **THEN** the system SHALL still remove files that no longer match delivery +- **THEN** for example, switching from `both` to `skills` SHALL remove generated command files + ### Requirement: Init tool confirmation UX The init command SHALL show detected tools and ask for confirmation. 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 566c90118..cac0bfa4e 100644 --- a/openspec/changes/simplify-skill-installation/specs/cli-update/spec.md +++ b/openspec/changes/simplify-skill-installation/specs/cli-update/spec.md @@ -26,6 +26,13 @@ The update command SHALL read global config and apply profile settings to the pr - **AND** delivery setting matches installed files - **THEN** the system SHALL display: "Already up to date." +#### Scenario: Profile or delivery drift with current templates +- **WHEN** user runs `openspec update` +- **AND** workflow templates are current for the installed skills +- **AND** project files do not match current profile and/or delivery config +- **THEN** the system SHALL treat this as an update-required state (not "Already up to date.") +- **THEN** the system SHALL add/remove files to match current profile and delivery settings + #### Scenario: Update summary output - **WHEN** update completes with changes - **THEN** the system SHALL display a summary: @@ -58,6 +65,94 @@ The update command SHALL add or remove files based on the delivery setting. - **AND** global config specifies `delivery: both` - **THEN** the system SHALL generate/update both skill and command files +### Requirement: Update detects configured tools from skills or commands +The update command SHALL treat a tool as configured if it has either generated skill files or generated command files. + +#### Scenario: Commands-only installation +- **WHEN** user runs `openspec update` +- **AND** a tool has generated OpenSpec command files +- **AND** that tool has no OpenSpec skill files (commands-only delivery) +- **THEN** the tool SHALL still be treated as configured +- **THEN** the system SHALL apply profile and delivery sync for that tool + +### Requirement: One-time migration for existing users +The update command SHALL detect existing users (no `profile` in global config + existing workflows) and migrate them to `custom` profile before applying updates. + +#### Scenario: First update after upgrade (existing user) +- **WHEN** user runs `openspec update` +- **AND** global config does not contain a `profile` field +- **AND** project has existing workflow files installed +- **THEN** the system SHALL scan installed workflows across all tool directories in the project +- **THEN** the system SHALL only match workflow names present in `ALL_WORKFLOWS` constant (ignoring user-created custom skills) +- **THEN** the system SHALL take the union of detected workflow names across all tools +- **THEN** the system SHALL write to global config: `profile: "custom"`, `delivery: "both"`, `workflows: []` +- **THEN** the system SHALL display: "Migrated: custom profile with workflows ()" +- **THEN** the system SHALL display: "New in this version: /opsx:propose (combines new + ff). Try 'openspec config profile core' for the streamlined 4-workflow experience." +- **THEN** the system SHALL proceed with normal update logic (using the migrated config) +- **THEN** the result SHALL be template refresh only (no workflows added or removed) + +#### Scenario: Migration with partial workflows (user manually removed some) +- **WHEN** user runs `openspec update` +- **AND** global config does not contain a `profile` field +- **AND** project has fewer than the original 10 workflows installed +- **THEN** the system SHALL migrate with only the workflows that are actually present +- **THEN** the migrated `workflows` array SHALL reflect the user's current state, not the original set + +#### Scenario: Migration with multiple tools having different workflow sets +- **WHEN** user runs `openspec update` +- **AND** project has multiple tools configured (e.g., Claude Code, Cursor) +- **AND** different tools have different workflows installed +- **THEN** the system SHALL take the union of all detected workflows across all tools +- **THEN** the migrated `workflows` array SHALL include any workflow that exists in at least one tool + +#### Scenario: No migration needed (profile already set) +- **WHEN** user runs `openspec update` +- **AND** global config already contains a `profile` field +- **THEN** the system SHALL NOT perform migration +- **THEN** the system SHALL proceed with normal update logic using existing config + +#### Scenario: No migration needed (no existing workflows) +- **WHEN** user runs `openspec update` +- **AND** global config does not contain a `profile` field +- **AND** project has no existing workflow files +- **THEN** the system SHALL NOT perform migration +- **THEN** the system SHALL use `core` profile defaults + +#### Scenario: Migration is idempotent +- **WHEN** user runs `openspec update` multiple times +- **THEN** migration SHALL only occur on the first run (when `profile` field is absent) +- **THEN** subsequent runs SHALL use the existing global config without re-scanning + +#### Scenario: Non-interactive migration +- **WHEN** user runs `openspec update` non-interactively (e.g., in CI) +- **AND** migration is triggered +- **THEN** the system SHALL perform migration without prompting +- **THEN** the system SHALL display the migration summary to stdout + +### Requirement: Update detects new tool directories +The update command SHALL notify the user if new AI tool directories are detected that aren't currently configured. + +#### Scenario: New tool directory detected +- **WHEN** user runs `openspec update` +- **AND** a new tool directory is detected (e.g., `.windsurf/` exists but Windsurf is not configured) +- **THEN** the system SHALL display: "Detected new tool: Windsurf. Run 'openspec init' to add it." +- **THEN** the system SHALL NOT automatically add the new tool +- **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 +- **THEN** the system SHALL NOT display any tool detection message + +### Requirement: Update requires an OpenSpec project +The update command SHALL only run inside an initialized OpenSpec project. + +#### Scenario: Update outside a project +- **WHEN** user runs `openspec update` +- **AND** no `openspec/` directory exists in the current working directory +- **THEN** the system SHALL display: "No OpenSpec project found. Run 'openspec init' to set up." +- **THEN** the system SHALL exit with code 1 + ### Requirement: Extra workflows preserved The update command SHALL NOT remove workflow files that aren't in the current profile. diff --git a/openspec/changes/simplify-skill-installation/specs/command-generation/spec.md b/openspec/changes/simplify-skill-installation/specs/command-generation/spec.md deleted file mode 100644 index a5f3c62d2..000000000 --- a/openspec/changes/simplify-skill-installation/specs/command-generation/spec.md +++ /dev/null @@ -1,37 +0,0 @@ -## Purpose - -Command generation SHALL create CLI command files for workflows based on profile and delivery settings, enabling users to choose their preferred invocation method. - -## MODIFIED Requirements - -### Requirement: Conditional command generation -The command generation system SHALL respect profile and delivery settings. - -#### Scenario: Generate commands for profile workflows only -- **WHEN** generating commands with profile `core` -- **THEN** the system SHALL only generate commands for: `propose`, `explore`, `apply`, `archive` -- **THEN** the system SHALL NOT generate commands for workflows not in the profile - -#### Scenario: Skip command generation when delivery is skills-only -- **WHEN** generating with delivery set to `skills` -- **THEN** the system SHALL NOT generate any command files -- **THEN** the system SHALL only generate skill files - -#### Scenario: Generate commands when delivery is both or commands -- **WHEN** generating with delivery set to `both` or `commands` -- **THEN** the system SHALL generate command files for profile workflows - -### Requirement: Include propose command template -The command generation system SHALL include the new `propose` workflow template. - -#### Scenario: Propose command in templates -- **WHEN** getting command templates -- **THEN** the system SHALL include `propose` command template -- **THEN** the template SHALL generate command with id `propose` - -### Requirement: Command file naming for propose -The propose command SHALL follow existing naming conventions. - -#### Scenario: Propose command file path -- **WHEN** generating propose command for Claude Code -- **THEN** the file path SHALL be `.claude/commands/opsx/propose.md` diff --git a/openspec/changes/simplify-skill-installation/specs/delivery-config/spec.md b/openspec/changes/simplify-skill-installation/specs/delivery-config/spec.md deleted file mode 100644 index 888965872..000000000 --- a/openspec/changes/simplify-skill-installation/specs/delivery-config/spec.md +++ /dev/null @@ -1,46 +0,0 @@ -## Purpose - -Delivery configuration SHALL allow power users to control how workflows are installed (skills, commands, or both) without affecting the default experience for new users. - -## ADDED Requirements - -### Requirement: Delivery options -The system SHALL support three delivery methods: `both`, `skills`, and `commands`. - -#### Scenario: Both delivery -- **WHEN** delivery is set to `both` -- **THEN** the system SHALL install both skill files and command files for each workflow - -#### Scenario: Skills-only delivery -- **WHEN** delivery is set to `skills` -- **THEN** the system SHALL install only skill files (SKILL.md) for each workflow -- **THEN** the system SHALL NOT install command files - -#### Scenario: Commands-only delivery -- **WHEN** delivery is set to `commands` -- **THEN** the system SHALL install only command files for each workflow -- **THEN** the system SHALL NOT install skill files - -### Requirement: Delivery CLI commands -The system SHALL provide CLI commands for managing delivery preference. - -#### Scenario: Set delivery preference -- **WHEN** user runs `openspec config set delivery ` -- **THEN** the system SHALL update the global config delivery setting -- **THEN** the system SHALL output confirmation of the change - -#### Scenario: Get delivery preference -- **WHEN** user runs `openspec config get delivery` -- **THEN** the system SHALL display the current delivery setting -- **THEN** if delivery is not explicitly set, the system SHALL display "both (default)" - -#### Scenario: Invalid delivery value -- **WHEN** user runs `openspec config set delivery ` -- **THEN** the system SHALL display an error with valid options - -### Requirement: Delivery defaults -The system SHALL use `both` as the default delivery method. - -#### Scenario: No delivery config exists -- **WHEN** global config does not specify delivery -- **THEN** the system SHALL behave as if delivery is `both` diff --git a/openspec/changes/simplify-skill-installation/specs/profiles/spec.md b/openspec/changes/simplify-skill-installation/specs/profiles/spec.md index 0c73cfc4b..4b0d85c96 100644 --- a/openspec/changes/simplify-skill-installation/specs/profiles/spec.md +++ b/openspec/changes/simplify-skill-installation/specs/profiles/spec.md @@ -16,7 +16,25 @@ The system SHALL support two workflow profiles: `core` and `custom`. - **THEN** the profile SHALL include only the workflows specified in global config `workflows` array ### Requirement: Delivery is independent of profile -The delivery setting controls HOW workflows are installed, separate from WHICH workflows are installed. +The delivery setting SHALL control HOW workflows are installed (skills, commands, or both), separate from WHICH workflows are installed. + +#### Scenario: Delivery options +- **WHEN** configuring delivery +- **THEN** the system SHALL support three options: `both` (skills and commands), `skills` (skill files only), `commands` (command files only) + +#### Scenario: Both delivery +- **WHEN** delivery is set to `both` +- **THEN** the system SHALL install both skill files and command files for each workflow + +#### Scenario: Skills-only delivery +- **WHEN** delivery is set to `skills` +- **THEN** the system SHALL install only skill files for each workflow +- **THEN** the system SHALL NOT install command files + +#### Scenario: Commands-only delivery +- **WHEN** delivery is set to `commands` +- **THEN** the system SHALL install only command files for each workflow +- **THEN** the system SHALL NOT install skill files #### Scenario: Core profile with custom delivery - **WHEN** profile is set to `core` @@ -37,7 +55,8 @@ The system SHALL provide an interactive picker for configuring profiles. - Workflow toggles for all available workflows - **THEN** the system SHALL pre-select current config values - **THEN** on confirmation, the system SHALL update global config -- **THEN** the system SHALL set profile to `custom` if user changes from core defaults +- **THEN** the system SHALL set profile to `custom` if selected workflows differ from core defaults +- **THEN** the system SHALL set profile to `core` if selected workflows match core defaults exactly (propose, explore, apply, archive), regardless of delivery setting - **THEN** the system SHALL NOT modify any project files - **THEN** the system SHALL display: "Config updated. Run `openspec update` in your projects to apply." @@ -68,8 +87,25 @@ The system SHALL provide an interactive picker for configuring profiles. - **THEN** the system SHALL display an error: "Interactive mode required. Use `openspec config profile core` or set config via environment/flags." - **THEN** the system SHALL exit with code 1 +### Requirement: Profile settings stored in global config +Profile and delivery settings SHALL be stored in the existing global config file (`~/.config/openspec/config.json`) alongside telemetry and feature flags. + +#### Scenario: Config schema +- **WHEN** reading profile configuration +- **THEN** the config SHALL contain `profile` (core|custom), `delivery` (both|skills|commands), and optionally `workflows` (array of workflow names) + +#### Scenario: Schema evolution +- **WHEN** loading config without profile/delivery fields +- **THEN** the system SHALL use defaults (profile=core, delivery=both) +- **AND** existing config fields (telemetry, featureFlags) SHALL be preserved + +#### Scenario: Config list displays profile settings +- **WHEN** user runs `openspec config list` +- **THEN** the system SHALL display profile, delivery, and workflows settings +- **AND** SHALL indicate which values are defaults vs explicitly set + ### Requirement: Config is global, projects are explicit -Config changes do NOT automatically propagate to projects. +Config changes SHALL NOT automatically propagate to projects. #### Scenario: Config update does not modify projects - **WHEN** user updates config via `openspec config profile` @@ -78,15 +114,29 @@ Config changes do NOT automatically propagate to projects. - **THEN** existing projects retain their current workflow files until user runs `openspec update` ### Requirement: Config changes applied via update command -The existing `openspec update` command applies the current global config to a project. See `specs/cli-update/spec.md` for detailed update behavior. +The existing `openspec update` command SHALL apply the current global config to a project. See `specs/cli-update/spec.md` for detailed update behavior. + +#### Scenario: Config changes require explicit project sync +- **WHEN** user updates profile or delivery via `openspec config profile` +- **THEN** the global config SHALL be updated immediately +- **AND** project files SHALL remain unchanged until `openspec update` is run for that project ### Requirement: Profile defaults -The system SHALL use `core` as the default profile for new users. +The system SHALL use `core` as the default profile for new users, while preserving existing users' workflows via migration. -#### Scenario: No global config exists +#### Scenario: No global config exists (new user) - **WHEN** global config file does not exist +- **AND** no existing workflows are installed in the project - **THEN** the system SHALL behave as if profile is `core` -#### Scenario: Global config exists but profile field absent +#### Scenario: Global config exists but profile field absent (new user) - **WHEN** global config file exists but does not contain a `profile` field +- **AND** no existing workflows are installed in the project - **THEN** the system SHALL behave as if profile is `core` + +#### Scenario: Profile field absent with existing workflows (existing user migration) +- **WHEN** global config does not contain a `profile` field +- **AND** the `update` command detects existing workflow files in the project +- **THEN** the system SHALL perform one-time migration (see `specs/cli-update/spec.md` for details) +- **THEN** the system SHALL set profile to `custom` with the detected workflows +- **THEN** the system SHALL NOT add or remove any workflow files during migration diff --git a/openspec/changes/simplify-skill-installation/specs/propose-workflow/spec.md b/openspec/changes/simplify-skill-installation/specs/propose-workflow/spec.md index 34bb6c8d8..90952d567 100644 --- a/openspec/changes/simplify-skill-installation/specs/propose-workflow/spec.md +++ b/openspec/changes/simplify-skill-installation/specs/propose-workflow/spec.md @@ -10,6 +10,7 @@ The system SHALL provide a `propose` workflow that creates a change and generate #### Scenario: Basic propose invocation - **WHEN** user invokes `/opsx:propose "add user authentication"` - **THEN** the system SHALL create a change directory with kebab-case name +- **THEN** the system SHALL create `.openspec.yaml` in the change directory (via `openspec new change`) - **THEN** the system SHALL generate all artifacts needed for implementation: proposal.md, design.md, specs/, tasks.md #### Scenario: Propose with existing change name diff --git a/openspec/changes/simplify-skill-installation/specs/skill-generation/spec.md b/openspec/changes/simplify-skill-installation/specs/skill-generation/spec.md deleted file mode 100644 index b94557f1e..000000000 --- a/openspec/changes/simplify-skill-installation/specs/skill-generation/spec.md +++ /dev/null @@ -1,36 +0,0 @@ -## Purpose - -Skill generation SHALL create skill files for workflows based on profile and delivery settings, supporting both the streamlined core profile and custom user configurations. - -## MODIFIED Requirements - -### Requirement: Conditional skill generation -The skill generation system SHALL respect profile and delivery settings. - -#### Scenario: Generate skills for profile workflows only -- **WHEN** generating skills with profile `core` -- **THEN** the system SHALL only generate skills for: `propose`, `explore`, `apply`, `archive` -- **THEN** the system SHALL NOT generate skills for workflows not in the profile - -#### Scenario: Skip skill generation when delivery is commands-only -- **WHEN** generating with delivery set to `commands` -- **THEN** the system SHALL NOT generate any skill files - -#### Scenario: Generate skills when delivery is both or skills -- **WHEN** generating with delivery set to `both` or `skills` -- **THEN** the system SHALL generate skill files for profile workflows - -### Requirement: Include propose skill template -The skill generation system SHALL include the new `propose` workflow template. - -#### Scenario: Propose skill in templates -- **WHEN** getting skill templates -- **THEN** the system SHALL include `openspec-propose` template -- **THEN** the template SHALL be in addition to templates listed in SKILL_NAMES constant - -### Requirement: Skill names constant update -The `SKILL_NAMES` constant SHALL include the propose workflow. - -#### Scenario: Updated skill names -- **WHEN** referencing `SKILL_NAMES` constant -- **THEN** it SHALL include `openspec-propose` in addition to existing names diff --git a/openspec/changes/simplify-skill-installation/specs/tool-selection-ux/spec.md b/openspec/changes/simplify-skill-installation/specs/tool-selection-ux/spec.md deleted file mode 100644 index 0ceaf4837..000000000 --- a/openspec/changes/simplify-skill-installation/specs/tool-selection-ux/spec.md +++ /dev/null @@ -1,40 +0,0 @@ -## Purpose - -Tool selection UX SHALL use industry-standard keybindings (space to toggle, enter to confirm) to reduce user confusion during initialization. - -## MODIFIED Requirements - -### Requirement: Multi-select keybindings -The tool selection prompt SHALL use standard keybindings for multi-select. - -#### Scenario: Toggle selection with space -- **WHEN** user presses Space key on a tool option -- **THEN** the system SHALL toggle the selection state of that option - -#### Scenario: Confirm selection with enter -- **WHEN** user presses Enter key -- **THEN** the system SHALL confirm the current selection and proceed - -#### Scenario: Navigate with arrow keys -- **WHEN** user presses Up or Down arrow keys -- **THEN** the system SHALL move the cursor to the previous or next option - -### Requirement: Remove tab-to-confirm behavior -The tool selection prompt SHALL NOT use Tab to confirm selection. - -#### Scenario: Tab key behavior -- **WHEN** user presses Tab key -- **THEN** the system SHALL NOT confirm selection -- **THEN** the system MAY move focus or do nothing (implementation-dependent) - -### Requirement: Selection feedback -The tool selection prompt SHALL clearly indicate selected and unselected states. - -#### Scenario: Visual feedback -- **WHEN** displaying tool options -- **THEN** selected options SHALL show a filled checkbox (e.g., `[x]`) -- **THEN** unselected options SHALL show an empty checkbox (e.g., `[ ]`) - -#### Scenario: Help text -- **WHEN** displaying the selection prompt -- **THEN** the system SHALL show hint text: "Space to toggle, Enter to confirm" diff --git a/openspec/changes/simplify-skill-installation/specs/user-config/spec.md b/openspec/changes/simplify-skill-installation/specs/user-config/spec.md deleted file mode 100644 index 3d1d76024..000000000 --- a/openspec/changes/simplify-skill-installation/specs/user-config/spec.md +++ /dev/null @@ -1,43 +0,0 @@ -## Purpose - -User configuration SHALL extend the existing global config to store profile and delivery preferences, enabling persistent customization across projects. - -## MODIFIED Requirements - -### Requirement: Global configuration storage (EXTENDS existing) -The system SHALL store profile, delivery, and workflows settings in the existing global config file alongside telemetry and feature flags. - -#### Scenario: Profile config structure -- **WHEN** reading or writing profile configuration -- **THEN** the config contains `profile` (string: core|custom), `delivery` (string: both|skills|commands), and optionally `workflows` (array of strings) - -#### Scenario: Schema evolution for new fields -- **WHEN** loading config without profile/delivery fields -- **THEN** the system SHALL use defaults: profile=core, delivery=both -- **AND** existing telemetry/featureFlags fields SHALL be preserved - -#### Scenario: Custom profile with workflows -- **WHEN** config contains `profile: "custom"` -- **THEN** the system SHALL read the `workflows` array for the list of enabled workflows -- **AND** if `workflows` is missing, SHALL treat as empty array - -## ADDED Requirements - -### Requirement: Profile config defaults -The system SHALL use sensible defaults when profile settings are missing. - -#### Scenario: Missing profile field -- **WHEN** config file exists but has no `profile` field -- **THEN** the system SHALL behave as if `profile: "core"` - -#### Scenario: Missing delivery field -- **WHEN** config file exists but has no `delivery` field -- **THEN** the system SHALL behave as if `delivery: "both"` - -### Requirement: Config list shows profile settings -The `openspec config list` command SHALL display profile and delivery settings. - -#### Scenario: List all config with profile -- **WHEN** user runs `openspec config list` -- **THEN** the system SHALL display profile, delivery, and workflows settings -- **AND** SHALL indicate which values are defaults vs explicitly set diff --git a/openspec/changes/simplify-skill-installation/tasks.md b/openspec/changes/simplify-skill-installation/tasks.md index 7d586e645..caf3d9d5b 100644 --- a/openspec/changes/simplify-skill-installation/tasks.md +++ b/openspec/changes/simplify-skill-installation/tasks.md @@ -1,108 +1,132 @@ ## 1. Global Config Extension -- [ ] 1.1 Extend `src/core/global-config.ts` schema with `profile`, `delivery`, and `workflows` fields -- [ ] 1.2 Add TypeScript types for profile (`core` | `custom`), delivery (`both` | `skills` | `commands`), and workflows (string array) -- [ ] 1.3 Update `GlobalConfig` interface and defaults (profile=`core`, delivery=`both`) -- [ ] 1.4 Update existing `readGlobalConfig()` to handle missing new fields with defaults -- [ ] 1.5 Add tests for schema evolution (existing config without new fields) +- [x] 1.1 Extend `src/core/global-config.ts` schema with `profile`, `delivery`, and `workflows` fields +- [x] 1.2 Add TypeScript types for profile (`core` | `custom`), delivery (`both` | `skills` | `commands`), and workflows (string array) +- [x] 1.3 Update `GlobalConfig` interface and defaults (profile=`core`, delivery=`both`) +- [x] 1.4 Update existing `readGlobalConfig()` to handle missing new fields with defaults +- [x] 1.5 Add tests for schema evolution (existing config without new fields) ## 2. Profile System -- [ ] 2.1 Create `src/core/profiles.ts` with profile definitions (core, custom) -- [ ] 2.2 Define `CORE_WORKFLOWS` constant: `['propose', 'explore', 'apply', 'archive']` -- [ ] 2.3 Define `ALL_WORKFLOWS` constant with all 11 workflows -- [ ] 2.4 Add `COMMAND_IDS` constant to `src/core/shared/tool-detection.ts` (parallel to existing SKILL_NAMES) -- [ ] 2.5 Implement `getProfileWorkflows(profile, customWorkflows?)` resolver function -- [ ] 2.6 Add tests for profile resolution +- [x] 2.1 Create `src/core/profiles.ts` with profile definitions (core, custom) +- [x] 2.2 Define `CORE_WORKFLOWS` constant: `['propose', 'explore', 'apply', 'archive']` +- [x] 2.3 Define `ALL_WORKFLOWS` constant with all 11 workflows +- [x] 2.4 Add `COMMAND_IDS` constant to `src/core/shared/tool-detection.ts` (parallel to existing SKILL_NAMES) +- [x] 2.5 Implement `getProfileWorkflows(profile, customWorkflows?)` resolver function +- [x] 2.6 Add tests for profile resolution ## 3. Config Profile Command (Interactive Picker) -- [ ] 3.1 Add `config profile` subcommand to `src/commands/config.ts` -- [ ] 3.2 Implement interactive picker UI with delivery selection (skills/commands/both) -- [ ] 3.3 Implement interactive picker UI with workflow toggles -- [ ] 3.4 Pre-select current config values in picker -- [ ] 3.5 Update global config on confirmation (config-only, no file regeneration) -- [ ] 3.6 Display post-update message: "Config updated. Run `openspec update` in your projects to apply." -- [ ] 3.7 Detect if running inside an OpenSpec project and offer to run update automatically -- [ ] 3.8 Implement `config profile core` preset shortcut (preserves delivery setting) -- [ ] 3.9 Handle non-interactive mode: error with helpful message -- [ ] 3.10 Add tests for config profile command +- [x] 3.1 Add `config profile` subcommand to `src/commands/config.ts` +- [x] 3.2 Implement interactive picker UI with delivery selection (skills/commands/both) +- [x] 3.3 Implement interactive picker UI with workflow toggles +- [x] 3.4 Pre-select current config values in picker +- [x] 3.5 Update global config on confirmation (config-only, no file regeneration) +- [x] 3.6 Display post-update message: "Config updated. Run `openspec update` in your projects to apply." +- [x] 3.7 Detect if running inside an OpenSpec project and offer to run update automatically +- [x] 3.8 Implement `config profile core` preset shortcut (preserves delivery setting) +- [x] 3.9 Handle non-interactive mode: error with helpful message +- [x] 3.10 Update `openspec config list` to display profile, delivery, and workflows settings (indicate defaults vs explicit) +- [x] 3.11 Add tests for config profile command and config list output ## 4. Available Tools Detection -- [ ] 4.1 Create `src/core/available-tools.ts` (separate from existing `tool-detection.ts`) -- [ ] 4.2 Implement `getAvailableTools(projectPath)` that scans for AI tool directories (`.claude/`, `.cursor/`, etc.) -- [ ] 4.3 Use `AI_TOOLS` config to map directory names to tool IDs -- [ ] 4.4 Add tests for available tools detection including cross-platform paths +- [x] 4.1 Create `src/core/available-tools.ts` (separate from existing `tool-detection.ts`) +- [x] 4.2 Implement `getAvailableTools(projectPath)` that scans for AI tool directories (`.claude/`, `.cursor/`, etc.) +- [x] 4.3 Use `AI_TOOLS` config to map directory names to tool IDs +- [x] 4.4 Add tests for available tools detection including cross-platform paths ## 5. Propose Workflow Template -- [ ] 5.1 Create `src/core/templates/workflows/propose.ts` -- [ ] 5.2 Implement skill template that combines new + ff behavior -- [ ] 5.3 Add onboarding-style explanatory output to template -- [ ] 5.4 Implement command template for propose -- [ ] 5.5 Export templates from `src/core/templates/skill-templates.ts` -- [ ] 5.6 Add `openspec-propose` to `SKILL_NAMES` in `src/core/shared/tool-detection.ts` -- [ ] 5.7 Add `propose` to command templates in `src/core/shared/skill-generation.ts` -- [ ] 5.8 Add `propose` to `COMMAND_IDS` in `src/core/shared/tool-detection.ts` +- [x] 5.1 Create `src/core/templates/workflows/propose.ts` +- [x] 5.2 Implement skill template that combines new + ff behavior +- [x] 5.3 Ensure propose creates `.openspec.yaml` via `openspec new change` before generating artifacts +- [x] 5.4 Add onboarding-style explanatory output to template +- [x] 5.5 Implement command template for propose +- [x] 5.6 Export templates from `src/core/templates/skill-templates.ts` +- [x] 5.7 Add `openspec-propose` to `SKILL_NAMES` in `src/core/shared/tool-detection.ts` +- [x] 5.8 Add `propose` to command templates in `src/core/shared/skill-generation.ts` +- [x] 5.9 Add `propose` to `COMMAND_IDS` in `src/core/shared/tool-detection.ts` +- [x] 5.10 Add tests for propose template (creates change, generates artifacts, equivalent to new + ff) ## 6. Conditional Skill/Command Generation -- [ ] 6.1 Update `getSkillTemplates()` to accept profile filter parameter -- [ ] 6.2 Update `getCommandTemplates()` to accept profile filter parameter -- [ ] 6.3 Update `generateSkillsAndCommands()` in init.ts to respect delivery setting -- [ ] 6.4 Add logic to skip skill generation when delivery is 'commands' -- [ ] 6.5 Add logic to skip command generation when delivery is 'skills' -- [ ] 6.6 Add tests for conditional generation +- [x] 6.1 Update `getSkillTemplates()` to accept profile filter parameter +- [x] 6.2 Update `getCommandTemplates()` to accept profile filter parameter +- [x] 6.3 Update `generateSkillsAndCommands()` in init.ts to respect delivery setting +- [x] 6.4 Add logic to skip skill generation when delivery is 'commands' +- [x] 6.5 Add logic to skip command generation when delivery is 'skills' +- [x] 6.6 Add tests for conditional generation ## 7. Init Flow Updates -- [ ] 7.1 Update init to call `getAvailableTools()` first -- [ ] 7.2 Update init to read global config for profile/delivery defaults -- [ ] 7.3 Change tool selection to show pre-selected detected tools -- [ ] 7.4 Update success message to show `/opsx:propose` prompt -- [ ] 7.5 Add `--profile` flag to override global config -- [ ] 7.6 Update non-interactive mode to use defaults without prompting -- [ ] 7.7 Add tests for init flow with various scenarios - -## 8. Update Command (Profile Support) - -- [ ] 8.1 Modify existing `src/commands/update.ts` to read global config for profile/delivery/workflows -- [ ] 8.2 Add logic to detect which workflows are in config but not installed (to add) -- [ ] 8.3 Add logic to detect which workflows are installed and need refresh (to update) -- [ ] 8.4 Respect delivery setting: generate only skills if `skills`, only commands if `commands` -- [ ] 8.5 Delete files when delivery changes: remove commands if `skills`, remove skills if `commands` -- [ ] 8.6 Generate new workflow files for missing workflows in profile -- [ ] 8.7 Display summary: "Added: X, Y" / "Updated: Z" / "Removed: N files" / "Already up to date." -- [ ] 8.8 List affected tools in output: "Tools: Claude Code, Cursor" -- [ ] 8.9 Add tests for update command with profile scenarios (including delivery changes) +- [x] 7.1 Update init to call `getAvailableTools()` first +- [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.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) + +## 8. Update Command (Profile Support + Migration) + +- [x] 8.1 Modify existing `src/commands/update.ts` to read global config for profile/delivery/workflows +- [x] 8.2 Implement shared `scanInstalledWorkflows(projectPath, tools)` — scan tool directories, match only against `ALL_WORKFLOWS` constant, return union across tools +- [x] 8.3 Implement shared `migrateIfNeeded(projectPath, tools)` — one-time migration logic used by both `init` and `update` +- [x] 8.4 Display migration message: "Migrated: custom profile with N workflows" + "New in this version: /opsx:propose. Try 'openspec config profile core' for the streamlined experience." +- [x] 8.5 Add project check: exit with error if no `openspec/` directory exists +- [x] 8.6 Add logic to detect which workflows are in config but not installed (to add) +- [x] 8.7 Add logic to detect which workflows are installed and need refresh (to update) +- [x] 8.8 Respect delivery setting: generate only skills if `skills`, only commands if `commands` +- [x] 8.9 Delete files when delivery changes: remove commands if `skills`, remove skills if `commands` +- [x] 8.10 Generate new workflow files for missing workflows in profile +- [x] 8.11 Display summary: "Added: X, Y" / "Updated: Z" / "Removed: N files" / "Already up to date." +- [x] 8.12 List affected tools in output: "Tools: Claude Code, Cursor" +- [x] 8.13 Detect new tool directories not currently configured and display hint to re-init +- [x] 8.14 Add tests for migration scenarios (existing user, partial workflows, multiple tools, idempotent, custom skills ignored) +- [x] 8.15 Add tests for update command with profile scenarios (including delivery changes, outside-project error, new tool detection) ## 9. Tool Selection UX Fix -- [ ] 9.1 Update `src/prompts/searchable-multi-select.ts` keybindings -- [ ] 9.2 Change Space to toggle selection -- [ ] 9.3 Change Enter to confirm selection -- [ ] 9.4 Remove Tab-to-confirm behavior -- [ ] 9.5 Add hint text "Space to toggle, Enter to confirm" -- [ ] 9.6 Add tests for keybinding behavior +- [x] 9.1 Update `src/prompts/searchable-multi-select.ts` keybindings +- [x] 9.2 Change Space to toggle selection +- [x] 9.3 Change Enter to confirm selection +- [x] 9.4 Remove Tab-to-confirm behavior +- [x] 9.5 Add hint text "Space to toggle, Enter to confirm" +- [x] 9.6 Add tests for keybinding behavior ## 10. Scaffolding Verification -- [ ] 10.1 Verify `openspec new change` creates `.openspec.yaml` with schema and created fields +- [x] 10.1 Verify `openspec new change` creates `.openspec.yaml` with schema and created fields -## 11. Explore Workflow Updates +## 11. Template Next-Step Guidance -- [ ] 11.1 Update `src/core/templates/workflows/explore.ts` to reference `/opsx:propose` instead of `/opsx:new` and `/opsx:ff` -- [ ] 11.2 Update explore's "next steps" summary to show single propose path -- [ ] 11.3 Review explore → propose transition UX (see `openspec/explorations/explore-workflow-ux.md` for open questions) +- [x] 11.1 Audit all templates for hardcoded cross-workflow command references (e.g., `/opsx:propose`) +- [x] 11.2 Replace any specific command references with generic concept-based guidance (e.g., "create a change proposal") +- [x] 11.3 Review explore → propose transition UX (see `openspec/explorations/explore-workflow-ux.md` for open questions) -## 12. Integration & Documentation +## 12. Integration & Manual Testing -- [ ] 12.1 Run full test suite and fix any failures -- [ ] 12.2 Test on Windows (or verify CI passes on Windows) -- [ ] 12.3 Test end-to-end flow: init → propose → apply → archive -- [ ] 12.4 Update CLI help text for new commands +- [x] 12.1 Run full test suite and fix any failures +- [x] 12.2 Test on Windows (or verify CI passes on Windows) +- [x] 12.3 Test end-to-end flow: init → propose → apply → archive +- [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.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 + +## 13. Post-Implementation Hardening (Review Follow-up) + +- [x] 13.1 Ensure `update` treats profile/delivery drift as update-required even when templates are current +- [x] 13.2 Ensure `update` recognizes command-only installations as configured tools +- [x] 13.3 Ensure `init` validates `--profile` values and errors on invalid overrides +- [x] 13.4 Ensure re-running `init` applies delivery cleanup (removes files not matching current delivery mode) +- [x] 13.5 Add/adjust regression tests for config drift sync, command-only detection, invalid profile override, and re-init delivery cleanup diff --git a/src/cli/index.ts b/src/cli/index.ts index 006f21c36..8947736f7 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -95,7 +95,8 @@ program .description('Initialize OpenSpec in your project') .option('--tools ', toolsOptionDescription) .option('--force', 'Auto-cleanup legacy files without prompting') - .action(async (targetPath = '.', options?: { tools?: string; force?: boolean }) => { + .option('--profile ', 'Override global config profile (core or custom)') + .action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string }) => { try { // Validate that the path is a valid directory const resolvedPath = path.resolve(targetPath); @@ -120,6 +121,7 @@ program const initCommand = new InitCommand({ tools: options?.tools, force: options?.force, + profile: options?.profile, }); await initCommand.execute(targetPath); } catch (error) { diff --git a/src/commands/config.ts b/src/commands/config.ts index 3df9f852d..c9d800c3e 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,12 +1,14 @@ import { Command } from 'commander'; -import { spawn } from 'node:child_process'; +import { spawn, execSync } from 'node:child_process'; import * as fs from 'node:fs'; +import * as path from 'node:path'; import { getGlobalConfigPath, getGlobalConfig, saveGlobalConfig, GlobalConfig, } from '../core/global-config.js'; +import type { Profile, Delivery } from '../core/global-config.js'; import { getNestedValue, setNestedValue, @@ -17,6 +19,8 @@ import { validateConfig, DEFAULT_CONFIG, } from '../core/config-schema.js'; +import { CORE_WORKFLOWS, ALL_WORKFLOWS, getProfileWorkflows } from '../core/profiles.js'; +import { OPENSPEC_DIR_NAME } from '../core/config.js'; /** * Register the config command and all its subcommands. @@ -55,7 +59,32 @@ export function registerConfigCommand(program: Command): void { if (options.json) { console.log(JSON.stringify(config, null, 2)); } else { + // Read raw config to determine which values are explicit vs defaults + const configPath = getGlobalConfigPath(); + let rawConfig: Record = {}; + try { + if (fs.existsSync(configPath)) { + rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } + } catch { + // If reading fails, treat all as defaults + } + console.log(formatValueYaml(config)); + + // Annotate profile settings + const profileSource = rawConfig.profile !== undefined ? '(explicit)' : '(default)'; + const deliverySource = rawConfig.delivery !== undefined ? '(explicit)' : '(default)'; + console.log(`\nProfile settings:`); + console.log(` profile: ${config.profile} ${profileSource}`); + console.log(` delivery: ${config.delivery} ${deliverySource}`); + if (config.profile === 'core') { + console.log(` workflows: ${CORE_WORKFLOWS.join(', ')} (from core profile)`); + } else if (config.workflows && config.workflows.length > 0) { + console.log(` workflows: ${config.workflows.join(', ')} (explicit)`); + } else { + console.log(` workflows: (none)`); + } } }); @@ -230,4 +259,98 @@ export function registerConfigCommand(program: Command): void { process.exitCode = 1; } }); + + // config profile [preset] + configCmd + .command('profile [preset]') + .description('Configure workflow profile (interactive picker or preset shortcut)') + .action(async (preset?: string) => { + // Preset shortcut: `openspec config profile core` + if (preset === 'core') { + const config = getGlobalConfig(); + config.profile = 'core'; + config.workflows = [...CORE_WORKFLOWS]; + // Preserve delivery setting + saveGlobalConfig(config); + console.log('Config updated. Run `openspec update` in your projects to apply.'); + return; + } + + if (preset) { + console.error(`Error: Unknown profile preset "${preset}". Available presets: core`); + process.exitCode = 1; + return; + } + + // Non-interactive check + if (!process.stdout.isTTY) { + console.error('Interactive mode required. Use `openspec config profile core` or set config via environment/flags.'); + process.exitCode = 1; + return; + } + + // Interactive picker + const { select, checkbox } = await import('@inquirer/prompts'); + + const config = getGlobalConfig(); + + // 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', + }); + + // 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), + })), + }); + + // Determine profile based on selection + const isCoreMatch = + selectedWorkflows.length === CORE_WORKFLOWS.length && + CORE_WORKFLOWS.every((w) => selectedWorkflows.includes(w)); + + const profile: Profile = isCoreMatch ? 'core' : 'custom'; + + config.profile = profile; + config.delivery = delivery; + config.workflows = selectedWorkflows; + + saveGlobalConfig(config); + + // Check if inside an OpenSpec project + const projectDir = process.cwd(); + const openspecDir = path.join(projectDir, OPENSPEC_DIR_NAME); + if (fs.existsSync(openspecDir)) { + const { confirm } = await import('@inquirer/prompts'); + const applyNow = await confirm({ + message: 'Apply 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.'); + }); } diff --git a/src/core/available-tools.ts b/src/core/available-tools.ts new file mode 100644 index 000000000..6b45b3ca6 --- /dev/null +++ b/src/core/available-tools.ts @@ -0,0 +1,29 @@ +/** + * Available Tools Detection + * + * Detects which AI tools are available in a project by scanning + * for their configuration directories. + */ + +import path from 'path'; +import * as fs from 'fs'; +import { AI_TOOLS, type AIToolOption } from './config.js'; + +/** + * Scans the project path for AI tool configuration directories and returns + * the tools that are present. + * + * Checks for each tool's `skillsDir` (e.g., `.claude/`, `.cursor/`) at the + * project root. Only tools with a `skillsDir` property are considered. + */ +export function getAvailableTools(projectPath: string): AIToolOption[] { + return AI_TOOLS.filter((tool) => { + if (!tool.skillsDir) return false; + const dirPath = path.join(projectPath, tool.skillsDir); + try { + return fs.statSync(dirPath).isDirectory(); + } catch { + return false; + } + }); +} diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 8de89351c..8d67d20f3 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -377,6 +377,11 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Open config in $EDITOR', flags: [], }, + { + name: 'profile', + description: 'Configure workflow profile', + flags: [], + }, ], }, { diff --git a/src/core/config-schema.ts b/src/core/config-schema.ts index 78d27b48b..0614ed33e 100644 --- a/src/core/config-schema.ts +++ b/src/core/config-schema.ts @@ -10,6 +10,17 @@ export const GlobalConfigSchema = z .record(z.string(), z.boolean()) .optional() .default({}), + profile: z + .enum(['core', 'custom']) + .optional() + .default('core'), + delivery: z + .enum(['both', 'skills', 'commands']) + .optional() + .default('both'), + workflows: z + .array(z.string()) + .optional(), }) .passthrough(); @@ -20,9 +31,11 @@ export type GlobalConfigType = z.infer; */ export const DEFAULT_CONFIG: GlobalConfigType = { featureFlags: {}, + profile: 'core', + delivery: 'both', }; -const KNOWN_TOP_LEVEL_KEYS = new Set(Object.keys(DEFAULT_CONFIG)); +const KNOWN_TOP_LEVEL_KEYS = new Set([...Object.keys(DEFAULT_CONFIG), 'workflows']); /** * Validate a config key path for CLI set operations. diff --git a/src/core/global-config.ts b/src/core/global-config.ts index 271ca5a69..08b3e7462 100644 --- a/src/core/global-config.ts +++ b/src/core/global-config.ts @@ -7,13 +7,22 @@ export const GLOBAL_CONFIG_DIR_NAME = 'openspec'; export const GLOBAL_CONFIG_FILE_NAME = 'config.json'; export const GLOBAL_DATA_DIR_NAME = 'openspec'; +// TypeScript types +export type Profile = 'core' | 'custom'; +export type Delivery = 'both' | 'skills' | 'commands'; + // TypeScript interfaces export interface GlobalConfig { featureFlags?: Record; + profile?: Profile; + delivery?: Delivery; + workflows?: string[]; } const DEFAULT_CONFIG: GlobalConfig = { - featureFlags: {} + featureFlags: {}, + profile: 'core', + delivery: 'both', }; /** @@ -101,7 +110,7 @@ export function getGlobalConfig(): GlobalConfig { const parsed = JSON.parse(content); // Merge with defaults (loaded values take precedence) - return { + const merged: GlobalConfig = { ...DEFAULT_CONFIG, ...parsed, // Deep merge featureFlags @@ -110,6 +119,16 @@ export function getGlobalConfig(): GlobalConfig { ...(parsed.featureFlags || {}) } }; + + // Schema evolution: apply defaults for new fields if not present in loaded config + if (parsed.profile === undefined) { + merged.profile = DEFAULT_CONFIG.profile; + } + if (parsed.delivery === undefined) { + merged.delivery = DEFAULT_CONFIG.delivery; + } + + return merged; } catch (error) { // Log warning for parse errors, but not for missing files if (error instanceof SyntaxError) { diff --git a/src/core/init.ts b/src/core/init.ts index ff314c120..0d18dbfe2 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -41,6 +41,10 @@ import { generateSkillContent, type ToolSkillStatus, } from './shared/index.js'; +import { getGlobalConfig, type Delivery, type Profile } from './global-config.js'; +import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js'; +import { getAvailableTools } from './available-tools.js'; +import { migrateIfNeeded } from './migration.js'; const require = createRequire(import.meta.url); const { version: OPENSPEC_VERSION } = require('../../package.json'); @@ -56,6 +60,20 @@ const PROGRESS_SPINNER = { frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'], }; +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', +}; + // ----------------------------------------------------------------------------- // Types // ----------------------------------------------------------------------------- @@ -64,6 +82,7 @@ type InitCommandOptions = { tools?: string; force?: boolean; interactive?: boolean; + profile?: string; }; // ----------------------------------------------------------------------------- @@ -74,11 +93,13 @@ export class InitCommand { private readonly toolsArg?: string; private readonly force: boolean; private readonly interactiveOption?: boolean; + private readonly profileOverride?: string; constructor(options: InitCommandOptions = {}) { this.toolsArg = options.tools; this.force = options.force ?? false; this.interactiveOption = options.interactive; + this.profileOverride = options.profile; } async execute(targetPath: string): Promise { @@ -92,6 +113,14 @@ export class InitCommand { // Check for legacy artifacts and handle cleanup await this.handleLegacyCleanup(projectPath, extendMode); + // Detect available tools in the project (task 7.1) + const detectedTools = getAvailableTools(projectPath); + + // Migration check: migrate existing projects to profile system (task 7.3) + if (extendMode) { + migrateIfNeeded(projectPath, detectedTools); + } + // Show animated welcome screen (interactive mode only) const canPrompt = this.canPromptInteractively(); if (canPrompt) { @@ -99,11 +128,30 @@ 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; + } + } + // Get tool states before processing const toolStates = getToolStates(projectPath); - // Get tool selection - const selectedToolIds = await this.getSelectedTools(toolStates, extendMode); + // Get tool selection (pass detected tools for pre-selection) + const selectedToolIds = await this.getSelectedTools(toolStates, extendMode, detectedTools, projectPath); // Validate selected tools const validatedTools = this.validateTools(selectedToolIds, toolStates); @@ -144,6 +192,18 @@ export class InitCommand { return isInteractive({ interactive: this.interactiveOption }); } + private resolveProfileOverride(): Profile | undefined { + if (this.profileOverride === undefined) { + return undefined; + } + + if (this.profileOverride === 'core' || this.profileOverride === 'custom') { + return this.profileOverride; + } + + throw new Error(`Invalid profile "${this.profileOverride}". Available profiles: core, custom`); + } + // ═══════════════════════════════════════════════════════════ // LEGACY CLEANUP // ═══════════════════════════════════════════════════════════ @@ -214,7 +274,9 @@ export class InitCommand { private async getSelectedTools( toolStates: Map, - extendMode: boolean + extendMode: boolean, + detectedTools: AIToolOption[], + projectPath: string ): Promise { // Check for --tools flag first const nonInteractiveSelection = this.resolveToolsArg(); @@ -223,38 +285,56 @@ export class InitCommand { } const validTools = getToolsWithSkillsDir(); + const detectedToolIds = new Set(detectedTools.map((t) => t.value)); const canPrompt = this.canPromptInteractively(); - if (!canPrompt || validTools.length === 0) { + // Non-interactive mode: use detected tools as fallback (task 7.8) + if (!canPrompt) { + if (detectedToolIds.size > 0) { + return [...detectedToolIds]; + } + throw new Error( + `No tools detected and no --tools flag provided. Valid tools:\n ${validTools.join('\n ')}\n\nUse --tools all, --tools none, or --tools claude,cursor,...` + ); + } + + if (validTools.length === 0) { throw new Error( - `Missing required option --tools. Valid tools:\n ${validTools.join('\n ')}\n\nUse --tools all, --tools none, or --tools claude,cursor,...` + `No tools available for skill generation.` ); } // Interactive mode: show searchable multi-select const { searchableMultiSelect } = await import('../prompts/searchable-multi-select.js'); - // Build choices with configured status and sort configured tools first + // Build choices: pre-select detected tools AND configured tools (task 7.4) const sortedChoices = validTools .map((toolId) => { const tool = AI_TOOLS.find((t) => t.value === toolId); const status = toolStates.get(toolId); const configured = status?.configured ?? false; + const detected = detectedToolIds.has(toolId); return { name: tool?.name || toolId, value: toolId, configured, - preSelected: configured, // Pre-select configured tools for easy refresh + preSelected: configured || detected, // Pre-select both configured and detected tools }; }) .sort((a, b) => { - // Configured tools first - if (a.configured && !b.configured) return -1; - if (!a.configured && b.configured) return 1; + // Pre-selected tools first (configured or detected) + if (a.preSelected && !b.preSelected) return -1; + if (!a.preSelected && b.preSelected) 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 selectedTools = await searchableMultiSelect({ message: `Select tools to set up (${validTools.length} available)`, pageSize: 15, @@ -417,49 +497,73 @@ export class InitCommand { refreshedTools: typeof tools; failedTools: Array<{ name: string; error: Error }>; commandsSkipped: string[]; + removedCommandCount: number; + removedSkillCount: number; }> { const createdTools: typeof tools = []; const refreshedTools: typeof tools = []; const failedTools: Array<{ name: string; error: Error }> = []; const commandsSkipped: string[] = []; + let removedCommandCount = 0; + let removedSkillCount = 0; - // Get skill and command templates once (shared across all tools) - const skillTemplates = getSkillTemplates(); - const commandContents = getCommandContents(); + // Read global config for profile and delivery settings (use --profile override if set) + const globalConfig = getGlobalConfig(); + const profile: Profile = this.resolveProfileOverride() ?? globalConfig.profile ?? 'core'; + const delivery: Delivery = globalConfig.delivery ?? 'both'; + const workflows = getProfileWorkflows(profile, globalConfig.workflows); + + // Get skill and command templates filtered by profile workflows + const shouldGenerateSkills = delivery !== 'commands'; + const shouldGenerateCommands = delivery !== 'skills'; + const skillTemplates = shouldGenerateSkills ? getSkillTemplates(workflows) : []; + const commandContents = shouldGenerateCommands ? getCommandContents(workflows) : []; // Process each tool for (const tool of tools) { const spinner = ora(`Setting up ${tool.name}...`).start(); try { - // Use tool-specific skillsDir - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); - - // Create skill directories and SKILL.md files - for (const { template, dirName } of skillTemplates) { - const skillDir = path.join(skillsDir, dirName); - const skillFile = path.join(skillDir, 'SKILL.md'); - - // Generate SKILL.md content with YAML frontmatter including generatedBy - // Use hyphen-based command references for OpenCode - const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined; - const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); - - // Write the skill file - await FileSystemUtils.writeFile(skillFile, skillContent); + // Generate skill files if delivery includes skills + if (shouldGenerateSkills) { + // Use tool-specific skillsDir + const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + + // Create skill directories and SKILL.md files + for (const { template, dirName } of skillTemplates) { + const skillDir = path.join(skillsDir, dirName); + const skillFile = path.join(skillDir, 'SKILL.md'); + + // Generate SKILL.md content with YAML frontmatter including generatedBy + // Use hyphen-based command references for OpenCode + const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined; + const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); + + // Write the skill file + await FileSystemUtils.writeFile(skillFile, skillContent); + } + } + if (!shouldGenerateSkills) { + const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + removedSkillCount += await this.removeSkillDirs(skillsDir); } - // Generate commands using the adapter system - 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); + // Generate commands if 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); + } + } else { + commandsSkipped.push(tool.value); } - } else { - commandsSkipped.push(tool.value); + } + if (!shouldGenerateCommands) { + removedCommandCount += await this.removeCommandFiles(projectPath, tool.value); } spinner.succeed(`Setup complete for ${tool.name}`); @@ -475,7 +579,14 @@ export class InitCommand { } } - return { createdTools, refreshedTools, failedTools, commandsSkipped }; + return { + createdTools, + refreshedTools, + failedTools, + commandsSkipped, + removedCommandCount, + removedSkillCount, + }; } // ═══════════════════════════════════════════════════════════ @@ -518,6 +629,8 @@ export class InitCommand { refreshedTools: typeof tools; failedTools: Array<{ name: string; error: Error }>; commandsSkipped: string[]; + removedCommandCount: number; + removedSkillCount: number; }, configStatus: 'created' | 'exists' | 'skipped' ): void { @@ -533,15 +646,22 @@ export class InitCommand { console.log(`Refreshed: ${results.refreshedTools.map((t) => t.name).join(', ')}`); } - // Show counts + // Show counts (respecting profile filter) const successfulTools = [...results.createdTools, ...results.refreshedTools]; if (successfulTools.length > 0) { + const globalConfig = getGlobalConfig(); + const profile: Profile = (this.profileOverride as Profile) ?? globalConfig.profile ?? 'core'; + const delivery: Delivery = globalConfig.delivery ?? 'both'; + const workflows = getProfileWorkflows(profile, globalConfig.workflows); const toolDirs = [...new Set(successfulTools.map((t) => t.skillsDir))].join(', '); - const hasCommands = results.commandsSkipped.length < successfulTools.length; - if (hasCommands) { - console.log(`${getSkillTemplates().length} skills and ${getCommandContents().length} commands in ${toolDirs}/`); - } else { - console.log(`${getSkillTemplates().length} skills in ${toolDirs}/`); + const skillCount = delivery !== 'commands' ? getSkillTemplates(workflows).length : 0; + const commandCount = delivery !== 'skills' ? getCommandContents(workflows).length : 0; + if (skillCount > 0 && commandCount > 0) { + console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`); + } else if (skillCount > 0) { + console.log(`${skillCount} skills in ${toolDirs}/`); + } else if (commandCount > 0) { + console.log(`${commandCount} commands in ${toolDirs}/`); } } @@ -554,6 +674,12 @@ export class InitCommand { if (results.commandsSkipped.length > 0) { console.log(chalk.dim(`Commands skipped for: ${results.commandsSkipped.join(', ')} (no adapter)`)); } + if (results.removedCommandCount > 0) { + console.log(chalk.dim(`Removed: ${results.removedCommandCount} command files (delivery: skills)`)); + } + if (results.removedSkillCount > 0) { + console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`)); + } // Config status if (configStatus === 'created') { @@ -568,12 +694,20 @@ export class InitCommand { console.log(chalk.dim(`Config: skipped (non-interactive mode)`)); } - // Getting started + // Getting started (task 7.6: show propose if in profile) + const globalCfg = getGlobalConfig(); + const activeProfile: Profile = (this.profileOverride as Profile) ?? globalCfg.profile ?? 'core'; + const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)]; console.log(); - console.log(chalk.bold('Getting started:')); - console.log(' /opsx:new Start a new change'); - console.log(' /opsx:continue Create the next artifact'); - console.log(' /opsx:apply Implement tasks'); + if (activeWorkflows.includes('propose')) { + console.log(chalk.bold('Getting started:')); + console.log(' Start your first change: /opsx:propose "your idea"'); + } else if (activeWorkflows.includes('new')) { + console.log(chalk.bold('Getting started:')); + console.log(' Start your first change: /opsx:new "your idea"'); + } else { + console.log("Done. Run 'openspec config profile' to configure your workflows."); + } // Links console.log(); @@ -597,4 +731,47 @@ export class InitCommand { spinner: PROGRESS_SPINNER, }).start(); } + + private async removeSkillDirs(skillsDir: string): Promise { + let removed = 0; + + for (const workflow of ALL_WORKFLOWS) { + const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; + if (!dirName) continue; + + const skillDir = path.join(skillsDir, dirName); + try { + if (fs.existsSync(skillDir)) { + await fs.promises.rm(skillDir, { recursive: true, force: true }); + removed++; + } + } catch { + // Ignore errors + } + } + + return removed; + } + + private async removeCommandFiles(projectPath: string, toolId: string): Promise { + let removed = 0; + const adapter = CommandAdapterRegistry.get(toolId); + if (!adapter) return 0; + + for (const workflow of ALL_WORKFLOWS) { + const cmdPath = adapter.getFilePath(workflow); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + + try { + if (fs.existsSync(fullPath)) { + await fs.promises.unlink(fullPath); + removed++; + } + } catch { + // Ignore errors + } + } + + return removed; + } } diff --git a/src/core/migration.ts b/src/core/migration.ts new file mode 100644 index 000000000..dc6e9c585 --- /dev/null +++ b/src/core/migration.ts @@ -0,0 +1,96 @@ +/** + * Migration Utilities + * + * One-time migration logic for existing projects when profile system is introduced. + * Called by both init and update commands before profile resolution. + */ + +import type { AIToolOption } from './config.js'; +import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig } from './global-config.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 = { + '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', +}; + +/** + * Scans installed workflow files across all detected tools and returns + * the union of installed workflow IDs. + */ +export function scanInstalledWorkflows(projectPath: string, tools: AIToolOption[]): string[] { + const installed = new Set(); + + for (const tool of tools) { + if (!tool.skillsDir) continue; + const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + + for (const [workflowId, skillDirName] of Object.entries(WORKFLOW_TO_SKILL_DIR)) { + const skillFile = path.join(skillsDir, skillDirName, 'SKILL.md'); + if (fs.existsSync(skillFile)) { + installed.add(workflowId); + } + } + } + + return [...installed]; +} + +/** + * Performs one-time migration if the global config does not yet have a profile field. + * Called by both init and update before profile resolution. + * + * - If no profile field exists and workflows are installed: sets profile to 'custom' + * with the detected workflows, preserving the user's existing setup. + * - If no profile field exists and no workflows are installed: no-op (defaults apply). + * - If profile field already exists: no-op. + */ +export function migrateIfNeeded(projectPath: string, tools: AIToolOption[]): void { + const config = getGlobalConfig(); + + // Check raw config file for profile field presence + const configPath = getGlobalConfigPath(); + + let rawConfig: Record = {}; + try { + if (fs.existsSync(configPath)) { + rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } + } catch { + return; // Can't read config, skip migration + } + + // If profile is already explicitly set, no migration needed + if (rawConfig.profile !== undefined) { + return; + } + + // Scan for installed workflows + const installedWorkflows = scanInstalledWorkflows(projectPath, tools); + + if (installedWorkflows.length === 0) { + // No workflows installed, new user — defaults will apply + return; + } + + // Migrate: set profile to custom with detected workflows + config.profile = 'custom'; + config.workflows = installedWorkflows; + saveGlobalConfig(config); + + console.log(`Migrated: custom profile with ${installedWorkflows.length} workflows`); + console.log("New in this version: /opsx:propose. Try 'openspec config profile core' for the streamlined experience."); +} diff --git a/src/core/profiles.ts b/src/core/profiles.ts new file mode 100644 index 000000000..f61215dfc --- /dev/null +++ b/src/core/profiles.ts @@ -0,0 +1,50 @@ +/** + * Profile System + * + * Defines workflow profiles that control which workflows are installed. + * Profiles determine WHICH workflows; delivery (in global config) determines HOW. + */ + +import type { Profile } from './global-config.js'; + +/** + * Core workflows included in the 'core' profile. + * These provide the streamlined experience for new users. + */ +export const CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'archive'] as const; + +/** + * All available workflows in the system. + */ +export const ALL_WORKFLOWS = [ + 'propose', + 'explore', + 'new', + 'continue', + 'apply', + 'ff', + 'sync', + 'archive', + 'bulk-archive', + 'verify', + 'onboard', +] as const; + +export type WorkflowId = (typeof ALL_WORKFLOWS)[number]; +export type CoreWorkflowId = (typeof CORE_WORKFLOWS)[number]; + +/** + * Resolves which workflows should be active for a given profile configuration. + * + * - 'core' profile always returns CORE_WORKFLOWS + * - 'custom' profile returns the provided customWorkflows, or empty array if not provided + */ +export function getProfileWorkflows( + profile: Profile, + customWorkflows?: string[] +): readonly string[] { + if (profile === 'custom') { + return customWorkflows ?? []; + } + return CORE_WORKFLOWS; +} diff --git a/src/core/shared/index.ts b/src/core/shared/index.ts index 8ff856051..32b965696 100644 --- a/src/core/shared/index.ts +++ b/src/core/shared/index.ts @@ -7,6 +7,8 @@ export { SKILL_NAMES, type SkillName, + COMMAND_IDS, + type CommandId, type ToolSkillStatus, type ToolVersionStatus, getToolsWithSkillsDir, diff --git a/src/core/shared/skill-generation.ts b/src/core/shared/skill-generation.ts index 6d6172277..898e7a25e 100644 --- a/src/core/shared/skill-generation.ts +++ b/src/core/shared/skill-generation.ts @@ -15,6 +15,7 @@ import { getBulkArchiveChangeSkillTemplate, getVerifyChangeSkillTemplate, getOnboardSkillTemplate, + getOpsxProposeSkillTemplate, getOpsxExploreCommandTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, @@ -25,16 +26,18 @@ import { getOpsxBulkArchiveCommandTemplate, getOpsxVerifyCommandTemplate, getOpsxOnboardCommandTemplate, + getOpsxProposeCommandTemplate, type SkillTemplate, } from '../templates/skill-templates.js'; import type { CommandContent } from '../command-generation/index.js'; /** - * Skill template with directory name mapping. + * Skill template with directory name and workflow ID mapping. */ export interface SkillTemplateEntry { template: SkillTemplate; dirName: string; + workflowId: string; } /** @@ -46,28 +49,38 @@ export interface CommandTemplateEntry { } /** - * Gets all skill templates with their directory names. + * Gets skill templates with their directory names, optionally filtered by workflow IDs. + * + * @param workflowFilter - If provided, only return templates whose workflowId is in this array */ -export function getSkillTemplates(): SkillTemplateEntry[] { - return [ - { template: getExploreSkillTemplate(), dirName: 'openspec-explore' }, - { template: getNewChangeSkillTemplate(), dirName: 'openspec-new-change' }, - { template: getContinueChangeSkillTemplate(), dirName: 'openspec-continue-change' }, - { template: getApplyChangeSkillTemplate(), dirName: 'openspec-apply-change' }, - { template: getFfChangeSkillTemplate(), dirName: 'openspec-ff-change' }, - { template: getSyncSpecsSkillTemplate(), dirName: 'openspec-sync-specs' }, - { template: getArchiveChangeSkillTemplate(), dirName: 'openspec-archive-change' }, - { template: getBulkArchiveChangeSkillTemplate(), dirName: 'openspec-bulk-archive-change' }, - { template: getVerifyChangeSkillTemplate(), dirName: 'openspec-verify-change' }, - { template: getOnboardSkillTemplate(), dirName: 'openspec-onboard' }, +export function getSkillTemplates(workflowFilter?: readonly string[]): SkillTemplateEntry[] { + const all: SkillTemplateEntry[] = [ + { template: getExploreSkillTemplate(), dirName: 'openspec-explore', workflowId: 'explore' }, + { template: getNewChangeSkillTemplate(), dirName: 'openspec-new-change', workflowId: 'new' }, + { template: getContinueChangeSkillTemplate(), dirName: 'openspec-continue-change', workflowId: 'continue' }, + { template: getApplyChangeSkillTemplate(), dirName: 'openspec-apply-change', workflowId: 'apply' }, + { template: getFfChangeSkillTemplate(), dirName: 'openspec-ff-change', workflowId: 'ff' }, + { template: getSyncSpecsSkillTemplate(), dirName: 'openspec-sync-specs', workflowId: 'sync' }, + { template: getArchiveChangeSkillTemplate(), dirName: 'openspec-archive-change', workflowId: 'archive' }, + { template: getBulkArchiveChangeSkillTemplate(), dirName: 'openspec-bulk-archive-change', workflowId: 'bulk-archive' }, + { template: getVerifyChangeSkillTemplate(), dirName: 'openspec-verify-change', workflowId: 'verify' }, + { template: getOnboardSkillTemplate(), dirName: 'openspec-onboard', workflowId: 'onboard' }, + { template: getOpsxProposeSkillTemplate(), dirName: 'openspec-propose', workflowId: 'propose' }, ]; + + if (!workflowFilter) return all; + + const filterSet = new Set(workflowFilter); + return all.filter(entry => filterSet.has(entry.workflowId)); } /** - * Gets all command templates with their IDs. + * Gets command templates with their IDs, optionally filtered by workflow IDs. + * + * @param workflowFilter - If provided, only return templates whose id is in this array */ -export function getCommandTemplates(): CommandTemplateEntry[] { - return [ +export function getCommandTemplates(workflowFilter?: readonly string[]): CommandTemplateEntry[] { + const all: CommandTemplateEntry[] = [ { template: getOpsxExploreCommandTemplate(), id: 'explore' }, { template: getOpsxNewCommandTemplate(), id: 'new' }, { template: getOpsxContinueCommandTemplate(), id: 'continue' }, @@ -78,14 +91,22 @@ export function getCommandTemplates(): CommandTemplateEntry[] { { template: getOpsxBulkArchiveCommandTemplate(), id: 'bulk-archive' }, { template: getOpsxVerifyCommandTemplate(), id: 'verify' }, { template: getOpsxOnboardCommandTemplate(), id: 'onboard' }, + { template: getOpsxProposeCommandTemplate(), id: 'propose' }, ]; + + if (!workflowFilter) return all; + + const filterSet = new Set(workflowFilter); + return all.filter(entry => filterSet.has(entry.id)); } /** - * Converts command templates to CommandContent array. + * Converts command templates to CommandContent array, optionally filtered by workflow IDs. + * + * @param workflowFilter - If provided, only return contents whose id is in this array */ -export function getCommandContents(): CommandContent[] { - const commandTemplates = getCommandTemplates(); +export function getCommandContents(workflowFilter?: readonly string[]): CommandContent[] { + const commandTemplates = getCommandTemplates(workflowFilter); return commandTemplates.map(({ template, id }) => ({ id, name: template.name, diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index b9b39ac95..72a0ebc8a 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -21,19 +21,40 @@ export const SKILL_NAMES = [ 'openspec-archive-change', 'openspec-bulk-archive-change', 'openspec-verify-change', + 'openspec-onboard', + 'openspec-propose', ] as const; export type SkillName = (typeof SKILL_NAMES)[number]; +/** + * IDs of command templates created by openspec init. + */ +export const COMMAND_IDS = [ + 'explore', + 'new', + 'continue', + 'apply', + 'ff', + 'sync', + 'archive', + 'bulk-archive', + 'verify', + 'onboard', + 'propose', +] as const; + +export type CommandId = (typeof COMMAND_IDS)[number]; + /** * Status of skill configuration for a tool. */ export interface ToolSkillStatus { /** Whether the tool has any skills configured */ configured: boolean; - /** Whether all 9 skills are configured */ + /** Whether all skills are configured */ fullyConfigured: boolean; - /** Number of skills currently configured (0-9) */ + /** Number of skills currently configured */ skillCount: number; } diff --git a/src/core/templates/skill-templates.ts b/src/core/templates/skill-templates.ts index 3bf7a1c42..ff687d900 100644 --- a/src/core/templates/skill-templates.ts +++ b/src/core/templates/skill-templates.ts @@ -16,4 +16,5 @@ export { getArchiveChangeSkillTemplate, getOpsxArchiveCommandTemplate } from './ export { getBulkArchiveChangeSkillTemplate, getOpsxBulkArchiveCommandTemplate } from './workflows/bulk-archive-change.js'; export { getVerifyChangeSkillTemplate, getOpsxVerifyCommandTemplate } from './workflows/verify-change.js'; export { getOnboardSkillTemplate, getOpsxOnboardCommandTemplate } from './workflows/onboard.js'; +export { getOpsxProposeSkillTemplate, getOpsxProposeCommandTemplate } from './workflows/propose.js'; export { getFeedbackSkillTemplate } from './workflows/feedback.js'; diff --git a/src/core/templates/workflows/bulk-archive-change.ts b/src/core/templates/workflows/bulk-archive-change.ts index b9c813e7e..d57db8aa0 100644 --- a/src/core/templates/workflows/bulk-archive-change.ts +++ b/src/core/templates/workflows/bulk-archive-change.ts @@ -230,7 +230,7 @@ Failed K changes: \`\`\` ## No Changes to Archive -No active changes found. Use \`/opsx:new\` to create a new change. +No active changes found. Create a new change to get started. \`\`\` **Guardrails** @@ -477,7 +477,7 @@ Failed K changes: \`\`\` ## No Changes to Archive -No active changes found. Use \`/opsx:new\` to create a new change. +No active changes found. Create a new change to get started. \`\`\` **Guardrails** diff --git a/src/core/templates/workflows/explore.ts b/src/core/templates/workflows/explore.ts index a4eaa733e..059d2ecae 100644 --- a/src/core/templates/workflows/explore.ts +++ b/src/core/templates/workflows/explore.ts @@ -12,7 +12,7 @@ export function getExploreSkillTemplate(): SkillTemplate { description: 'Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.', instructions: `Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. -**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with \`/opsx:new\` or \`/opsx:ff\`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing. +**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing. **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. @@ -96,8 +96,7 @@ This tells you: Think freely. When insights crystallize, you might offer: -- "This feels solid enough to start a change. Want me to create one?" - → Can transition to \`/opsx:new\` or \`/opsx:ff\` +- "This feels solid enough to start a change. Want me to create a proposal?" - Or keep exploring - no pressure to formalize ### When a change exists @@ -253,7 +252,7 @@ You: That changes everything. There's no required ending. Discovery might: -- **Flow into action**: "Ready to start? /opsx:new or /opsx:ff" +- **Flow into a proposal**: "Ready to start? I can create a change proposal." - **Result in artifact updates**: "Updated design.md with these decisions" - **Just provide clarity**: User has what they need, moves on - **Continue later**: "We can pick this up anytime" @@ -270,8 +269,7 @@ When it feels like things are crystallizing, you might summarize: **Open questions**: [if any remain] **Next steps** (if ready): -- Create a change: /opsx:new -- Fast-forward to tasks: /opsx:ff +- Create a change proposal - Keep exploring: just keep talking \`\`\` @@ -303,7 +301,7 @@ export function getOpsxExploreCommandTemplate(): CommandTemplate { tags: ['workflow', 'explore', 'experimental', 'thinking'], content: `Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes. -**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with \`/opsx:new\` or \`/opsx:ff\`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing. +**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing. **This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore. @@ -396,8 +394,7 @@ If the user mentioned a specific change name, read its artifacts for context. Think freely. When insights crystallize, you might offer: -- "This feels solid enough to start a change. Want me to create one?" - → Can transition to \`/opsx:new\` or \`/opsx:ff\` +- "This feels solid enough to start a change. Want me to create a proposal?" - Or keep exploring - no pressure to formalize ### When a change exists @@ -449,7 +446,7 @@ If the user mentions a change or you detect one is relevant: There's no required ending. Discovery might: -- **Flow into action**: "Ready to start? \`/opsx:new\` or \`/opsx:ff\`" +- **Flow into a proposal**: "Ready to start? I can create a change proposal." - **Result in artifact updates**: "Updated design.md with these decisions" - **Just provide clarity**: User has what they need, moves on - **Continue later**: "We can pick this up anytime" diff --git a/src/core/templates/workflows/onboard.ts b/src/core/templates/workflows/onboard.ts index 8e49fedf8..f9280fa07 100644 --- a/src/core/templates/workflows/onboard.ts +++ b/src/core/templates/workflows/onboard.ts @@ -475,21 +475,29 @@ This same rhythm works for any size change—a small fix or a major feature. ## Command Reference +**Core workflow:** + | Command | What it does | |---------|--------------| +| \`/opsx:propose\` | Create a change and generate all artifacts | | \`/opsx:explore\` | Think through problems before/during work | -| \`/opsx:new\` | Start a new change, step through artifacts | -| \`/opsx:ff\` | Fast-forward: create all artifacts at once | -| \`/opsx:continue\` | Continue working on an existing change | | \`/opsx:apply\` | Implement tasks from a change | -| \`/opsx:verify\` | Verify implementation matches artifacts | | \`/opsx:archive\` | Archive a completed change | +**Additional commands:** + +| Command | What it does | +|---------|--------------| +| \`/opsx:new\` | Start a new change, step through artifacts one at a time | +| \`/opsx:continue\` | Continue working on an existing change | +| \`/opsx:ff\` | Fast-forward: create all artifacts at once | +| \`/opsx:verify\` | Verify implementation matches artifacts | + --- ## What's Next? -Try \`/opsx:new\` or \`/opsx:ff\` on something you actually want to build. You've got the rhythm now! +Try \`/opsx:propose\` on something you actually want to build. You've got the rhythm now! \`\`\` --- @@ -519,17 +527,25 @@ If the user says they just want to see the commands or skip the tutorial: \`\`\` ## OpenSpec Quick Reference +**Core workflow:** + | Command | What it does | |---------|--------------| +| \`/opsx:propose \` | Create a change and generate all artifacts | | \`/opsx:explore\` | Think through problems (no code changes) | +| \`/opsx:apply \` | Implement tasks | +| \`/opsx:archive \` | Archive when done | + +**Additional commands:** + +| Command | What it does | +|---------|--------------| | \`/opsx:new \` | Start a new change, step by step | -| \`/opsx:ff \` | Fast-forward: all artifacts at once | | \`/opsx:continue \` | Continue an existing change | -| \`/opsx:apply \` | Implement tasks | +| \`/opsx:ff \` | Fast-forward: all artifacts at once | | \`/opsx:verify \` | Verify implementation | -| \`/opsx:archive \` | Archive when done | -Try \`/opsx:new\` to start your first change, or \`/opsx:ff\` if you want to move fast. +Try \`/opsx:propose\` to start your first change. \`\`\` Exit gracefully. diff --git a/src/core/templates/workflows/propose.ts b/src/core/templates/workflows/propose.ts new file mode 100644 index 000000000..74a9ce2d0 --- /dev/null +++ b/src/core/templates/workflows/propose.ts @@ -0,0 +1,224 @@ +/** + * Skill Template Workflow Modules + * + * This file is generated by splitting the legacy monolithic + * templates file into workflow-focused modules. + */ +import type { SkillTemplate, CommandTemplate } from '../types.js'; + +export function getOpsxProposeSkillTemplate(): SkillTemplate { + return { + name: 'openspec-propose', + description: 'Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.', + instructions: `Propose a new change - create the change and generate all artifacts in one step. + +I'll create a change with artifacts: +- proposal.md (what & why) +- design.md (how) +- tasks.md (implementation steps) + +When ready to implement, run /opsx:apply + +--- + +**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. + +**Steps** + +1. **If no clear input provided, ask what they want to build** + + Use the **AskUserQuestion tool** (open-ended, no preset options) to ask: + > "What change do you want to work on? Describe what you want to build or fix." + + From their description, derive a kebab-case name (e.g., "add user authentication" → \`add-user-auth\`). + + **IMPORTANT**: Do NOT proceed without understanding what the user wants to build. + +2. **Create the change directory** + \`\`\`bash + openspec new change "" + \`\`\` + This creates a scaffolded change at \`openspec/changes//\` with \`.openspec.yaml\`. + +3. **Get the artifact build order** + \`\`\`bash + openspec status --change "" --json + \`\`\` + Parse the JSON to get: + - \`applyRequires\`: array of artifact IDs needed before implementation (e.g., \`["tasks"]\`) + - \`artifacts\`: list of all artifacts with their status and dependencies + +4. **Create artifacts in sequence until apply-ready** + + Use the **TodoWrite tool** to track progress through the artifacts. + + Loop through artifacts in dependency order (artifacts with no pending dependencies first): + + a. **For each artifact that is \`ready\` (dependencies satisfied)**: + - Get instructions: + \`\`\`bash + openspec instructions --change "" --json + \`\`\` + - The instructions JSON includes: + - \`context\`: Project background (constraints for you - do NOT include in output) + - \`rules\`: Artifact-specific rules (constraints for you - do NOT include in output) + - \`template\`: The structure to use for your output file + - \`instruction\`: Schema-specific guidance for this artifact type + - \`outputPath\`: Where to write the artifact + - \`dependencies\`: Completed artifacts to read for context + - Read any completed dependency files for context + - Create the artifact file using \`template\` as the structure + - Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file + - Show brief progress: "Created " + + b. **Continue until all \`applyRequires\` artifacts are complete** + - After creating each artifact, re-run \`openspec status --change "" --json\` + - Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array + - Stop when all \`applyRequires\` artifacts are done + + c. **If an artifact requires user input** (unclear context): + - Use **AskUserQuestion tool** to clarify + - Then continue with creation + +5. **Show final status** + \`\`\`bash + openspec status --change "" + \`\`\` + +**Output** + +After completing all artifacts, summarize: +- Change name and location +- List of artifacts created with brief descriptions +- What's ready: "All artifacts created! Ready for implementation." +- Prompt: "Run \`/opsx:apply\` or ask me to implement to start working on the tasks." + +**Artifact Creation Guidelines** + +- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type +- The schema defines what each artifact should contain - follow it +- Read dependency artifacts for context before creating new ones +- Use \`template\` as the structure for your output file - fill in its sections +- **IMPORTANT**: \`context\` and \`rules\` are constraints for YOU, not content for the file + - Do NOT copy \`\`, \`\`, \`\` blocks into the artifact + - These guide what you write, but should never appear in the output + +**Guardrails** +- Create ALL artifacts needed for implementation (as defined by schema's \`apply.requires\`) +- Always read dependency artifacts before creating a new one +- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum +- If a change with that name already exists, ask if user wants to continue it or create a new one +- Verify each artifact file exists after writing before proceeding to next`, + license: 'MIT', + compatibility: 'Requires openspec CLI.', + metadata: { author: 'openspec', version: '1.0' }, + }; +} + +export function getOpsxProposeCommandTemplate(): CommandTemplate { + return { + name: 'OPSX: Propose', + description: 'Propose a new change - create it and generate all artifacts in one step', + category: 'Workflow', + tags: ['workflow', 'artifacts', 'experimental'], + content: `Propose a new change - create the change and generate all artifacts in one step. + +I'll create a change with artifacts: +- proposal.md (what & why) +- design.md (how) +- tasks.md (implementation steps) + +When ready to implement, run /opsx:apply + +--- + +**Input**: The argument after \`/opsx:propose\` is the change name (kebab-case), OR a description of what the user wants to build. + +**Steps** + +1. **If no input provided, ask what they want to build** + + Use the **AskUserQuestion tool** (open-ended, no preset options) to ask: + > "What change do you want to work on? Describe what you want to build or fix." + + From their description, derive a kebab-case name (e.g., "add user authentication" → \`add-user-auth\`). + + **IMPORTANT**: Do NOT proceed without understanding what the user wants to build. + +2. **Create the change directory** + \`\`\`bash + openspec new change "" + \`\`\` + This creates a scaffolded change at \`openspec/changes//\` with \`.openspec.yaml\`. + +3. **Get the artifact build order** + \`\`\`bash + openspec status --change "" --json + \`\`\` + Parse the JSON to get: + - \`applyRequires\`: array of artifact IDs needed before implementation (e.g., \`["tasks"]\`) + - \`artifacts\`: list of all artifacts with their status and dependencies + +4. **Create artifacts in sequence until apply-ready** + + Use the **TodoWrite tool** to track progress through the artifacts. + + Loop through artifacts in dependency order (artifacts with no pending dependencies first): + + a. **For each artifact that is \`ready\` (dependencies satisfied)**: + - Get instructions: + \`\`\`bash + openspec instructions --change "" --json + \`\`\` + - The instructions JSON includes: + - \`context\`: Project background (constraints for you - do NOT include in output) + - \`rules\`: Artifact-specific rules (constraints for you - do NOT include in output) + - \`template\`: The structure to use for your output file + - \`instruction\`: Schema-specific guidance for this artifact type + - \`outputPath\`: Where to write the artifact + - \`dependencies\`: Completed artifacts to read for context + - Read any completed dependency files for context + - Create the artifact file using \`template\` as the structure + - Apply \`context\` and \`rules\` as constraints - but do NOT copy them into the file + - Show brief progress: "Created " + + b. **Continue until all \`applyRequires\` artifacts are complete** + - After creating each artifact, re-run \`openspec status --change "" --json\` + - Check if every artifact ID in \`applyRequires\` has \`status: "done"\` in the artifacts array + - Stop when all \`applyRequires\` artifacts are done + + c. **If an artifact requires user input** (unclear context): + - Use **AskUserQuestion tool** to clarify + - Then continue with creation + +5. **Show final status** + \`\`\`bash + openspec status --change "" + \`\`\` + +**Output** + +After completing all artifacts, summarize: +- Change name and location +- List of artifacts created with brief descriptions +- What's ready: "All artifacts created! Ready for implementation." +- Prompt: "Run \`/opsx:apply\` to start implementing." + +**Artifact Creation Guidelines** + +- Follow the \`instruction\` field from \`openspec instructions\` for each artifact type +- The schema defines what each artifact should contain - follow it +- Read dependency artifacts for context before creating new ones +- Use \`template\` as the structure for your output file - fill in its sections +- **IMPORTANT**: \`context\` and \`rules\` are constraints for YOU, not content for the file + - Do NOT copy \`\`, \`\`, \`\` blocks into the artifact + - These guide what you write, but should never appear in the output + +**Guardrails** +- Create ALL artifacts needed for implementation (as defined by schema's \`apply.requires\`) +- Always read dependency artifacts before creating a new one +- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum +- If a change with that name already exists, ask if user wants to continue it or create a new one +- Verify each artifact file exists after writing before proceeding to next` + }; +} diff --git a/src/core/update.ts b/src/core/update.ts index a9368a213..614dfda86 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -2,12 +2,13 @@ * Update Command * * Refreshes OpenSpec skills and commands for configured tools. - * Supports smart update detection to skip updates when already current. + * Supports profile-aware updates, delivery changes, migration, and smart update detection. */ import path from 'path'; import chalk from 'chalk'; import ora from 'ora'; +import * as fs from 'fs'; import { createRequire } from 'module'; import { FileSystemUtils } from '../utils/file-system.js'; import { transformToHyphenCommands } from '../utils/command-references.js'; @@ -17,8 +18,9 @@ import { CommandAdapterRegistry, } from './command-generation/index.js'; import { + COMMAND_IDS, getConfiguredTools, - getAllToolVersionStatus, + getToolVersionStatus, getSkillTemplates, getCommandContents, generateSkillContent, @@ -34,10 +36,34 @@ import { type LegacyDetectionResult, } from './legacy-cleanup.js'; 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 { + scanInstalledWorkflows as scanInstalledWorkflowsShared, + migrateIfNeeded as migrateIfNeededShared, +} from './migration.js'; 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. */ @@ -46,6 +72,19 @@ export interface UpdateCommandOptions { force?: boolean; } +/** + * Scans installed workflow skill directories across all configured tools in the project. + * Returns the union of detected workflow IDs that match ALL_WORKFLOWS. + * + * Wrapper around the shared migration module's scanInstalledWorkflows that accepts tool IDs. + */ +export function scanInstalledWorkflows(projectPath: string, toolIds: string[]): string[] { + const tools = toolIds + .map((id) => AI_TOOLS.find((t) => t.value === id)) + .filter((t): t is NonNullable => t != null); + return scanInstalledWorkflowsShared(projectPath, tools); +} + export class UpdateCommand { private readonly force: boolean; @@ -66,7 +105,7 @@ export class UpdateCommand { const newlyConfiguredTools = await this.handleLegacyCleanup(resolvedProjectPath); // 3. Find configured tools - const configuredTools = getConfiguredTools(resolvedProjectPath); + const configuredTools = this.getConfiguredToolsForUpdate(resolvedProjectPath); if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) { console.log(chalk.yellow('No configured tools found.')); @@ -74,35 +113,83 @@ export class UpdateCommand { return; } - // 4. Check version status for all configured tools - const toolStatuses = getAllToolVersionStatus(resolvedProjectPath, OPENSPEC_VERSION); - - // 5. Smart update detection - const toolsNeedingUpdate = toolStatuses.filter((s) => s.needsUpdate); - const toolsUpToDate = toolStatuses.filter((s) => !s.needsUpdate); - - if (!this.force && toolsNeedingUpdate.length === 0) { + // 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); + + // 5. Read global config for profile/delivery + const globalConfig = getGlobalConfig(); + const profile = globalConfig.profile ?? 'core'; + const delivery: Delivery = globalConfig.delivery ?? 'both'; + const profileWorkflows = getProfileWorkflows(profile, globalConfig.workflows); + const desiredWorkflows = profileWorkflows.filter((workflow): workflow is (typeof ALL_WORKFLOWS)[number] => + (ALL_WORKFLOWS as readonly string[]).includes(workflow) + ); + const shouldGenerateSkills = delivery !== 'commands'; + const shouldGenerateCommands = delivery !== 'skills'; + + // 6. Check version status for all configured tools + const commandConfiguredTools = this.getCommandConfiguredTools(resolvedProjectPath); + const commandConfiguredSet = new Set(commandConfiguredTools); + const toolStatuses = configuredTools.map((toolId) => { + const status = getToolVersionStatus(resolvedProjectPath, toolId, OPENSPEC_VERSION); + if (!status.configured && commandConfiguredSet.has(toolId)) { + return { ...status, configured: true }; + } + return status; + }); + const statusByTool = new Map(toolStatuses.map((status) => [status.toolId, status] as const)); + + // 7. Smart update detection + const toolsNeedingVersionUpdate = toolStatuses + .filter((s) => s.needsUpdate) + .map((s) => s.toolId); + const toolsNeedingConfigSync = configuredTools.filter((toolId) => + this.hasProfileOrDeliveryDrift( + resolvedProjectPath, + toolId, + desiredWorkflows, + shouldGenerateSkills, + shouldGenerateCommands + ) + ); + const toolsToUpdateSet = new Set([ + ...toolsNeedingVersionUpdate, + ...toolsNeedingConfigSync, + ]); + const toolsUpToDate = toolStatuses.filter((s) => !toolsToUpdateSet.has(s.toolId)); + + if (!this.force && toolsToUpdateSet.size === 0) { // All tools are up to date this.displayUpToDateMessage(toolStatuses); + + // Still check for new tool directories and extra workflows + this.detectNewTools(resolvedProjectPath, configuredTools); + this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows); return; } - // 6. Display update plan + // 8. Display update plan if (this.force) { console.log(`Force updating ${configuredTools.length} tool(s): ${configuredTools.join(', ')}`); } else { - this.displayUpdatePlan(toolsNeedingUpdate, toolsUpToDate); + this.displayUpdatePlan([...toolsToUpdateSet], statusByTool, toolsUpToDate); } console.log(); - // 7. Prepare templates - const skillTemplates = getSkillTemplates(); - const commandContents = getCommandContents(); + // 9. Determine what to generate based on delivery + const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : []; + const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : []; - // 8. Update tools (all if force, otherwise only those needing update) - const toolsToUpdate = this.force ? configuredTools : toolsNeedingUpdate.map((s) => s.toolId); + // 10. Update tools (all if force, otherwise only those needing update) + const toolsToUpdate = this.force ? configuredTools : [...toolsToUpdateSet]; const updatedTools: string[] = []; const failedTools: Array<{ name: string; error: string }> = []; + let removedCommandCount = 0; + let removedSkillCount = 0; for (const toolId of toolsToUpdate) { const tool = AI_TOOLS.find((t) => t.value === toolId); @@ -113,28 +200,42 @@ export class UpdateCommand { try { const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills'); - // Update skill files - for (const { template, dirName } of skillTemplates) { - const skillDir = path.join(skillsDir, dirName); - const skillFile = path.join(skillDir, 'SKILL.md'); + // Generate skill files if 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); + } } - // Update commands - const adapter = CommandAdapterRegistry.get(tool.value); - if (adapter) { - const generatedCommands = generateCommands(commandContents, adapter); + // Delete skill directories if delivery is commands-only + if (!shouldGenerateSkills) { + removedSkillCount += await this.removeSkillDirs(skillsDir); + } - for (const cmd of generatedCommands) { - const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path); - await FileSystemUtils.writeFile(commandFile, cmd.fileContent); + // Generate commands if 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(resolvedProjectPath, cmd.path); + await FileSystemUtils.writeFile(commandFile, cmd.fileContent); + } } } + // Delete command files if delivery is skills-only + if (!shouldGenerateCommands) { + removedCommandCount += await this.removeCommandFiles(resolvedProjectPath, toolId); + } + spinner.succeed(`Updated ${tool.name}`); updatedTools.push(tool.name); } catch (error) { @@ -146,7 +247,7 @@ export class UpdateCommand { } } - // 9. Summary + // 11. Summary console.log(); if (updatedTools.length > 0) { console.log(chalk.green(`✓ Updated: ${updatedTools.join(', ')} (v${OPENSPEC_VERSION})`)); @@ -154,8 +255,14 @@ export class UpdateCommand { if (failedTools.length > 0) { console.log(chalk.red(`✗ Failed: ${failedTools.map(f => `${f.name} (${f.error})`).join(', ')}`)); } + if (removedCommandCount > 0) { + console.log(chalk.dim(`Removed: ${removedCommandCount} command files (delivery: skills)`)); + } + if (removedSkillCount > 0) { + console.log(chalk.dim(`Removed: ${removedSkillCount} skill directories (delivery: commands)`)); + } - // 10. Show onboarding message for newly configured tools from legacy upgrade + // 12. Show onboarding message for newly configured tools from legacy upgrade if (newlyConfiguredTools.length > 0) { console.log(); console.log(chalk.bold('Getting started:')); @@ -166,6 +273,18 @@ export class UpdateCommand { console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`); } + // 13. Detect new tool directories not currently configured + this.detectNewTools(resolvedProjectPath, configuredTools); + + // 14. Display note about extra workflows not in profile + this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows); + + // 15. List affected tools + if (updatedTools.length > 0) { + const toolDisplayNames = updatedTools; + console.log(chalk.dim(`Tools: ${toolDisplayNames.join(', ')}`)); + } + console.log(); console.log(chalk.dim('Restart your IDE for changes to take effect.')); } @@ -178,22 +297,27 @@ export class UpdateCommand { console.log(chalk.green(`✓ All ${toolStatuses.length} tool(s) up to date (v${OPENSPEC_VERSION})`)); console.log(chalk.dim(` Tools: ${toolNames.join(', ')}`)); console.log(); - console.log(chalk.dim('Use --force to refresh skills anyway.')); + console.log(chalk.dim('Use --force to refresh files anyway.')); } /** * Display the update plan showing which tools need updating. */ private displayUpdatePlan( - needingUpdate: ToolVersionStatus[], + toolsToUpdate: string[], + statusByTool: Map, upToDate: ToolVersionStatus[] ): void { - const updates = needingUpdate.map((s) => { - const fromVersion = s.generatedByVersion ?? 'unknown'; - return `${s.toolId} (${fromVersion} → ${OPENSPEC_VERSION})`; + const updates = toolsToUpdate.map((toolId) => { + const status = statusByTool.get(toolId); + if (status?.needsUpdate) { + const fromVersion = status.generatedByVersion ?? 'unknown'; + return `${status.toolId} (${fromVersion} → ${OPENSPEC_VERSION})`; + } + return `${toolId} (config sync)`; }); - console.log(`Updating ${needingUpdate.length} tool(s): ${updates.join(', ')}`); + console.log(`Updating ${toolsToUpdate.length} tool(s): ${updates.join(', ')}`); if (upToDate.length > 0) { const upToDateNames = upToDate.map((s) => s.toolId); @@ -201,6 +325,197 @@ 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. + */ + private detectNewTools(projectPath: string, configuredTools: string[]): void { + const availableTools = getAvailableTools(projectPath); + const configuredSet = new Set(configuredTools); + + const newTools = availableTools.filter((t) => !configuredSet.has(t.value)); + + if (newTools.length > 0) { + console.log(); + for (const tool of newTools) { + console.log(chalk.yellow(`Detected new tool: ${tool.name}. Run 'openspec init' to add it.`)); + } + } + } + + /** + * Displays a note about extra workflows installed that aren't in the current profile. + */ + private displayExtraWorkflowsNote( + projectPath: string, + configuredTools: string[], + profileWorkflows: readonly string[] + ): void { + const installedWorkflows = scanInstalledWorkflows(projectPath, configuredTools); + const profileSet = new Set(profileWorkflows); + const extraWorkflows = installedWorkflows.filter((w) => !profileSet.has(w)); + + if (extraWorkflows.length > 0) { + console.log(chalk.dim(`Note: ${extraWorkflows.length} extra workflows not in profile (use \`openspec config profile\` to manage)`)); + } + } + + /** + * Removes skill directories for workflows when delivery changed to commands-only. + * Returns the number of directories removed. + */ + private async removeSkillDirs(skillsDir: string): Promise { + let removed = 0; + + for (const workflow of ALL_WORKFLOWS) { + const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; + if (!dirName) continue; + + const skillDir = path.join(skillsDir, dirName); + try { + if (fs.existsSync(skillDir)) { + await fs.promises.rm(skillDir, { recursive: true, force: true }); + removed++; + } + } catch { + // Ignore errors + } + } + + return removed; + } + + /** + * Removes command files for workflows when delivery changed to skills-only. + * Returns the number of files removed. + */ + private async removeCommandFiles( + projectPath: string, + toolId: string, + ): Promise { + let removed = 0; + + const adapter = CommandAdapterRegistry.get(toolId); + if (!adapter) return 0; + + for (const workflow of ALL_WORKFLOWS) { + const cmdPath = adapter.getFilePath(workflow); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + + try { + if (fs.existsSync(fullPath)) { + await fs.promises.unlink(fullPath); + removed++; + } + } catch { + // Ignore errors + } + } + + return removed; + } + /** * Detect and handle legacy OpenSpec artifacts. * Unlike init, update warns but continues if legacy files found in non-interactive mode. @@ -290,7 +605,7 @@ export class UpdateCommand { } // Get currently configured tools - const configuredTools = getConfiguredTools(projectPath); + const configuredTools = this.getConfiguredToolsForUpdate(projectPath); const configuredSet = new Set(configuredTools); // Filter to tools that aren't already configured diff --git a/src/prompts/searchable-multi-select.ts b/src/prompts/searchable-multi-select.ts index e011e487a..b0569b8e1 100644 --- a/src/prompts/searchable-multi-select.ts +++ b/src/prompts/searchable-multi-select.ts @@ -68,8 +68,8 @@ async function createSearchableMultiSelect(): Promise< useKeypress((key) => { if (status === 'done') return; - // Tab to confirm - if (key.name === 'tab') { + // Enter to confirm/submit + if (isEnterKey(key)) { if (validate) { const result = validate(selectedValues); if (result !== true) { @@ -82,13 +82,15 @@ async function createSearchableMultiSelect(): Promise< return; } - // Enter to add item - if (isEnterKey(key)) { + // Space to toggle selection + if (key.name === 'space') { const choice = filteredChoices[cursor]; - if (choice && !selectedSet.has(choice.value)) { - setSelectedValues([...selectedValues, choice.value]); - setSearchText(''); - setCursor(0); + if (choice) { + if (selectedSet.has(choice.value)) { + setSelectedValues(selectedValues.filter(v => v !== choice.value)); + } else { + setSelectedValues([...selectedValues, choice.value]); + } } return; } @@ -149,7 +151,7 @@ async function createSearchableMultiSelect(): Promise< // Instructions lines.push( - ` ${chalk.cyan('↑↓')} navigate • ${chalk.cyan('Enter')} add • ${chalk.cyan('Backspace')} remove • ${chalk.cyan('Tab')} confirm` + ` ${chalk.cyan('↑↓')} navigate • ${chalk.cyan('Space')} toggle • ${chalk.cyan('Backspace')} remove • ${chalk.cyan('Enter')} confirm` ); // List @@ -198,9 +200,9 @@ async function createSearchableMultiSelect(): Promise< * * - Type to filter choices * - ↑↓ to navigate - * - Enter to add highlighted item + * - Space to toggle highlighted item selection * - Backspace to remove last selected item (or delete search char) - * - Tab to confirm selections + * - Enter to confirm selections */ export async function searchableMultiSelect(config: Config): Promise { const prompt = await createSearchableMultiSelect(); diff --git a/test/commands/config.test.ts b/test/commands/config.test.ts index e6880c924..68ea43f3b 100644 --- a/test/commands/config.test.ts +++ b/test/commands/config.test.ts @@ -172,4 +172,114 @@ describe('config key validation', () => { const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); expect(validateConfigKeyPath('featureFlags.someFlag.extra').valid).toBe(false); }); + + it('allows profile key', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('profile').valid).toBe(true); + }); + + it('allows delivery key', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('delivery').valid).toBe(true); + }); + + it('allows workflows key', async () => { + const { validateConfigKeyPath } = await import('../../src/core/config-schema.js'); + expect(validateConfigKeyPath('workflows').valid).toBe(true); + }); +}); + +describe('config profile command', () => { + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = path.join(os.tmpdir(), `openspec-profile-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(tempDir, { recursive: true }); + originalEnv = { ...process.env }; + process.env.XDG_CONFIG_HOME = tempDir; + }); + + afterEach(() => { + process.env = originalEnv; + fs.rmSync(tempDir, { recursive: true, force: true }); + vi.resetModules(); + }); + + it('core preset should set profile to core and preserve delivery', async () => { + const { getGlobalConfig, saveGlobalConfig } = await import('../../src/core/global-config.js'); + + // Set initial config with custom delivery + saveGlobalConfig({ featureFlags: {}, profile: 'custom', delivery: 'skills', workflows: ['explore'] }); + + // Simulate the core preset logic + const config = getGlobalConfig(); + const { CORE_WORKFLOWS } = await import('../../src/core/profiles.js'); + config.profile = 'core'; + config.workflows = [...CORE_WORKFLOWS]; + // Delivery should be preserved + saveGlobalConfig(config); + + const result = getGlobalConfig(); + expect(result.profile).toBe('core'); + expect(result.delivery).toBe('skills'); // preserved + expect(result.workflows).toEqual(['propose', 'explore', 'apply', 'archive']); + }); + + it('custom workflow selection should set profile to custom', async () => { + const { getGlobalConfig, saveGlobalConfig } = await import('../../src/core/global-config.js'); + const { CORE_WORKFLOWS } = await import('../../src/core/profiles.js'); + + // Simulate custom selection that differs from core + const selectedWorkflows = ['explore', 'new', 'apply', 'ff', 'verify']; + const isCoreMatch = + selectedWorkflows.length === CORE_WORKFLOWS.length && + CORE_WORKFLOWS.every((w: string) => selectedWorkflows.includes(w)); + + expect(isCoreMatch).toBe(false); + + saveGlobalConfig({ + featureFlags: {}, + profile: isCoreMatch ? 'core' : 'custom', + delivery: 'both', + workflows: selectedWorkflows, + }); + + const result = getGlobalConfig(); + expect(result.profile).toBe('custom'); + expect(result.workflows).toEqual(selectedWorkflows); + }); + + it('selecting exactly core workflows should set profile to core', async () => { + const { CORE_WORKFLOWS } = await import('../../src/core/profiles.js'); + + const selectedWorkflows = [...CORE_WORKFLOWS]; + const isCoreMatch = + selectedWorkflows.length === CORE_WORKFLOWS.length && + CORE_WORKFLOWS.every((w: string) => selectedWorkflows.includes(w)); + + expect(isCoreMatch).toBe(true); + }); + + it('config schema should validate profile and delivery values', async () => { + const { validateConfig } = await import('../../src/core/config-schema.js'); + + expect(validateConfig({ featureFlags: {}, profile: 'core', delivery: 'both' }).success).toBe(true); + expect(validateConfig({ featureFlags: {}, profile: 'custom', delivery: 'skills' }).success).toBe(true); + expect(validateConfig({ featureFlags: {}, profile: 'custom', delivery: 'commands', workflows: ['explore'] }).success).toBe(true); + }); + + it('config schema should reject invalid profile values', async () => { + const { validateConfig } = await import('../../src/core/config-schema.js'); + + const result = validateConfig({ featureFlags: {}, profile: 'invalid' }); + expect(result.success).toBe(false); + }); + + it('config schema should reject invalid delivery values', async () => { + const { validateConfig } = await import('../../src/core/config-schema.js'); + + const result = validateConfig({ featureFlags: {}, delivery: 'invalid' }); + expect(result.success).toBe(false); + }); }); diff --git a/test/core/available-tools.test.ts b/test/core/available-tools.test.ts new file mode 100644 index 000000000..fb2912eb5 --- /dev/null +++ b/test/core/available-tools.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { randomUUID } from 'crypto'; +import { getAvailableTools } from '../../src/core/available-tools.js'; + +describe('available-tools', () => { + let testDir: string; + + beforeEach(async () => { + testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`); + await fs.mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + describe('getAvailableTools', () => { + it('should return empty array when no tool directories exist', () => { + const tools = getAvailableTools(testDir); + expect(tools).toEqual([]); + }); + + it('should detect a single tool directory', async () => { + await fs.mkdir(path.join(testDir, '.claude'), { recursive: true }); + + const tools = getAvailableTools(testDir); + expect(tools).toHaveLength(1); + expect(tools[0].value).toBe('claude'); + expect(tools[0].name).toBe('Claude Code'); + expect(tools[0].skillsDir).toBe('.claude'); + }); + + it('should detect multiple tool directories', async () => { + await fs.mkdir(path.join(testDir, '.claude'), { recursive: true }); + await fs.mkdir(path.join(testDir, '.cursor'), { recursive: true }); + await fs.mkdir(path.join(testDir, '.windsurf'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).toContain('claude'); + expect(toolValues).toContain('cursor'); + expect(toolValues).toContain('windsurf'); + expect(tools).toHaveLength(3); + }); + + it('should ignore files that are not directories', async () => { + // Create a file named .claude instead of a directory + await fs.writeFile(path.join(testDir, '.claude'), 'not a directory'); + + const tools = getAvailableTools(testDir); + expect(tools).toEqual([]); + }); + + it('should only return tools that have a skillsDir property', async () => { + // .agents value has no skillsDir in AI_TOOLS config + // Create directories for both a valid and the agents case + await fs.mkdir(path.join(testDir, '.claude'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).toContain('claude'); + expect(toolValues).not.toContain('agents'); + }); + + it('should return full AIToolOption objects', async () => { + await fs.mkdir(path.join(testDir, '.cursor'), { recursive: true }); + + const tools = getAvailableTools(testDir); + expect(tools).toHaveLength(1); + expect(tools[0]).toMatchObject({ + name: 'Cursor', + value: 'cursor', + available: true, + skillsDir: '.cursor', + }); + }); + + it('should handle paths with spaces', async () => { + const spacedDir = path.join(testDir, 'path with spaces'); + await fs.mkdir(spacedDir, { recursive: true }); + await fs.mkdir(path.join(spacedDir, '.claude'), { recursive: true }); + + const tools = getAvailableTools(spacedDir); + expect(tools).toHaveLength(1); + expect(tools[0].value).toBe('claude'); + }); + }); +}); diff --git a/test/core/global-config.test.ts b/test/core/global-config.test.ts index 052d32018..71668c7ff 100644 --- a/test/core/global-config.test.ts +++ b/test/core/global-config.test.ts @@ -11,6 +11,7 @@ import { GLOBAL_CONFIG_DIR_NAME, GLOBAL_CONFIG_FILE_NAME } from '../../src/core/global-config.js'; +import type { Profile, Delivery } from '../../src/core/global-config.js'; describe('global-config', () => { let tempDir: string; @@ -100,7 +101,7 @@ describe('global-config', () => { const config = getGlobalConfig(); - expect(config).toEqual({ featureFlags: {} }); + expect(config).toEqual({ featureFlags: {}, profile: 'core', delivery: 'both' }); }); it('should not create directory when reading non-existent config', () => { @@ -137,7 +138,7 @@ describe('global-config', () => { const config = getGlobalConfig(); - expect(config).toEqual({ featureFlags: {} }); + expect(config).toEqual({ featureFlags: {}, profile: 'core', delivery: 'both' }); }); it('should log warning for invalid JSON', () => { @@ -189,6 +190,81 @@ describe('global-config', () => { // Should have the custom flag expect(config.featureFlags?.customFlag).toBe(true); }); + + describe('schema evolution', () => { + it('should add default profile and delivery when loading old config without them', () => { + process.env.XDG_CONFIG_HOME = tempDir; + const configDir = path.join(tempDir, 'openspec'); + const configPath = path.join(configDir, 'config.json'); + + // Simulate a pre-existing config that only has featureFlags + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ + featureFlags: { existingFlag: true } + })); + + const config = getGlobalConfig(); + + expect(config.profile).toBe('core'); + expect(config.delivery).toBe('both'); + expect(config.workflows).toBeUndefined(); + expect(config.featureFlags?.existingFlag).toBe(true); + }); + + it('should preserve explicit profile and delivery values from config', () => { + process.env.XDG_CONFIG_HOME = tempDir; + const configDir = path.join(tempDir, 'openspec'); + const configPath = path.join(configDir, 'config.json'); + + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ + featureFlags: {}, + profile: 'custom', + delivery: 'skills', + workflows: ['propose', 'review'] + })); + + const config = getGlobalConfig(); + + expect(config.profile).toBe('custom'); + expect(config.delivery).toBe('skills'); + expect(config.workflows).toEqual(['propose', 'review']); + }); + + it('should round-trip new fields correctly', () => { + process.env.XDG_CONFIG_HOME = tempDir; + const originalConfig = { + featureFlags: { flag1: true }, + profile: 'custom' as Profile, + delivery: 'commands' as Delivery, + workflows: ['propose'] + }; + + saveGlobalConfig(originalConfig); + const loadedConfig = getGlobalConfig(); + + expect(loadedConfig.profile).toBe('custom'); + expect(loadedConfig.delivery).toBe('commands'); + expect(loadedConfig.workflows).toEqual(['propose']); + }); + + it('should default workflows to undefined when not in config', () => { + process.env.XDG_CONFIG_HOME = tempDir; + const configDir = path.join(tempDir, 'openspec'); + const configPath = path.join(configDir, 'config.json'); + + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify({ + featureFlags: {}, + profile: 'core', + delivery: 'both' + })); + + const config = getGlobalConfig(); + + expect(config.workflows).toBeUndefined(); + }); + }); }); describe('saveGlobalConfig', () => { diff --git a/test/core/init.test.ts b/test/core/init.test.ts index ea313ca69..f8882a6b3 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -3,20 +3,30 @@ import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; import { InitCommand } from '../../src/core/init.js'; +import { saveGlobalConfig, getGlobalConfig } from '../../src/core/global-config.js'; describe('InitCommand', () => { let testDir: string; + let configTempDir: string; + let originalEnv: NodeJS.ProcessEnv; beforeEach(async () => { testDir = path.join(os.tmpdir(), `openspec-init-test-${Date.now()}`); await fs.mkdir(testDir, { recursive: true }); + originalEnv = { ...process.env }; + // Use a temp dir for global config to avoid reading real config + configTempDir = path.join(os.tmpdir(), `openspec-config-init-${Date.now()}`); + await fs.mkdir(configTempDir, { recursive: true }); + process.env.XDG_CONFIG_HOME = configTempDir; // Mock console.log to suppress output during tests vi.spyOn(console, 'log').mockImplementation(() => { }); }); afterEach(async () => { + process.env = originalEnv; await fs.rm(testDir, { recursive: true, force: true }); + await fs.rm(configTempDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); @@ -45,24 +55,20 @@ describe('InitCommand', () => { expect(content).toContain('schema: spec-driven'); }); - it('should create 9 Agent Skills for Claude Code', async () => { + it('should create core profile skills for Claude Code by default', async () => { const initCommand = new InitCommand({ tools: 'claude', force: true }); await initCommand.execute(testDir); - const skillNames = [ + // Core profile: propose, explore, apply, archive + const coreSkillNames = [ + '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', ]; - for (const skillName of skillNames) { + for (const skillName of coreSkillNames) { const skillFile = path.join(testDir, '.claude', 'skills', skillName, 'SKILL.md'); expect(await fileExists(skillFile)).toBe(true); @@ -71,28 +77,54 @@ describe('InitCommand', () => { expect(content).toContain('name:'); expect(content).toContain('description:'); } + + // Non-core skills should NOT be created + const nonCoreSkillNames = [ + 'openspec-new-change', + 'openspec-continue-change', + 'openspec-ff-change', + 'openspec-sync-specs', + 'openspec-bulk-archive-change', + 'openspec-verify-change', + ]; + + for (const skillName of nonCoreSkillNames) { + const skillFile = path.join(testDir, '.claude', 'skills', skillName, 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(false); + } }); - it('should create 9 slash commands for Claude Code', async () => { + it('should create core profile commands for Claude Code by default', async () => { const initCommand = new InitCommand({ tools: 'claude', force: true }); await initCommand.execute(testDir); - const commandNames = [ + // Core profile: propose, explore, apply, archive + const coreCommandNames = [ + 'opsx/propose.md', 'opsx/explore.md', + 'opsx/apply.md', + 'opsx/archive.md', + ]; + + for (const cmdName of coreCommandNames) { + const cmdFile = path.join(testDir, '.claude', 'commands', cmdName); + expect(await fileExists(cmdFile)).toBe(true); + } + + // Non-core commands should NOT be created + const nonCoreCommandNames = [ 'opsx/new.md', 'opsx/continue.md', - 'opsx/apply.md', 'opsx/ff.md', 'opsx/sync.md', - 'opsx/archive.md', 'opsx/bulk-archive.md', 'opsx/verify.md', ]; - for (const cmdName of commandNames) { + for (const cmdName of nonCoreCommandNames) { const cmdFile = path.join(testDir, '.claude', 'commands', cmdName); - expect(await fileExists(cmdFile)).toBe(true); + expect(await fileExists(cmdFile)).toBe(false); } }); @@ -270,14 +302,14 @@ describe('InitCommand', () => { expect(content).toContain('thinking partner'); }); - it('should include new-change skill instructions', async () => { + it('should include propose skill instructions', async () => { const initCommand = new InitCommand({ tools: 'claude', force: true }); await initCommand.execute(testDir); - const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-new-change', 'SKILL.md'); + const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-propose', 'SKILL.md'); const content = await fs.readFile(skillFile, 'utf-8'); - expect(content).toContain('name: openspec-new-change'); + expect(content).toContain('name: openspec-propose'); }); it('should include apply-change skill instructions', async () => { @@ -351,10 +383,10 @@ describe('InitCommand', () => { await expect(initCommand.execute(readOnlyDir)).rejects.toThrow(/Insufficient permissions/); }); - it('should throw error in non-interactive mode without --tools flag', async () => { + it('should throw error in non-interactive mode without --tools flag and no detected tools', async () => { const initCommand = new InitCommand({ interactive: false }); - await expect(initCommand.execute(testDir)).rejects.toThrow(/Missing required option --tools/); + await expect(initCommand.execute(testDir)).rejects.toThrow(/No tools detected and no --tools flag/); }); }); @@ -409,6 +441,164 @@ describe('InitCommand', () => { }); }); +describe('InitCommand - profile and detection features', () => { + let testDir: string; + let configTempDir: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(async () => { + testDir = path.join(os.tmpdir(), `openspec-init-profile-test-${Date.now()}`); + await fs.mkdir(testDir, { recursive: true }); + originalEnv = { ...process.env }; + // Use a temp dir for global config to avoid polluting real config + configTempDir = path.join(os.tmpdir(), `openspec-config-test-${Date.now()}`); + await fs.mkdir(configTempDir, { recursive: true }); + process.env.XDG_CONFIG_HOME = configTempDir; + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + process.env = originalEnv; + await fs.rm(testDir, { recursive: true, force: true }); + await fs.rm(configTempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('should use --profile flag to override global config', async () => { + // Set global config to custom profile + saveGlobalConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'both', + workflows: ['explore', 'new', 'apply'], + }); + + // Override with --profile core + const initCommand = new InitCommand({ tools: 'claude', force: true, profile: 'core' }); + await initCommand.execute(testDir); + + // Core profile skills should be created + const proposeSkill = path.join(testDir, '.claude', 'skills', 'openspec-propose', 'SKILL.md'); + expect(await fileExists(proposeSkill)).toBe(true); + + // Non-core skills (from the custom profile) should NOT be created + const newChangeSkill = path.join(testDir, '.claude', 'skills', 'openspec-new-change', 'SKILL.md'); + expect(await fileExists(newChangeSkill)).toBe(false); + }); + + it('should reject invalid --profile values', async () => { + const initCommand = new InitCommand({ + tools: 'claude', + force: true, + profile: 'invalid-profile', + }); + + await expect(initCommand.execute(testDir)).rejects.toThrow( + /Invalid profile "invalid-profile"/ + ); + }); + + it('should use detected tools in non-interactive mode when no --tools flag', async () => { + // Create a .claude directory to simulate detected tool + await fs.mkdir(path.join(testDir, '.claude'), { recursive: true }); + + const initCommand = new InitCommand({ interactive: false, force: true }); + await initCommand.execute(testDir); + + // Should have used claude (detected) + const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + }); + + it('should respect custom profile from global config', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'both', + workflows: ['explore', 'new'], + }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + // Custom profile skills should be created + 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); + + // Non-selected skills should NOT be created + const proposeSkill = path.join(testDir, '.claude', 'skills', 'openspec-propose', 'SKILL.md'); + expect(await fileExists(proposeSkill)).toBe(false); + }); + + it('should respect delivery=skills setting (no commands)', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + // Skills should exist + const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + + // Commands should NOT exist + const cmdFile = path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md'); + expect(await fileExists(cmdFile)).toBe(false); + }); + + it('should respect delivery=commands setting (no skills)', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + // Skills should NOT exist + const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(false); + + // Commands should exist + const cmdFile = path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md'); + expect(await fileExists(cmdFile)).toBe(true); + }); + + it('should remove commands on re-init when delivery changes to skills', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + const initCommand1 = new InitCommand({ tools: 'claude', force: true }); + await initCommand1.execute(testDir); + + const cmdFile = path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md'); + expect(await fileExists(cmdFile)).toBe(true); + + saveGlobalConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + const initCommand2 = new InitCommand({ tools: 'claude', force: true }); + await initCommand2.execute(testDir); + + expect(await fileExists(cmdFile)).toBe(false); + + const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + }); +}); + async function fileExists(filePath: string): Promise { try { await fs.access(filePath); diff --git a/test/core/profiles.test.ts b/test/core/profiles.test.ts new file mode 100644 index 000000000..4df8e66c0 --- /dev/null +++ b/test/core/profiles.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; + +import { + CORE_WORKFLOWS, + ALL_WORKFLOWS, + getProfileWorkflows, +} from '../../src/core/profiles.js'; + +describe('profiles', () => { + describe('CORE_WORKFLOWS', () => { + it('should contain the four core workflows', () => { + expect(CORE_WORKFLOWS).toEqual(['propose', 'explore', 'apply', 'archive']); + }); + + it('should be a subset of ALL_WORKFLOWS', () => { + for (const workflow of CORE_WORKFLOWS) { + expect(ALL_WORKFLOWS).toContain(workflow); + } + }); + }); + + describe('ALL_WORKFLOWS', () => { + it('should contain all 11 workflows', () => { + expect(ALL_WORKFLOWS).toHaveLength(11); + }); + + it('should contain expected workflow IDs', () => { + const expected = [ + 'propose', 'explore', 'new', 'continue', 'apply', + 'ff', 'sync', 'archive', 'bulk-archive', 'verify', 'onboard', + ]; + expect([...ALL_WORKFLOWS]).toEqual(expected); + }); + }); + + describe('getProfileWorkflows', () => { + it('should return core workflows for core profile', () => { + const result = getProfileWorkflows('core'); + expect(result).toEqual(CORE_WORKFLOWS); + }); + + it('should return core workflows for core profile even if customWorkflows provided', () => { + const result = getProfileWorkflows('core', ['new', 'apply']); + expect(result).toEqual(CORE_WORKFLOWS); + }); + + it('should return custom workflows for custom profile', () => { + const customWorkflows = ['explore', 'new', 'apply', 'ff']; + const result = getProfileWorkflows('custom', customWorkflows); + expect(result).toEqual(customWorkflows); + }); + + it('should return empty array for custom profile with no customWorkflows', () => { + const result = getProfileWorkflows('custom'); + expect(result).toEqual([]); + }); + + it('should return empty array for custom profile with empty customWorkflows', () => { + const result = getProfileWorkflows('custom', []); + expect(result).toEqual([]); + }); + }); +}); diff --git a/test/core/shared/skill-generation.test.ts b/test/core/shared/skill-generation.test.ts index 9970eda52..6c755f51d 100644 --- a/test/core/shared/skill-generation.test.ts +++ b/test/core/shared/skill-generation.test.ts @@ -8,9 +8,9 @@ import { describe('skill-generation', () => { describe('getSkillTemplates', () => { - it('should return all 10 skill templates', () => { + it('should return all 11 skill templates', () => { const templates = getSkillTemplates(); - expect(templates).toHaveLength(10); + expect(templates).toHaveLength(11); }); it('should have unique directory names', () => { @@ -34,24 +34,63 @@ describe('skill-generation', () => { expect(dirNames).toContain('openspec-bulk-archive-change'); expect(dirNames).toContain('openspec-verify-change'); expect(dirNames).toContain('openspec-onboard'); + expect(dirNames).toContain('openspec-propose'); }); it('should have valid template structure', () => { const templates = getSkillTemplates(); - for (const { template, dirName } of templates) { + for (const { template, dirName, workflowId } of templates) { expect(template.name).toBeTruthy(); expect(template.description).toBeTruthy(); expect(template.instructions).toBeTruthy(); expect(dirName).toBeTruthy(); + expect(workflowId).toBeTruthy(); } }); + + it('should have unique workflow IDs', () => { + const templates = getSkillTemplates(); + const ids = templates.map(t => t.workflowId); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(templates.length); + }); + + it('should filter by workflow IDs when provided', () => { + const filtered = getSkillTemplates(['propose', 'explore', 'apply', 'archive']); + expect(filtered).toHaveLength(4); + const ids = filtered.map(t => t.workflowId); + expect(ids).toContain('propose'); + expect(ids).toContain('explore'); + expect(ids).toContain('apply'); + expect(ids).toContain('archive'); + expect(ids).not.toContain('new'); + expect(ids).not.toContain('ff'); + }); + + it('should return all templates when filter is undefined', () => { + const all = getSkillTemplates(); + const noFilter = getSkillTemplates(undefined); + expect(noFilter).toHaveLength(all.length); + }); + + it('should return empty array when filter matches nothing', () => { + const filtered = getSkillTemplates(['nonexistent']); + expect(filtered).toHaveLength(0); + }); + + it('should return single template when filter has one workflow', () => { + const filtered = getSkillTemplates(['propose']); + expect(filtered).toHaveLength(1); + expect(filtered[0].workflowId).toBe('propose'); + expect(filtered[0].dirName).toBe('openspec-propose'); + }); }); describe('getCommandTemplates', () => { - it('should return all 10 command templates', () => { + it('should return all 11 command templates', () => { const templates = getCommandTemplates(); - expect(templates).toHaveLength(10); + expect(templates).toHaveLength(11); }); it('should have unique IDs', () => { @@ -75,13 +114,37 @@ describe('skill-generation', () => { expect(ids).toContain('bulk-archive'); expect(ids).toContain('verify'); expect(ids).toContain('onboard'); + expect(ids).toContain('propose'); + }); + + it('should filter by workflow IDs when provided', () => { + const filtered = getCommandTemplates(['propose', 'explore', 'apply', 'archive']); + expect(filtered).toHaveLength(4); + const ids = filtered.map(t => t.id); + expect(ids).toContain('propose'); + expect(ids).toContain('explore'); + expect(ids).toContain('apply'); + expect(ids).toContain('archive'); + expect(ids).not.toContain('new'); + expect(ids).not.toContain('ff'); + }); + + it('should return all templates when filter is undefined', () => { + const all = getCommandTemplates(); + const noFilter = getCommandTemplates(undefined); + expect(noFilter).toHaveLength(all.length); + }); + + it('should return empty array when filter matches nothing', () => { + const filtered = getCommandTemplates(['nonexistent']); + expect(filtered).toHaveLength(0); }); }); describe('getCommandContents', () => { - it('should return all 10 command contents', () => { + it('should return all 11 command contents', () => { const contents = getCommandContents(); - expect(contents).toHaveLength(10); + expect(contents).toHaveLength(11); }); it('should have valid content structure', () => { @@ -104,6 +167,21 @@ describe('skill-generation', () => { expect(contentIds).toEqual(templateIds); }); + + it('should filter by workflow IDs when provided', () => { + const filtered = getCommandContents(['propose', 'explore']); + expect(filtered).toHaveLength(2); + const ids = filtered.map(c => c.id); + expect(ids).toContain('propose'); + expect(ids).toContain('explore'); + expect(ids).not.toContain('new'); + }); + + it('should return all contents when filter is undefined', () => { + const all = getCommandContents(); + const noFilter = getCommandContents(undefined); + expect(noFilter).toHaveLength(all.length); + }); }); describe('generateSkillContent', () => { diff --git a/test/core/shared/tool-detection.test.ts b/test/core/shared/tool-detection.test.ts index d11e0da65..5a66ff3cd 100644 --- a/test/core/shared/tool-detection.test.ts +++ b/test/core/shared/tool-detection.test.ts @@ -27,8 +27,8 @@ describe('tool-detection', () => { }); describe('SKILL_NAMES', () => { - it('should contain all 9 skill names', () => { - expect(SKILL_NAMES).toHaveLength(9); + it('should contain all skill names matching COMMAND_IDS', () => { + expect(SKILL_NAMES).toHaveLength(11); expect(SKILL_NAMES).toContain('openspec-explore'); expect(SKILL_NAMES).toContain('openspec-new-change'); expect(SKILL_NAMES).toContain('openspec-continue-change'); @@ -38,6 +38,8 @@ describe('tool-detection', () => { expect(SKILL_NAMES).toContain('openspec-archive-change'); expect(SKILL_NAMES).toContain('openspec-bulk-archive-change'); expect(SKILL_NAMES).toContain('openspec-verify-change'); + expect(SKILL_NAMES).toContain('openspec-onboard'); + expect(SKILL_NAMES).toContain('openspec-propose'); }); }); @@ -87,7 +89,7 @@ describe('tool-detection', () => { const status = getToolSkillStatus(testDir, 'claude'); expect(status.configured).toBe(true); expect(status.fullyConfigured).toBe(true); - expect(status.skillCount).toBe(9); + expect(status.skillCount).toBe(SKILL_NAMES.length); }); }); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 3ede17bb3..f8fb1307b 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -21,6 +21,8 @@ import { getOpsxNewCommandTemplate, getOpsxOnboardCommandTemplate, getOpsxSyncCommandTemplate, + getOpsxProposeCommandTemplate, + getOpsxProposeSkillTemplate, getOpsxVerifyCommandTemplate, getSyncSpecsSkillTemplate, getVerifyChangeSkillTemplate, @@ -28,40 +30,43 @@ import { import { generateSkillContent } from '../../../src/core/shared/skill-generation.js'; const EXPECTED_FUNCTION_HASHES: Record = { - getExploreSkillTemplate: '98d4b021b184e39d51a39ffebb4d047c5417d86b39b0c423839aa05a4451e7e4', + getExploreSkillTemplate: '55a2a1afcba0af88c638e77e4e3870f65ed82c030b4a2056d39812ae13a616be', getNewChangeSkillTemplate: '5989672758eccf54e3bb554ab97f2c129a192b12bbb7688cc1ffcf6bccb1ae9d', getContinueChangeSkillTemplate: 'f2e413f0333dfd6641cc2bd1a189273fdea5c399eecdde98ef528b5216f097b3', getApplyChangeSkillTemplate: '26e52e67693e93fbcdd40dcd3e20949c07ce019183d55a8149d0260c791cd7f4', getFfChangeSkillTemplate: 'a7332fb14c8dc3f9dec71f5d332790b4a8488191e7db4ab6132ccbefecf9ded9', getSyncSpecsSkillTemplate: 'bded184e4c345619148de2c0ad80a5b527d4ffe45c87cc785889b9329e0f465b', - getOnboardSkillTemplate: 'fa2f2d4aa339db6b331f6140ca270cfbe4f7ab63b4be24237ff1cc8898a6f201', - getOpsxExploreCommandTemplate: '25f103b98e747284d7368864ac73063522ca07ff150428cbb2733ad10ddc25f7', + getOnboardSkillTemplate: '819a2d117ad1386187975686839cb0584b41484013d0ca6a6691f7a439a11a4a', + getOpsxExploreCommandTemplate: '91353d9e8633a3a9ce7339e796f1283478fca279153f3807c92f4f8ece246b19', getOpsxNewCommandTemplate: '62eee32d6d81a376e7be845d0891e28e6262ad07482f9bfe6af12a9f0366c364', getOpsxContinueCommandTemplate: '8bbaedcc95287f9e822572608137df4f49ad54cedfb08d3342d0d1c4e9716caa', getOpsxApplyCommandTemplate: 'a9d631a07fcd832b67d263ff3800b98604ab8d378baf1b0d545907ef3affa3b5', getOpsxFfCommandTemplate: 'cdebe872cc8e0fcc25c8864b98ffd66a93484c0657db94bd1285b8113092702a', getArchiveChangeSkillTemplate: '6f8ca383fdb5a4eb9872aca81e07bf0ba7f25e4de8617d7a047ca914ca7f14b9', - getBulkArchiveChangeSkillTemplate: 'bd2fc7d8ab721ae0815fda7b7ec2062d2a05e13a34f3a8c552c31f7125c13f31', + getBulkArchiveChangeSkillTemplate: 'b40fc44ea4e420bdc9c803985b10e5c091fc472cdfc69153b962be6be303bddd', getOpsxSyncCommandTemplate: '378d035fe7cc30be3e027b66dcc4b8afc78ef1c8369c39479c9b05a582fb5ccf', getVerifyChangeSkillTemplate: '63a213ba3b42af54a1cd56f5072234a03b265c3fe4a1da12cd6fbbef5ee46c4b', getOpsxArchiveCommandTemplate: 'b44cc9748109f61687f9f596604b037bc3ea803abc143b22f09a76aebd98b493', - getOpsxOnboardCommandTemplate: '8d6fd8918a8e21d80974ac44b547c5af4c02ffdbe5fe2d9bf05162116af90fce', - getOpsxBulkArchiveCommandTemplate: '166ce6a552515b82526d3806218c03ec1b6e8aba6dc6fdf7b72c725ad2a2d4cd', + getOpsxOnboardCommandTemplate: '10052d05a4e2cdade7fdfa549b3444f7a92f55a39bf81ddd6af7e0e9e83a7302', + getOpsxBulkArchiveCommandTemplate: 'eaaba253a950b9e681d8427a5cbc6b50c4e91137fb37fd2360859e08f63a0c14', getOpsxVerifyCommandTemplate: '9b4d3ca422553b7534764eb3a009da87a051612c5238e9baab294c7b1233e9a2', + getOpsxProposeSkillTemplate: 'd67f937d44650e9c61d2158c865309fbab23cb3f50a3d4868a640a97776e3999', + getOpsxProposeCommandTemplate: '41ad59b37eafd7a161bab5c6e41997a37368f9c90b194451295ede5cd42e4d46', getFeedbackSkillTemplate: 'd7d83c5f7fc2b92fe8f4588a5bf2d9cb315e4c73ec19bcd5ef28270906319a0d', }; const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { - 'openspec-explore': '0cae74f949ac7dd8942515df9f4d74f16706f4542b001aa0418bde16bb3a7e4a', + 'openspec-explore': '90463d00761417dfbca5cb09361adcf8bbdbbb24000b86dd03647869a4104479', 'openspec-new-change': 'c324a7ace1f244aa3f534ac8e3370a2c11190d6d1b85a315f26a211398310f0f', 'openspec-continue-change': '463cf0b980ec9c3c24774414ef2a3e48e9faa8577bc8748990f45ab3d5efe960', 'openspec-apply-change': 'a0084442b59be9d7e22a0382a279d470501e1ecf74bdd5347e169951c9be191c', 'openspec-ff-change': '672c3a5b8df152d959b15bd7ae2be7a75ab7b8eaa2ec1e0daa15c02479b27937', 'openspec-sync-specs': 'b8859cf454379a19ca35dbf59eedca67306607f44a355327f9dc851114e50bde', 'openspec-archive-change': 'f83c85452bd47de0dee6b8efbcea6a62534f8a175480e9044f3043f887cebf0f', - 'openspec-bulk-archive-change': '617e2137ce273340300998519238db57b0c7d10d0f009047c5ee1ded319d6c4e', + 'openspec-bulk-archive-change': 'a235a539f7729ab7669e45256905808789240ecd02820e044f4d0eef67b0c2ab', 'openspec-verify-change': '30d07c6f7051965f624f5964db51844ec17c7dfd05f0da95281fe0ca73616326', - 'openspec-onboard': '92df7651bc2e0055efa0df0d09159fb9ba1c4a87906051ba4a65deabe8b481ff', + 'openspec-onboard': 'dbce376cf895f3fe4f63b4bce66d258c35b7b8884ac746670e5e35fabcefd255', + 'openspec-propose': '20e36dabefb90e232bad0667292bd5007ec280f8fc4fc995dbc4282bf45a22e7', }; function stableStringify(value: unknown): string { @@ -107,6 +112,8 @@ describe('skill templates split parity', () => { getOpsxOnboardCommandTemplate, getOpsxBulkArchiveCommandTemplate, getOpsxVerifyCommandTemplate, + getOpsxProposeSkillTemplate, + getOpsxProposeCommandTemplate, getFeedbackSkillTemplate, }; @@ -131,6 +138,7 @@ describe('skill templates split parity', () => { ['openspec-bulk-archive-change', getBulkArchiveChangeSkillTemplate], ['openspec-verify-change', getVerifyChangeSkillTemplate], ['openspec-onboard', getOnboardSkillTemplate], + ['openspec-propose', getOpsxProposeSkillTemplate], ]; const actualHashes = Object.fromEntries( diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 21bb00f59..c678e51c3 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1,12 +1,43 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { UpdateCommand } from '../../src/core/update.js'; +import { UpdateCommand, scanInstalledWorkflows } from '../../src/core/update.js'; +import { InitCommand } from '../../src/core/init.js'; import { FileSystemUtils } from '../../src/utils/file-system.js'; import { OPENSPEC_MARKERS } from '../../src/core/config.js'; +import type { GlobalConfig } from '../../src/core/global-config.js'; import path from 'path'; import fs from 'fs/promises'; import os from 'os'; import { randomUUID } from 'crypto'; +// Shared mutable mock config state +const mockState = { + config: { + featureFlags: {}, + profile: 'core' as const, + delivery: 'both' as const, + } as GlobalConfig, +}; + +// Mock global config module to isolate tests from the machine's actual config +vi.mock('../../src/core/global-config.js', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + getGlobalConfig: () => ({ ...mockState.config }), + saveGlobalConfig: vi.fn(), + }; +}); + +// Helper to set mock config for tests +function setMockConfig(config: GlobalConfig) { + mockState.config = config; +} + +function resetMockConfig() { + mockState.config = { featureFlags: {}, profile: 'core', delivery: 'both' }; +} + describe('UpdateCommand', () => { let testDir: string; let updateCommand: UpdateCommand; @@ -22,6 +53,9 @@ describe('UpdateCommand', () => { updateCommand = new UpdateCommand(); + // Reset mock config to defaults + resetMockConfig(); + // Clear all mocks before each test vi.restoreAllMocks(); }); @@ -106,20 +140,9 @@ Old instructions content consoleSpy.mockRestore(); }); - it('should update all 9 skill files when tool is configured', async () => { - // Set up a configured tool with all skill directories + it('should update core profile skill files when tool is configured', async () => { + // Set up a configured tool with one skill directory const skillsDir = path.join(testDir, '.claude', 'skills'); - const skillNames = [ - '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', - ]; // Create at least one skill to mark tool as configured await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { @@ -132,8 +155,15 @@ Old instructions content await updateCommand.execute(testDir); - // Verify all skill files were created/updated - for (const skillName of skillNames) { + // Verify core profile skill files were created/updated (propose, explore, apply, archive) + const coreSkillNames = [ + 'openspec-explore', + 'openspec-apply-change', + 'openspec-archive-change', + 'openspec-propose', + ]; + + for (const skillName of coreSkillNames) { const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); const exists = await FileSystemUtils.fileExists(skillFile); expect(exists).toBe(true); @@ -143,6 +173,22 @@ Old instructions content expect(content).toContain('name:'); expect(content).toContain('description:'); } + + // Verify non-core skills are NOT created + const nonCoreSkillNames = [ + 'openspec-new-change', + 'openspec-continue-change', + 'openspec-ff-change', + 'openspec-sync-specs', + 'openspec-bulk-archive-change', + 'openspec-verify-change', + ]; + + for (const skillName of nonCoreSkillNames) { + const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); + const exists = await FileSystemUtils.fileExists(skillFile); + expect(exists).toBe(false); + } }); }); @@ -174,7 +220,7 @@ Old instructions content expect(content).toContain('tags:'); }); - it('should update all 9 opsx commands when tool is configured', async () => { + it('should update core profile opsx commands when tool is configured', async () => { // Set up a configured tool const skillsDir = path.join(testDir, '.claude', 'skills'); await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { @@ -187,24 +233,22 @@ Old instructions content await updateCommand.execute(testDir); - const commandIds = [ - 'explore', - 'new', - 'continue', - 'apply', - 'ff', - 'sync', - 'archive', - 'bulk-archive', - 'verify', - ]; - + // Verify core profile commands were created (propose, explore, apply, archive) + const coreCommandIds = ['explore', 'apply', 'archive', 'propose']; const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); - for (const cmdId of commandIds) { + for (const cmdId of coreCommandIds) { const cmdFile = path.join(commandsDir, `${cmdId}.md`); const exists = await FileSystemUtils.fileExists(cmdFile); expect(exists).toBe(true); } + + // Verify non-core commands are NOT created + const nonCoreCommandIds = ['new', 'continue', 'ff', 'sync', 'bulk-archive', 'verify']; + for (const cmdId of nonCoreCommandIds) { + const cmdFile = path.join(commandsDir, `${cmdId}.md`); + const exists = await FileSystemUtils.fileExists(cmdFile); + expect(exists).toBe(false); + } }); }); @@ -474,7 +518,7 @@ Old instructions content }); it('should include proper instructions in skill files', async () => { - // Set up a configured tool + // Set up a configured tool with apply-change skill (which is in core profile) const skillsDir = path.join(testDir, '.claude', 'skills'); await fs.mkdir(path.join(skillsDir, 'openspec-apply-change'), { recursive: true, @@ -545,27 +589,9 @@ Old instructions content describe('smart update detection', () => { it('should show "up to date" message when skills have current version', async () => { - // Set up a configured tool with current version - const skillsDir = path.join(testDir, '.claude', 'skills'); - await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { - recursive: true, - }); - - // Use the current package version in generatedBy - const { version } = await import('../../package.json'); - await fs.writeFile( - path.join(skillsDir, 'openspec-explore', 'SKILL.md'), - `--- -name: openspec-explore -metadata: - author: openspec - version: "1.0" - generatedBy: "${version}" ---- - -Content here -` - ); + // Initialize full core profile output so there is no profile/delivery drift. + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); const consoleSpy = vi.spyOn(console, 'log'); @@ -797,29 +823,16 @@ metadata: }); it('should only update tools that need updating', async () => { - // Set up Claude with old version (needs update) - const claudeSkillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore'); - await fs.mkdir(claudeSkillDir, { recursive: true }); - await fs.writeFile( - path.join(claudeSkillDir, 'SKILL.md'), - `--- -metadata: - generatedBy: "0.1.0" ---- -` - ); + // Initialize both tools so Cursor is fully synced with profile/delivery. + const initCommand = new InitCommand({ tools: 'claude,cursor', force: true }); + await initCommand.execute(testDir); - // Set up Cursor with current version (up to date) - const { version } = await import('../../package.json'); - const cursorSkillDir = path.join(testDir, '.cursor', 'skills', 'openspec-explore'); - await fs.mkdir(cursorSkillDir, { recursive: true }); + // Make Claude stale to force a version update. + const claudeSkillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'); + const claudeContent = await fs.readFile(claudeSkillFile, 'utf-8'); await fs.writeFile( - path.join(cursorSkillDir, 'SKILL.md'), - `--- -metadata: - generatedBy: "${version}" ---- -` + claudeSkillFile, + claudeContent.replace(/generatedBy:\s*["'][^"']+["']/, 'generatedBy: "0.1.0"') ); const consoleSpy = vi.spyOn(console, 'log'); @@ -1293,7 +1306,7 @@ More user content after markers. consoleSpy.mockRestore(); }); - it('should create all 9 skills when upgrading legacy tools', async () => { + it('should create core 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( @@ -1305,7 +1318,7 @@ More user content after markers. const forceUpdateCommand = new UpdateCommand({ force: true }); await forceUpdateCommand.execute(testDir); - // Verify all 9 skill directories were created + // Legacy upgrade uses unfiltered templates (all skills), verify all exist const skillNames = [ 'openspec-explore', 'openspec-new-change', @@ -1345,4 +1358,305 @@ More user content after markers. expect(exists).toBe(true); }); }); + + describe('profile-aware updates', () => { + it('should generate only profile workflows when custom profile is set', async () => { + // Set custom profile with only explore and new + setMockConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'both', + workflows: ['explore', 'new'], + }); + + // Set up a configured tool + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + await updateCommand.execute(testDir); + + // Should create explore and new skills + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-explore', 'SKILL.md') + )).toBe(true); + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-new-change', 'SKILL.md') + )).toBe(true); + + // Should NOT create non-profile skills + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-apply-change', 'SKILL.md') + )).toBe(false); + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-propose', 'SKILL.md') + )).toBe(false); + }); + + it('should respect skills-only delivery setting', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'skills', + }); + + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + await updateCommand.execute(testDir); + + // Skills should be created + expect(await FileSystemUtils.fileExists( + path.join(skillsDir, 'openspec-explore', 'SKILL.md') + )).toBe(true); + + // Commands should NOT be created + const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); + expect(await FileSystemUtils.fileExists( + path.join(commandsDir, 'explore.md') + )).toBe(false); + }); + + it('should respect commands-only delivery setting', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + await updateCommand.execute(testDir); + + // Commands should be created + const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); + expect(await FileSystemUtils.fileExists( + path.join(commandsDir, 'explore.md') + )).toBe(true); + + // Skills should be removed for commands-only delivery + 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: {}, + profile: 'core', + delivery: 'skills', + }); + + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + const packageJsonPath = path.join(process.cwd(), 'package.json'); + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')) as { version: string }; + await fs.writeFile( + path.join(skillsDir, 'openspec-explore', 'SKILL.md'), + `--- +name: openspec-explore +metadata: + generatedBy: "${packageJson.version}" +--- +content +` + ); + + const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); + await fs.mkdir(commandsDir, { recursive: true }); + await fs.writeFile(path.join(commandsDir, 'explore.md'), 'old command'); + + await updateCommand.execute(testDir); + + // Command files should be removed due to delivery change, even though skill version is current + expect(await FileSystemUtils.fileExists( + path.join(commandsDir, 'explore.md') + )).toBe(false); + }); + + it('should detect commands-only tool configuration', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); + await fs.mkdir(commandsDir, { recursive: true }); + await fs.writeFile(path.join(commandsDir, 'explore.md'), 'existing command'); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + // Should not short-circuit with "No configured tools found" + const calls = consoleSpy.mock.calls.map(call => + call.map(arg => String(arg)).join(' ') + ); + const hasNoConfiguredMessage = calls.some(call => + call.includes('No configured tools found') + ); + expect(hasNoConfiguredMessage).toBe(false); + + // Commands should be updated/generated for the core profile + expect(await FileSystemUtils.fileExists( + path.join(commandsDir, 'propose.md') + )).toBe(true); + + consoleSpy.mockRestore(); + }); + + it('should display extra workflows note when workflows outside profile exist', async () => { + // Set core profile (propose, explore, apply, archive) + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + // Set up tool with extra workflows beyond core profile + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + // Add a non-core workflow + await fs.mkdir(path.join(skillsDir, 'openspec-new-change'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-new-change', 'SKILL.md'), 'old'); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + // Should display note about extra workflows + const calls = consoleSpy.mock.calls.map(call => + call.map(arg => String(arg)).join(' ') + ); + const hasExtraNote = calls.some(call => + call.includes('extra workflows not in profile') + ); + expect(hasExtraNote).toBe(true); + + consoleSpy.mockRestore(); + }); + }); + + describe('new tool detection', () => { + it('should detect new tool directories not currently configured', 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 a Cursor directory (not configured — no skills) + await fs.mkdir(path.join(testDir, '.cursor'), { recursive: true }); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + // Should detect Cursor as a new tool + const calls = consoleSpy.mock.calls.map(call => + call.map(arg => String(arg)).join(' ') + ); + const hasNewToolMessage = calls.some(call => + call.includes('Detected new tool') && call.includes('Cursor') + ); + expect(hasNewToolMessage).toBe(true); + + 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'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const calls = consoleSpy.mock.calls.map(call => + call.map(arg => String(arg)).join(' ') + ); + const hasNewToolMessage = calls.some(call => + call.includes('Detected new tool') + ); + expect(hasNewToolMessage).toBe(false); + + consoleSpy.mockRestore(); + }); + }); + + describe('scanInstalledWorkflows', () => { + it('should detect installed workflows across tools', async () => { + // Create skills for Claude + 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'), 'content'); + await fs.mkdir(path.join(claudeSkillsDir, 'openspec-apply-change'), { recursive: true }); + await fs.writeFile(path.join(claudeSkillsDir, 'openspec-apply-change', 'SKILL.md'), 'content'); + + const workflows = scanInstalledWorkflows(testDir, ['claude']); + expect(workflows).toContain('explore'); + expect(workflows).toContain('apply'); + expect(workflows).not.toContain('propose'); + }); + + it('should return union of workflows across multiple tools', async () => { + // Claude has explore + 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'), 'content'); + + // Cursor has apply + const cursorSkillsDir = path.join(testDir, '.cursor', 'skills'); + await fs.mkdir(path.join(cursorSkillsDir, 'openspec-apply-change'), { recursive: true }); + await fs.writeFile(path.join(cursorSkillsDir, 'openspec-apply-change', 'SKILL.md'), 'content'); + + const workflows = scanInstalledWorkflows(testDir, ['claude', 'cursor']); + expect(workflows).toContain('explore'); + expect(workflows).toContain('apply'); + }); + + it('should only match workflows in ALL_WORKFLOWS', async () => { + // Create a custom skill directory that doesn't match any workflow + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'my-custom-skill'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'my-custom-skill', 'SKILL.md'), 'content'); + + const workflows = scanInstalledWorkflows(testDir, ['claude']); + expect(workflows).toHaveLength(0); + }); + + it('should return empty array when no tools have skills', async () => { + const workflows = scanInstalledWorkflows(testDir, ['claude']); + expect(workflows).toHaveLength(0); + }); + }); + + describe('tools output', () => { + it('should list affected tools in output', async () => { + const skillsDir = path.join(testDir, '.claude', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'openspec-explore', 'SKILL.md'), 'old'); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const calls = consoleSpy.mock.calls.map(call => + call.map(arg => String(arg)).join(' ') + ); + const hasToolsList = calls.some(call => + call.includes('Tools:') && call.includes('Claude Code') + ); + expect(hasToolsList).toBe(true); + + consoleSpy.mockRestore(); + }); + }); }); diff --git a/test/prompts/searchable-multi-select.test.ts b/test/prompts/searchable-multi-select.test.ts new file mode 100644 index 000000000..99971a9c7 --- /dev/null +++ b/test/prompts/searchable-multi-select.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +/** + * Tests for searchable-multi-select keybinding behavior. + * + * We mock @inquirer/core to intercept the prompt's render function and + * keypress handler, then simulate key events to verify: + * - Space toggles selection (add/remove) + * - Enter confirms and submits + * - Tab does NOT confirm (removed) + * - Hint text is updated + */ + +// State store for the mock hook system +const state: Record = {}; +let stateIndex = 0; +let keypressHandler: ((key: Record) => void) | null = null; +let renderOutput = ''; + +function resetState() { + for (const k of Object.keys(state)) delete state[k as unknown as number]; + stateIndex = 0; + keypressHandler = null; + renderOutput = ''; + currentRenderFn = null; + currentConfig = null; + currentDone = null; +} + +// Re-render: reset hook index, re-invoke the render function +let currentRenderFn: ((config: Record, done: (v: string[]) => void) => string) | null = null; +let currentConfig: Record | null = null; +let currentDone: ((v: string[]) => void) | null = null; + +function rerender() { + if (!currentRenderFn || !currentConfig || !currentDone) return; + stateIndex = 0; + renderOutput = currentRenderFn(currentConfig, currentDone); +} + +vi.mock('@inquirer/core', () => { + return { + createPrompt: (fn: (config: Record, done: (v: string[]) => void) => string) => { + currentRenderFn = fn; + return (config: Record) => { + return new Promise((resolve) => { + currentConfig = config; + currentDone = resolve; + stateIndex = 0; + renderOutput = fn(config, resolve); + }); + }; + }, + useState: (initial: unknown) => { + const idx = stateIndex++; + if (!(idx in state)) { + state[idx] = typeof initial === 'function' ? (initial as () => unknown)() : initial; + } + const setter = (value: unknown) => { + state[idx] = value; + // Re-render after state change + rerender(); + }; + return [state[idx], setter]; + }, + useKeypress: (handler: (key: Record) => void) => { + keypressHandler = handler; + }, + useMemo: (fn: () => unknown, _deps: unknown[]) => fn(), + usePrefix: () => '?', + isEnterKey: (key: Record) => key.name === 'return' || key.name === 'enter', + isBackspaceKey: (key: Record) => key.name === 'backspace', + isUpKey: (key: Record) => key.name === 'up', + isDownKey: (key: Record) => key.name === 'down', + }; +}); + +function pressKey(name: string) { + if (!keypressHandler) throw new Error('No keypress handler registered'); + keypressHandler({ name, ctrl: false }); +} + +function getSelectedValues(): string[] { + return (state[1] as string[]) ?? []; +} + +function getStatus(): string { + return (state[3] as string) ?? 'idle'; +} + +function getError(): string | null { + return (state[4] as string | null) ?? null; +} + +const testChoices = [ + { name: 'Tool A', value: 'tool-a' }, + { name: 'Tool B', value: 'tool-b' }, + { name: 'Tool C', value: 'tool-c' }, +]; + +async function setup(choices = testChoices, validate?: (selected: string[]) => boolean | string) { + resetState(); + + const mod = await import('../../src/prompts/searchable-multi-select.js'); + + // Fire and forget - the promise resolves only when done() is called via Enter + // We just need the side effect of registering the keypress handler + mod.searchableMultiSelect({ + message: 'Select tools', + choices, + validate, + }); + + // The async chain in searchableMultiSelect involves: + // 1. await createSearchableMultiSelect() -> await import('@inquirer/core') + // 2. prompt(config) which registers the keypress handler synchronously + // Flush enough microtask ticks for the full chain to settle. + await vi.waitFor(() => { + if (!keypressHandler) throw new Error('Keypress handler not yet registered'); + }, { timeout: 500 }); +} + +describe('searchable-multi-select keybindings', () => { + beforeEach(() => { + resetState(); + vi.resetModules(); + }); + + describe('Space to toggle', () => { + it('should select highlighted item when Space is pressed', async () => { + await setup(); + pressKey('space'); + expect(getSelectedValues()).toContain('tool-a'); + }); + + it('should deselect highlighted item when Space is pressed on already-selected item', async () => { + await setup(); + pressKey('space'); + expect(getSelectedValues()).toContain('tool-a'); + + pressKey('space'); + expect(getSelectedValues()).not.toContain('tool-a'); + }); + + it('should toggle multiple items independently', async () => { + await setup(); + + // Select Tool A + pressKey('space'); + expect(getSelectedValues()).toEqual(['tool-a']); + + // Move down to Tool B, select it + pressKey('down'); + pressKey('space'); + expect(getSelectedValues()).toContain('tool-a'); + expect(getSelectedValues()).toContain('tool-b'); + + // Move back up to Tool A, deselect it + pressKey('up'); + pressKey('space'); + expect(getSelectedValues()).not.toContain('tool-a'); + expect(getSelectedValues()).toContain('tool-b'); + }); + }); + + describe('Enter to confirm', () => { + it('should set status to done when Enter is pressed', async () => { + await setup(); + pressKey('space'); + pressKey('return'); + expect(getStatus()).toBe('done'); + }); + + it('should confirm with empty selection', async () => { + await setup(); + pressKey('return'); + expect(getStatus()).toBe('done'); + }); + + it('should show validation error when validation fails', async () => { + const validate = (selected: string[]) => + selected.length > 0 ? true : 'Select at least one'; + await setup(testChoices, validate); + + pressKey('return'); + expect(getStatus()).toBe('idle'); + expect(getError()).toBe('Select at least one'); + }); + + it('should confirm when validation passes', async () => { + const validate = (selected: string[]) => + selected.length > 0 ? true : 'Select at least one'; + await setup(testChoices, validate); + + pressKey('space'); + pressKey('return'); + expect(getStatus()).toBe('done'); + }); + }); + + describe('Tab does not confirm', () => { + it('should not change status when Tab is pressed', async () => { + await setup(); + pressKey('space'); + pressKey('tab'); + expect(getStatus()).toBe('idle'); + }); + }); + + describe('hint text', () => { + it('should include Space toggle and Enter confirm in rendered output', async () => { + await setup(); + expect(renderOutput).toContain('Space'); + expect(renderOutput).toContain('toggle'); + expect(renderOutput).toContain('Enter'); + expect(renderOutput).toContain('confirm'); + expect(renderOutput).not.toMatch(/Tab.*confirm/); + }); + }); +});