From 00e058f0433d83498ae158ab2bb4ade814e671ab Mon Sep 17 00:00:00 2001 From: Patrick <4002194+askpatrickw@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:33:47 -0800 Subject: [PATCH 1/3] feat: add `openspec init --global` for tool global directory installation Install skills and commands to tool global directories (~/.claude/, ~/.config/opencode/, ~/.codex/) instead of project directories. This lets consultants and multi-repo developers install once globally rather than per-project. - Add optional `getGlobalRoot()` method to ToolCommandAdapter interface - Implement for Claude (~/.claude/), OpenCode (XDG-aware), Codex (~/.codex/) - Return null from all 20 remaining adapters (Cursor, Windsurf, etc.) - Migrate Codex adapter: getFilePath() now returns project-relative path - Add `--global` flag to `openspec init` (requires `--tools`) - Add `--global` flag to `openspec update` - Add `resolveGlobalRoot()` helper with OPENSPEC_GLOBAL_ROOT env var override - Add `getGlobalAdapters()` registry method Closes #752 Co-Authored-By: Claude Opus 4.6 --- .../2026-02-23-init-global/.openspec.yaml | 2 + .../archive/2026-02-23-init-global/design.md | 65 +++++ .../2026-02-23-init-global/proposal.md | 34 +++ .../specs/cli-init/spec.md | 54 ++++ .../specs/cli-update/spec.md | 26 ++ .../specs/command-generation/spec.md | 61 ++++ .../specs/global-install/spec.md | 80 +++++ .../archive/2026-02-23-init-global/tasks.md | 46 +++ openspec/specs/cli-init/spec.md | 273 +++--------------- openspec/specs/cli-update/spec.md | 212 ++------------ openspec/specs/command-generation/spec.md | 78 ++--- openspec/specs/global-install/spec.md | 80 +++++ src/cli/index.ts | 76 +++-- .../command-generation/adapters/amazon-q.ts | 4 + .../adapters/antigravity.ts | 4 + .../command-generation/adapters/auggie.ts | 4 + .../command-generation/adapters/claude.ts | 8 + src/core/command-generation/adapters/cline.ts | 4 + .../command-generation/adapters/codebuddy.ts | 4 + src/core/command-generation/adapters/codex.ts | 6 +- .../command-generation/adapters/continue.ts | 4 + .../command-generation/adapters/costrict.ts | 4 + src/core/command-generation/adapters/crush.ts | 4 + .../command-generation/adapters/cursor.ts | 4 + .../command-generation/adapters/factory.ts | 4 + .../command-generation/adapters/gemini.ts | 4 + .../adapters/github-copilot.ts | 4 + src/core/command-generation/adapters/iflow.ts | 4 + .../command-generation/adapters/kilocode.ts | 4 + src/core/command-generation/adapters/kiro.ts | 4 + .../command-generation/adapters/opencode.ts | 11 + src/core/command-generation/adapters/pi.ts | 4 + src/core/command-generation/adapters/qoder.ts | 4 + src/core/command-generation/adapters/qwen.ts | 4 + .../command-generation/adapters/roocode.ts | 4 + .../command-generation/adapters/windsurf.ts | 4 + src/core/command-generation/global-root.ts | 25 ++ src/core/command-generation/index.ts | 3 + src/core/command-generation/registry.ts | 10 + src/core/command-generation/types.ts | 11 +- src/core/init.ts | 135 +++++++++ src/core/update.ts | 90 ++++++ test/core/command-generation/adapters.test.ts | 25 +- .../command-generation/global-install.test.ts | 168 +++++++++++ test/core/global-init.test.ts | 189 ++++++++++++ 45 files changed, 1313 insertions(+), 535 deletions(-) create mode 100644 openspec/changes/archive/2026-02-23-init-global/.openspec.yaml create mode 100644 openspec/changes/archive/2026-02-23-init-global/design.md create mode 100644 openspec/changes/archive/2026-02-23-init-global/proposal.md create mode 100644 openspec/changes/archive/2026-02-23-init-global/specs/cli-init/spec.md create mode 100644 openspec/changes/archive/2026-02-23-init-global/specs/cli-update/spec.md create mode 100644 openspec/changes/archive/2026-02-23-init-global/specs/command-generation/spec.md create mode 100644 openspec/changes/archive/2026-02-23-init-global/specs/global-install/spec.md create mode 100644 openspec/changes/archive/2026-02-23-init-global/tasks.md create mode 100644 openspec/specs/global-install/spec.md create mode 100644 src/core/command-generation/global-root.ts create mode 100644 test/core/command-generation/global-install.test.ts create mode 100644 test/core/global-init.test.ts diff --git a/openspec/changes/archive/2026-02-23-init-global/.openspec.yaml b/openspec/changes/archive/2026-02-23-init-global/.openspec.yaml new file mode 100644 index 000000000..69e221fbf --- /dev/null +++ b/openspec/changes/archive/2026-02-23-init-global/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-24 diff --git a/openspec/changes/archive/2026-02-23-init-global/design.md b/openspec/changes/archive/2026-02-23-init-global/design.md new file mode 100644 index 000000000..070ce4acc --- /dev/null +++ b/openspec/changes/archive/2026-02-23-init-global/design.md @@ -0,0 +1,65 @@ +## Context + +`openspec init` and `openspec update` install skills and slash commands into project-relative directories (e.g., `.claude/commands/opsx/`). The only exception is Codex, whose adapter already returns an absolute path to `~/.codex/prompts/`. Several other tools (Claude Code, OpenCode) have documented global directories but OpenSpec has no way to target them. + +The existing architecture — a `ToolCommandAdapter` interface with `getFilePath()`, a `CommandAdapterRegistry`, and file-write logic in `InitCommand` / `UpdateCommand` that already handles absolute paths via `path.isAbsolute()` — provides a clean extension point. + +## Goals / Non-Goals + +**Goals:** +- Allow `openspec init --global --tools ` to write skills and commands to each tool's global directory +- Allow `openspec update --global` to refresh globally-installed files +- Support `OPENSPEC_GLOBAL_ROOT` env var to override base paths +- Migrate Codex adapter to use the new `getGlobalRoot()` pattern explicitly +- Keep all existing project-local behaviour unchanged + +**Non-Goals:** +- GUI or interactive prompts for global mode — `--tools` is always required +- Global installation of schemas or the `openspec/` directory structure — only skills and commands +- Auto-detection of whether to install globally vs locally +- Global skill installation paths for tools that only support project-scoped files (Cursor, Windsurf, etc.) +- Windows path verification — mark as "unknown / not yet confirmed" rather than guess + +## Decisions + +### 1. Extend `ToolCommandAdapter` with `getGlobalRoot()` + +An initial consideration was per-command `getGlobalFilePath(commandId)`, but skills also need global paths (see Decision #2). Instead, add a single optional `getGlobalRoot(): string | null` method to the interface. Adapters that support global installation return an absolute path to the tool's base directory; others return `null`. `InitCommand` and `UpdateCommand` derive both skill and command paths from this root. + +**Why not a separate GlobalAdapter class?** The global and local adapters share `formatFile()` and `toolId`. Splitting them would duplicate the formatting logic or require inheritance. A single method addition keeps the adapter registry simple — one adapter per tool, two path strategies. + +**Why optional (not abstract)?** There are 23+ adapters. Making it required would force boilerplate `return null` in ~20 of them. Instead, the base registry lookup treats missing `getGlobalRoot` as equivalent to returning `null`. + +### 2. Skills also need global path resolution + +Skills are written to `./skills/openspec-*/SKILL.md` — structurally parallel to commands. The same global-vs-local routing applies. If the tool has a global root (e.g., `~/.claude/`), skills go under `/skills/` and commands under `/commands/`. The single `getGlobalRoot()` method from Decision #1 handles both — no per-command or per-skill path methods needed. + +### 3. Route via `--global` flag in InitCommand and UpdateCommand + +Both commands gain a `--global` boolean option. When set: +- `InitCommand` skips directory structure creation (`openspec/`, `config.yaml`) — those are project-local concerns +- File writes use `adapter.getGlobalRoot()` to derive absolute paths instead of joining with `projectPath` +- `--tools` is required — no interactive selection (global install is explicit) +- Tools whose adapter returns `null` from `getGlobalRoot()` are skipped with a stderr warning + +### 4. `OPENSPEC_GLOBAL_ROOT` env var + +When set, overrides the base path for all `getGlobalRoot()` calls. Implementation: a helper function `resolveGlobalRoot(adapter)` checks the env var first, falling back to the adapter's native path. This keeps env var handling out of individual adapters. + +### 5. Codex migration + +The Codex adapter currently returns an absolute path from `getFilePath()`. Migration: +- `getFilePath()` → return project-relative path (`.codex/prompts/opsx-.md`) for consistency +- `getGlobalRoot()` → return the current absolute base (`~/.codex/`) + +This is a **breaking change for Codex project-local installs** (previously impossible since path was always absolute). In practice, Codex has always been global-only, so this formalizes what already exists. The `openspec init` default (non-global) will now create project-local Codex files; `openspec init --global --tools codex` preserves current behavior. + +## Risks / Trade-offs + +**[Risk] Codex migration changes default behavior** → The current Codex adapter always writes globally. After migration, `openspec init --tools codex` (without `--global`) would write project-locally. This is actually more correct — global should be opt-in. Document in release notes. + +**[Risk] Global files have no project association** → A globally-installed skill cannot reference project-specific paths. This is fine for OpenSpec skills which are project-agnostic instructions. If future skills need project context, they'll need project-local installation. + +**[Risk] Multiple global installs from different OpenSpec versions** → Running `openspec init --global` from different projects with different OpenSpec versions could overwrite global files with different versions. `openspec update --global` mitigates this — run it from the latest version. No lockfile or version tracking needed initially. + +**[Risk] Tool global paths change across versions** → If Claude Code moves its global directory, the adapter needs updating. Each adapter's `getGlobalRoot()` documents the source of truth for the path. Keep these paths in sync with upstream tool documentation. diff --git a/openspec/changes/archive/2026-02-23-init-global/proposal.md b/openspec/changes/archive/2026-02-23-init-global/proposal.md new file mode 100644 index 000000000..3556a2b23 --- /dev/null +++ b/openspec/changes/archive/2026-02-23-init-global/proposal.md @@ -0,0 +1,34 @@ +## Why + +`openspec init` and `openspec update` always install skills and commands into the current project directory. This forces consultants, agencies, and developers who work across many client repositories to either commit OpenSpec files into repos they don't control or re-run `openspec init` in every project. Several supported tools already have first-class global installation paths (`~/.claude/`, `~/.config/opencode/`, `~/.codex/`), and Codex already proves the pattern works inside this codebase — its adapter returns an absolute path today. + +Ref: [GitHub Issue #752](https://github.com/Fission-AI/OpenSpec/issues/752) + +## What Changes + +- Add an optional `getGlobalRoot(): string | null` method to the `ToolCommandAdapter` interface — returns an absolute path to the tool's global configuration root, or `null` +- Implement `getGlobalRoot()` across all 23 existing adapters (Claude Code, OpenCode, Codex return paths; Cursor, Windsurf, and others return `null`) +- Migrate the Codex adapter so `getFilePath()` returns a project-relative path and `getGlobalRoot()` returns the current absolute path — behaviour unchanged, intent explicit +- Add `--global` flag to `openspec init` requiring `--tools ` — routes file writes to global paths derived from `getGlobalRoot()` +- Add `--global` flag to `openspec update` — regenerates globally-installed files +- Support `OPENSPEC_GLOBAL_ROOT` env var to override the base path for all global installs + +## Capabilities + +### New Capabilities +- `global-install`: Adapter-level global path resolution, `--global` flag routing in init/update, and `OPENSPEC_GLOBAL_ROOT` env var handling + +### Modified Capabilities +- `command-generation`: `ToolCommandAdapter` interface gains `getGlobalRoot()`, all adapters implement it, Codex adapter migrated +- `cli-init`: `InitCommand` gains `--global` flag, routes to global paths when set, requires `--tools` with `--global` +- `cli-update`: `UpdateCommand` gains `--global` flag, scopes update to globally-installed files + +## Impact + +- **Adapters** (`src/core/command-generation/adapters/`): All 23 adapters gain a `getGlobalRoot()` method. Most return `null`. Claude, OpenCode, Codex return absolute paths. +- **Interface** (`src/core/command-generation/types.ts`): `ToolCommandAdapter` gains one optional method. +- **Registry** (`src/core/command-generation/registry.ts`): Gains `getGlobalAdapters()` helper to filter adapters with global support. +- **CLI** (`src/cli/index.ts`): `init` and `update` commands gain `--global` option. +- **Init** (`src/core/init.ts`): `InitCommand` gains `executeGlobal()` for global-path routing of both skills and commands. +- **Update** (`src/core/update.ts`): `UpdateCommand` gains `executeGlobal()` for global-scope updates. +- **Existing behaviour**: All project-local behaviour is unchanged. Global and local installs coexist — project-local takes precedence per each tool's own resolution order. diff --git a/openspec/changes/archive/2026-02-23-init-global/specs/cli-init/spec.md b/openspec/changes/archive/2026-02-23-init-global/specs/cli-init/spec.md new file mode 100644 index 000000000..989f7afbf --- /dev/null +++ b/openspec/changes/archive/2026-02-23-init-global/specs/cli-init/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: Global installation mode + +The `openspec init` command SHALL support a `--global` flag that installs skills and commands to tool global directories instead of project directories. + +#### Scenario: Global init for a specific tool + +- **WHEN** `openspec init --global --tools claude` is executed +- **THEN** the system SHALL write skills and commands to Claude Code's global directory (`~/.claude/`) +- **AND** the system SHALL NOT write any files to the current working directory +- **AND** the system SHALL NOT create the `openspec/` directory structure + +#### Scenario: Global init for multiple tools + +- **WHEN** `openspec init --global --tools claude,opencode` is executed +- **THEN** the system SHALL write skills and commands to each tool's respective global directory + +#### Scenario: Global init for all supported tools + +- **WHEN** `openspec init --global --tools all` is executed +- **THEN** the system SHALL install for all tools where `getGlobalRoot()` returns non-null +- **AND** the system SHALL print a summary listing installed tools and skipped tools (those without global support) + +#### Scenario: Global init without --tools + +- **WHEN** `openspec init --global` is executed without `--tools` +- **THEN** the system SHALL exit with a non-zero error code +- **AND** display: "--tools is required with --global. Use --tools all to install for all tools with a known global path." + +#### Scenario: Global init for tool without global support + +- **WHEN** `openspec init --global --tools cursor` is executed +- **AND** Cursor has no known global filesystem path +- **THEN** the system SHALL exit with a non-zero error code +- **AND** display a clear message that the specified tool does not support global installation + +#### Scenario: Global init success output + +- **WHEN** global initialization completes successfully +- **THEN** the system SHALL display a summary of files written per tool +- **AND** display the global directory paths used +- **AND** SHALL NOT display project-local "next steps" instructions + +### Requirement: Help text for global flag + +The `openspec init --help` output SHALL document the `--global` flag. + +#### Scenario: Help text content + +- **WHEN** `openspec init --help` is displayed +- **THEN** the output SHALL document the `--global` flag +- **AND** note that `--tools` is required when using `--global` +- **AND** list which tools support global installation diff --git a/openspec/changes/archive/2026-02-23-init-global/specs/cli-update/spec.md b/openspec/changes/archive/2026-02-23-init-global/specs/cli-update/spec.md new file mode 100644 index 000000000..a02602cc5 --- /dev/null +++ b/openspec/changes/archive/2026-02-23-init-global/specs/cli-update/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: Global update mode + +The `openspec update` command SHALL support a `--global` flag that updates globally-installed OpenSpec files. + +#### Scenario: Global update + +- **WHEN** `openspec update --global` is executed +- **THEN** the system SHALL regenerate globally-installed skill and command files for all tools that have global files present +- **AND** use the existing marker-based update logic to refresh managed content +- **AND** preserve any user-authored content outside OpenSpec markers + +#### Scenario: Global update with no globally-installed files + +- **WHEN** `openspec update --global` is executed +- **AND** no globally-installed OpenSpec files are found +- **THEN** the system SHALL display a message indicating no global installations were found +- **AND** suggest running `openspec init --global --tools ` first + +#### Scenario: Non-global update unchanged + +- **WHEN** `openspec update` is executed without `--global` +- **THEN** the system SHALL only update project-local files +- **AND** SHALL NOT modify or scan globally-installed files +- **AND** all existing update behavior SHALL remain unchanged diff --git a/openspec/changes/archive/2026-02-23-init-global/specs/command-generation/spec.md b/openspec/changes/archive/2026-02-23-init-global/specs/command-generation/spec.md new file mode 100644 index 000000000..c49d853ec --- /dev/null +++ b/openspec/changes/archive/2026-02-23-init-global/specs/command-generation/spec.md @@ -0,0 +1,61 @@ +## MODIFIED Requirements + +### Requirement: ToolCommandAdapter interface + +The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting. + +#### Scenario: Adapter interface structure + +- **WHEN** implementing a tool adapter +- **THEN** `ToolCommandAdapter` SHALL require: + - `toolId`: string identifier matching `AIToolOption.value` + - `getFilePath(commandId: string)`: returns file path for command (relative from project root) + - `formatFile(content: CommandContent)`: returns complete file content with frontmatter +- **AND** `ToolCommandAdapter` SHALL optionally support: + - `getGlobalRoot()`: returns absolute path to the tool's global configuration directory, or `null` if the tool has no global filesystem path + +#### Scenario: Claude adapter formatting + +- **WHEN** formatting a command for Claude Code +- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields +- **AND** file path SHALL follow pattern `.claude/commands/opsx/.md` +- **AND** `getGlobalRoot()` SHALL return `~/.claude/` (macOS/Linux) or `%APPDATA%\Claude\` (Windows) + +#### Scenario: Cursor adapter formatting + +- **WHEN** formatting a command for Cursor +- **THEN** the adapter SHALL output YAML frontmatter with `name` as `/opsx-`, `id`, `category`, `description` fields +- **AND** file path SHALL follow pattern `.cursor/commands/opsx-.md` +- **AND** `getGlobalRoot()` SHALL return `null` + +#### Scenario: Windsurf adapter formatting + +- **WHEN** formatting a command for Windsurf +- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields +- **AND** file path SHALL follow pattern `.windsurf/workflows/opsx-.md` +- **AND** `getGlobalRoot()` SHALL return `null` + +## ADDED Requirements + +### Requirement: CommandAdapterRegistry global filtering + +The registry SHALL support filtering adapters by global installation support. + +#### Scenario: Get adapters with global support + +- **WHEN** calling `CommandAdapterRegistry.getGlobalAdapters()` +- **THEN** it SHALL return an array of adapters where `getGlobalRoot()` returns a non-null value + +### Requirement: Codex adapter migration + +The Codex adapter SHALL separate project-local and global path concerns. + +#### Scenario: Codex project-local path + +- **WHEN** calling `getFilePath()` on the Codex adapter +- **THEN** it SHALL return a project-relative path: `.codex/prompts/opsx-.md` + +#### Scenario: Codex global path + +- **WHEN** calling `getGlobalRoot()` on the Codex adapter +- **THEN** it SHALL return the absolute path to the Codex home directory (respecting `$CODEX_HOME`, defaulting to `~/.codex/`) diff --git a/openspec/changes/archive/2026-02-23-init-global/specs/global-install/spec.md b/openspec/changes/archive/2026-02-23-init-global/specs/global-install/spec.md new file mode 100644 index 000000000..4f7139dd8 --- /dev/null +++ b/openspec/changes/archive/2026-02-23-init-global/specs/global-install/spec.md @@ -0,0 +1,80 @@ +## ADDED Requirements + +### Requirement: Global root resolution + +The system SHALL resolve a tool's global installation root directory via the adapter interface. + +#### Scenario: Adapter with known global root + +- **WHEN** calling `getGlobalRoot()` on a tool adapter that supports global installation (e.g., Claude Code, OpenCode, Codex) +- **THEN** it SHALL return an absolute path to the tool's global configuration directory +- **AND** the path SHALL follow the tool's own documented convention per platform + +#### Scenario: Adapter without global support + +- **WHEN** calling `getGlobalRoot()` on a tool adapter that has no global filesystem path (e.g., Cursor, Windsurf) +- **THEN** it SHALL return `null` + +#### Scenario: OPENSPEC_GLOBAL_ROOT override + +- **WHEN** the `OPENSPEC_GLOBAL_ROOT` environment variable is set +- **THEN** the system SHALL use the env var value as the base path for all global installs, replacing the adapter's native global root +- **AND** the per-tool subdirectory structure SHALL be preserved under the override path + +### Requirement: Global path derivation for commands + +The system SHALL derive global command file paths from the adapter's global root. + +#### Scenario: Deriving global command path + +- **WHEN** a tool adapter returns a non-null global root +- **AND** the system needs the global path for a command +- **THEN** the system SHALL construct the absolute path by combining the global root with the tool's command path pattern (e.g., `/commands/opsx/.md` for Claude Code) + +### Requirement: Global path derivation for skills + +The system SHALL derive global skill file paths from the adapter's global root. + +#### Scenario: Deriving global skill path + +- **WHEN** a tool adapter returns a non-null global root +- **AND** the system needs the global path for a skill +- **THEN** the system SHALL construct the absolute path by combining the global root with the tool's skill path pattern (e.g., `/skills/openspec-/SKILL.md` for Claude Code) + +### Requirement: Global tool path reference + +The system SHALL use the following global root paths per tool and platform. + +#### Scenario: Claude Code global root + +- **WHEN** resolving the global root for Claude Code +- **THEN** on macOS and Linux it SHALL be `~/.claude/` +- **AND** on Windows it SHALL be `%APPDATA%\Claude\` + +#### Scenario: OpenCode global root + +- **WHEN** resolving the global root for OpenCode +- **THEN** it SHALL respect `$XDG_CONFIG_HOME` if set, defaulting to `~/.config/opencode/` +- **AND** on Windows it SHALL be `%APPDATA%\opencode\` + +#### Scenario: Codex global root + +- **WHEN** resolving the global root for Codex +- **THEN** it SHALL respect `$CODEX_HOME` if set, defaulting to `~/.codex/` +- **AND** on Windows it SHALL be `%USERPROFILE%\.codex\` + +### Requirement: Global and local coexistence + +Global and project-local installations SHALL coexist without conflict. + +#### Scenario: Both global and local installed + +- **WHEN** a tool has both global and project-local OpenSpec files +- **THEN** the project-local files SHALL take precedence per each tool's own resolution order +- **AND** global files serve as a fallback for projects without local installation + +#### Scenario: Update scoping + +- **WHEN** `openspec update` is run without `--global` +- **THEN** the system SHALL only update project-local files +- **AND** SHALL NOT modify globally-installed files diff --git a/openspec/changes/archive/2026-02-23-init-global/tasks.md b/openspec/changes/archive/2026-02-23-init-global/tasks.md new file mode 100644 index 000000000..259032907 --- /dev/null +++ b/openspec/changes/archive/2026-02-23-init-global/tasks.md @@ -0,0 +1,46 @@ +## 1. Adapter Interface & Registry + +- [x] 1.1 Add optional `getGlobalRoot(): string | null` method to `ToolCommandAdapter` interface in `src/core/command-generation/types.ts` +- [x] 1.2 Add `getGlobalAdapters()` method to `CommandAdapterRegistry` in `src/core/command-generation/registry.ts` that filters adapters returning non-null from `getGlobalRoot()` +- [x] 1.3 Add `resolveGlobalRoot(adapter)` helper that checks `OPENSPEC_GLOBAL_ROOT` env var first, falling back to `adapter.getGlobalRoot()` + +## 2. Adapter Implementations + +- [x] 2.1 Add `getGlobalRoot()` to Claude adapter returning `~/.claude/` (macOS/Linux) or `%APPDATA%\Claude\` (Windows) +- [x] 2.2 Add `getGlobalRoot()` to OpenCode adapter returning XDG-aware `~/.config/opencode/` (macOS/Linux) or `%APPDATA%\opencode\` (Windows) +- [x] 2.3 Migrate Codex adapter: change `getFilePath()` to return project-relative `.codex/prompts/opsx-.md`, add `getGlobalRoot()` returning current absolute path logic (respecting `$CODEX_HOME`) +- [x] 2.4 Add `getGlobalRoot()` returning `null` to all remaining adapters (Cursor, Windsurf, Cline, Roocode, etc.) + +## 3. InitCommand Global Mode + +- [x] 3.1 Add `--global` option to `init` command definition in `src/cli/index.ts` +- [x] 3.2 Add global mode validation in `InitCommand`: require `--tools` with `--global`, reject tools without global support, skip directory structure creation +- [x] 3.3 Implement global path routing in `generateSkillsAndCommands()`: when `global` option is set, derive skill and command paths from `resolveGlobalRoot(adapter)` instead of project path +- [x] 3.4 Implement global-mode success output: display per-tool summary with global directory paths, omit project-local "next steps" + +## 4. UpdateCommand Global Mode + +- [x] 4.1 Add `--global` option to `update` command definition in `src/cli/index.ts` +- [x] 4.2 Implement global update scan: detect globally-installed OpenSpec files by checking each adapter's global root for `openspec-*` / `opsx-*` patterns +- [x] 4.3 Implement global update execution: regenerate globally-installed files using existing marker-based update logic, scoped to global paths only +- [x] 4.4 Handle case where no global files are found: display message suggesting `openspec init --global --tools ` + +## 5. Help & Discoverability + +- [x] 5.1 Update `openspec init --help` to document `--global`, note `--tools` requirement, list tools with global support +- [x] 5.2 Update `openspec update --help` to document `--global` flag + +## 6. Tests + +- [x] 6.1 Add unit tests for `getGlobalRoot()` on Claude, OpenCode, and Codex adapters (including env var overrides) +- [x] 6.2 Add unit tests for `resolveGlobalRoot()` helper with and without `OPENSPEC_GLOBAL_ROOT` +- [x] 6.3 Add unit tests for `CommandAdapterRegistry.getGlobalAdapters()` filtering +- [x] 6.4 Add integration tests for `openspec init --global --tools claude` (verify skills and commands written to global paths) +- [x] 6.5 Add integration tests for `openspec init --global` without `--tools` (verify error) +- [x] 6.6 Add integration tests for `openspec init --global --tools cursor` (verify error for unsupported tool) +- [x] 6.7 Add integration tests for `openspec init --global --tools all` (verify installs for all globally-supported tools) +- [x] 6.8 Add integration tests for `openspec init --global --tools claude,opencode` (verify multi-tool comma-separated install) +- [x] 6.9 Add integration tests for `openspec init --global --tools claude,cursor` (verify mixed support: installs supported, skips unsupported) +- [x] 6.10 Add integration tests for `openspec init --global --tools ""` (verify error on empty/whitespace tools value) +- [x] 6.11 Add integration tests for `openspec update --global` (verify global files refreshed, content regenerated) +- [x] 6.12 Add test for Codex adapter migration: verify `getFilePath()` returns relative path, `getGlobalRoot()` returns absolute diff --git a/openspec/specs/cli-init/spec.md b/openspec/specs/cli-init/spec.md index a1a70e59b..989f7afbf 100644 --- a/openspec/specs/cli-init/spec.md +++ b/openspec/specs/cli-init/spec.md @@ -1,255 +1,54 @@ -# CLI Init Specification +## ADDED Requirements -## Purpose +### Requirement: Global installation mode -The `openspec init` command SHALL create a complete OpenSpec directory structure in any project, enabling immediate adoption of OpenSpec conventions with support for multiple AI coding assistants. -## Requirements -### Requirement: Progress Indicators +The `openspec init` command SHALL support a `--global` flag that installs skills and commands to tool global directories instead of project directories. -The command SHALL display progress indicators during initialization to provide clear feedback about each step. +#### Scenario: Global init for a specific tool -#### Scenario: Displaying initialization progress +- **WHEN** `openspec init --global --tools claude` is executed +- **THEN** the system SHALL write skills and commands to Claude Code's global directory (`~/.claude/`) +- **AND** the system SHALL NOT write any files to the current working directory +- **AND** the system SHALL NOT create the `openspec/` directory structure -- **WHEN** executing initialization steps -- **THEN** validate environment silently in background (no output unless error) -- **AND** display progress with ora spinners: - - Show spinner: "⠋ Creating OpenSpec structure..." - - Then success: "✔ OpenSpec structure created" - - Show spinner: "⠋ Configuring AI tools..." - - Then success: "✔ AI tools configured" +#### Scenario: Global init for multiple tools -### Requirement: Directory Creation +- **WHEN** `openspec init --global --tools claude,opencode` is executed +- **THEN** the system SHALL write skills and commands to each tool's respective global directory -The command SHALL create the OpenSpec directory structure with config file. +#### Scenario: Global init for all supported tools -#### Scenario: Creating OpenSpec structure +- **WHEN** `openspec init --global --tools all` is executed +- **THEN** the system SHALL install for all tools where `getGlobalRoot()` returns non-null +- **AND** the system SHALL print a summary listing installed tools and skipped tools (those without global support) -- **WHEN** `openspec init` is executed -- **THEN** create the following directory structure: -``` -openspec/ -├── config.yaml -├── specs/ -└── changes/ - └── archive/ -``` +#### Scenario: Global init without --tools -### Requirement: AI Tool Configuration +- **WHEN** `openspec init --global` is executed without `--tools` +- **THEN** the system SHALL exit with a non-zero error code +- **AND** display: "--tools is required with --global. Use --tools all to install for all tools with a known global path." -The command SHALL configure AI coding assistants with skills and slash commands using a searchable multi-select experience. +#### Scenario: Global init for tool without global support -#### Scenario: Prompting for AI tool selection +- **WHEN** `openspec init --global --tools cursor` is executed +- **AND** Cursor has no known global filesystem path +- **THEN** the system SHALL exit with a non-zero error code +- **AND** display a clear message that the specified tool does not support global installation -- **WHEN** run interactively -- **THEN** display animated welcome screen with OpenSpec logo -- **AND** present a searchable multi-select that shows all available tools -- **AND** mark already configured tools with "(configured ✓)" indicator -- **AND** pre-select configured tools for easy refresh -- **AND** sort configured tools to appear first in the list -- **AND** allow filtering by typing to search +#### Scenario: Global init success output -#### Scenario: Selecting tools to configure +- **WHEN** global initialization completes successfully +- **THEN** the system SHALL display a summary of files written per tool +- **AND** display the global directory paths used +- **AND** SHALL NOT display project-local "next steps" instructions -- **WHEN** user selects tools and confirms -- **THEN** generate skills in `./skills/` directory for each selected tool -- **AND** generate slash commands in `./commands/opsx/` directory for each selected tool -- **AND** create `openspec/config.yaml` with default schema setting +### Requirement: Help text for global flag -### Requirement: Interactive Mode -The command SHALL provide an interactive menu for AI tool selection with clear navigation instructions. -#### Scenario: Displaying interactive menu -- **WHEN** run in fresh or extend mode -- **THEN** present a looping select menu that lets users toggle tools with Space and review selections with Enter -- **AND** when Enter is pressed on a highlighted selectable tool that is not already selected, automatically add it to the selection before moving to review so the highlighted tool is configured -- **AND** label already configured tools with "(already configured)" while keeping disabled options marked "coming soon" -- **AND** change the prompt copy in extend mode to "Which AI tools would you like to add or refresh?" -- **AND** display inline instructions clarifying that Space toggles tools and Enter selects the highlighted tool before reviewing selections +The `openspec init --help` output SHALL document the `--global` flag. -### Requirement: Safety Checks -The command SHALL perform safety checks to prevent overwriting existing structures and ensure proper permissions. +#### Scenario: Help text content -#### Scenario: Detecting existing initialization -- **WHEN** the `openspec/` directory already exists -- **THEN** inform the user that OpenSpec is already initialized, skip recreating the base structure, and enter an extend mode -- **AND** continue to the AI tool selection step so additional tools can be configured -- **AND** display the existing-initialization error message only when the user declines to add any AI tools - -### Requirement: Success Output - -The command SHALL provide clear, actionable next steps upon successful initialization. - -#### Scenario: Displaying success message - -- **WHEN** initialization completes successfully -- **THEN** display categorized summary: - - "Created: " for newly configured tools - - "Refreshed: " for already-configured tools that were updated - - Count of skills and commands generated -- **AND** display getting started section with: - - `/opsx:new` - Start a new change - - `/opsx:continue` - Create the next artifact - - `/opsx:apply` - Implement tasks -- **AND** display links to documentation and feedback - -#### Scenario: Displaying restart instruction - -- **WHEN** initialization completes successfully and tools were created or refreshed -- **THEN** display instruction to restart IDE for slash commands to take effect - -### Requirement: Exit Codes - -The command SHALL use consistent exit codes to indicate different failure modes. - -#### Scenario: Returning exit codes - -- **WHEN** the command completes -- **THEN** return appropriate exit code: - - 0: Success - - 1: General error (including when OpenSpec directory already exists) - - 2: Insufficient permissions (reserved for future use) - - 3: User cancelled operation (reserved for future use) - -### Requirement: Additional AI Tool Initialization -`openspec init` SHALL allow users to add configuration files for new AI coding assistants after the initial setup. - -#### Scenario: Configuring an extra tool after initial setup -- **GIVEN** an `openspec/` directory already exists and at least one AI tool file is present -- **WHEN** the user runs `openspec init` and selects a different supported AI tool -- **THEN** generate that tool's configuration files with OpenSpec markers the same way as during first-time initialization -- **AND** leave existing tool configuration files unchanged except for managed sections that need refreshing -- **AND** exit with code 0 and display a success summary highlighting the newly added tool files - -### Requirement: Success Output Enhancements -`openspec init` SHALL summarize tool actions when initialization or extend mode completes. - -#### Scenario: Showing tool summary -- **WHEN** the command completes successfully -- **THEN** display a categorized summary of tools that were created, refreshed, or skipped (including already-configured skips) -- **AND** personalize the "Next steps" header using the names of the selected tools, defaulting to a generic label when none remain - -### Requirement: Exit Code Adjustments -`openspec init` SHALL treat extend mode without new native tool selections as a successful refresh. - -#### Scenario: Allowing empty extend runs -- **WHEN** OpenSpec is already initialized and the user selects no additional natively supported tools -- **THEN** complete successfully without requiring additional tool setup -- **AND** preserve the existing OpenSpec structure and config files -- **AND** exit with code 0 - -### Requirement: Non-Interactive Mode - -The command SHALL support non-interactive operation through command-line options. - -#### Scenario: Select all tools non-interactively - -- **WHEN** run with `--tools all` -- **THEN** automatically select every available AI tool without prompting -- **AND** proceed with skill and command generation - -#### Scenario: Select specific tools non-interactively - -- **WHEN** run with `--tools claude,cursor` -- **THEN** parse the comma-separated tool IDs -- **AND** generate skills and commands for specified tools only - -#### Scenario: Skip tool configuration non-interactively - -- **WHEN** run with `--tools none` -- **THEN** create only the openspec directory structure -- **AND** skip skill and command generation -- **AND** create config only when config creation conditions are met - -#### Scenario: Invalid tool specification - -- **WHEN** run with `--tools invalid-tool` -- **THEN** fail with exit code 1 -- **AND** display an error listing available values (`all`, `none`, and supported tool IDs) - -#### Scenario: Reserved value combined with tool IDs - -- **WHEN** run with `--tools all,claude` or `--tools none,cursor` -- **THEN** fail with exit code 1 -- **AND** display an error explaining reserved values cannot be combined with specific tool IDs - -#### Scenario: Missing --tools in non-interactive mode - -- **GIVEN** prompts are unavailable in non-interactive execution -- **WHEN** user runs `openspec init` without `--tools` -- **THEN** fail with exit code 1 -- **AND** instruct to use `--tools all`, `--tools none`, or explicit tool IDs - -### Requirement: Skill Generation - -The command SHALL generate Agent Skills for selected AI tools. - -#### Scenario: Generating skills for a tool - -- **WHEN** a tool is selected during initialization -- **THEN** create 9 skill directories under `./skills/`: - - `openspec-explore/SKILL.md` - - `openspec-new-change/SKILL.md` - - `openspec-continue-change/SKILL.md` - - `openspec-apply-change/SKILL.md` - - `openspec-ff-change/SKILL.md` - - `openspec-verify-change/SKILL.md` - - `openspec-sync-specs/SKILL.md` - - `openspec-archive-change/SKILL.md` - - `openspec-bulk-archive-change/SKILL.md` -- **AND** each SKILL.md SHALL contain YAML frontmatter with name and description -- **AND** each SKILL.md SHALL contain the skill instructions - -### Requirement: Slash Command Generation - -The command SHALL generate opsx slash commands for selected AI tools. - -#### Scenario: Generating slash commands for a tool - -- **WHEN** a tool is selected during initialization -- **THEN** create 9 slash command files using the tool's command adapter: - - `/opsx:explore` - - `/opsx:new` - - `/opsx:continue` - - `/opsx:apply` - - `/opsx:ff` - - `/opsx:verify` - - `/opsx:sync` - - `/opsx:archive` - - `/opsx:bulk-archive` -- **AND** use tool-specific path conventions (e.g., `.claude/commands/opsx/` for Claude) -- **AND** include tool-specific frontmatter format - -### Requirement: Config File Generation - -The command SHALL create an OpenSpec config file with schema settings. - -#### Scenario: Creating config.yaml - -- **WHEN** initialization completes -- **AND** config.yaml does not exist -- **THEN** create `openspec/config.yaml` with default schema setting -- **AND** display config location in output - -#### Scenario: Preserving existing config.yaml - -- **WHEN** initialization runs in extend mode -- **AND** `openspec/config.yaml` already exists -- **THEN** preserve the existing config file -- **AND** display "(exists)" indicator in output - -### Requirement: Experimental Command Alias - -The command SHALL maintain backward compatibility with the experimental command. - -#### Scenario: Running openspec experimental - -- **WHEN** user runs `openspec experimental` -- **THEN** delegate to `openspec init` -- **AND** the command SHALL be hidden from help output - -## Why - -Manual creation of OpenSpec structure is error-prone and creates adoption friction. A standardized init command ensures: -- Consistent structure across all projects -- Proper AI instruction files are always included -- Quick onboarding for new projects -- Clear conventions from the start +- **WHEN** `openspec init --help` is displayed +- **THEN** the output SHALL document the `--global` flag +- **AND** note that `--tools` is required when using `--global` +- **AND** list which tools support global installation diff --git a/openspec/specs/cli-update/spec.md b/openspec/specs/cli-update/spec.md index 5ba070c4d..a02602cc5 100644 --- a/openspec/specs/cli-update/spec.md +++ b/openspec/specs/cli-update/spec.md @@ -1,202 +1,26 @@ -# Update Command Specification +## ADDED Requirements -## Purpose +### Requirement: Global update mode -As a developer using OpenSpec, I want to update the OpenSpec instructions in my project when new versions are released, so that I can benefit from improvements to AI agent instructions. -## Requirements -### Requirement: Update Behavior -The update command SHALL update OpenSpec instruction files to the latest templates in a team-friendly manner. +The `openspec update` command SHALL support a `--global` flag that updates globally-installed OpenSpec files. -#### Scenario: Running update command -- **WHEN** a user runs `openspec update` -- **THEN** replace `openspec/AGENTS.md` with the latest template -- **AND** if a root-level stub (`AGENTS.md`/`CLAUDE.md`) exists, refresh it so it points to `@/openspec/AGENTS.md` +#### Scenario: Global update -### Requirement: Prerequisites +- **WHEN** `openspec update --global` is executed +- **THEN** the system SHALL regenerate globally-installed skill and command files for all tools that have global files present +- **AND** use the existing marker-based update logic to refresh managed content +- **AND** preserve any user-authored content outside OpenSpec markers -The command SHALL require an existing OpenSpec structure before allowing updates. +#### Scenario: Global update with no globally-installed files -#### Scenario: Checking prerequisites +- **WHEN** `openspec update --global` is executed +- **AND** no globally-installed OpenSpec files are found +- **THEN** the system SHALL display a message indicating no global installations were found +- **AND** suggest running `openspec init --global --tools ` first -- **GIVEN** the command requires an existing `openspec` directory (created by `openspec init`) -- **WHEN** the `openspec` directory does not exist -- **THEN** display error: "No OpenSpec directory found. Run 'openspec init' first." -- **AND** exit with code 1 +#### Scenario: Non-global update unchanged -### Requirement: File Handling -The update command SHALL handle file updates in a predictable and safe manner. - -#### Scenario: Updating files -- **WHEN** updating files -- **THEN** completely replace `openspec/AGENTS.md` with the latest template -- **AND** if a root-level stub exists, update the managed block content so it keeps directing teammates to `@/openspec/AGENTS.md` - -### Requirement: Tool-Agnostic Updates -The update command SHALL refresh OpenSpec-managed files in a predictable manner while respecting each team's chosen tooling. - -#### Scenario: Updating files -- **WHEN** updating files -- **THEN** completely replace `openspec/AGENTS.md` with the latest template -- **AND** create or refresh the root-level `AGENTS.md` stub using the managed marker block, even if the file was previously absent -- **AND** update only the OpenSpec-managed sections inside existing AI tool files, leaving user-authored content untouched -- **AND** avoid creating new native-tool configuration files (slash commands, CLAUDE.md, etc.) unless they already exist - -### Requirement: Core Files Always Updated -The update command SHALL always update the core OpenSpec files and display an ASCII-safe success message. - -#### Scenario: Successful update -- **WHEN** the update completes successfully -- **THEN** replace `openspec/AGENTS.md` with the latest template -- **AND** if a root-level stub exists, refresh it so it still directs contributors to `@/openspec/AGENTS.md` - -### Requirement: Slash Command Updates - -The update command SHALL refresh existing slash command files for configured tools without creating new ones, and ensure the OpenCode archive command accepts change ID arguments. - -#### Scenario: Updating slash commands for Antigravity -- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` -- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter -- **AND** skip creating any missing workflow files during update, mirroring the behavior for Windsurf and other IDEs - -#### Scenario: Updating slash commands for Claude Code -- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md` -- **THEN** refresh each file using shared templates -- **AND** ensure templates include instructions for the relevant workflow stage - -#### Scenario: Updating slash commands for CodeBuddy Code -- **WHEN** `.codebuddy/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md` -- **THEN** refresh each file using the shared CodeBuddy templates that include YAML frontmatter for the `description` and `argument-hint` fields -- **AND** use square bracket format for `argument-hint` parameters (e.g., `[change-id]`) -- **AND** preserve any user customizations outside the OpenSpec managed markers - -#### Scenario: Updating slash commands for Cline -- **WHEN** `.clinerules/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` -- **THEN** refresh each file using shared templates -- **AND** include Cline-specific Markdown heading frontmatter -- **AND** ensure templates include instructions for the relevant workflow stage - -#### Scenario: Updating slash commands for Continue -- **WHEN** `.continue/prompts/` contains `openspec-proposal.prompt`, `openspec-apply.prompt`, and `openspec-archive.prompt` -- **THEN** refresh each file using shared templates -- **AND** ensure templates include instructions for the relevant workflow stage - -#### Scenario: Updating slash commands for Crush -- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md` -- **THEN** refresh each file using shared templates -- **AND** include Crush-specific frontmatter with OpenSpec category and tags -- **AND** ensure templates include instructions for the relevant workflow stage - -#### Scenario: Updating slash commands for Cursor -- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` -- **THEN** refresh each file using shared templates -- **AND** ensure templates include instructions for the relevant workflow stage - -#### Scenario: Updating slash commands for Factory Droid -- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` -- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields -- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid -- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched -- **AND** skip creating missing files during update - -#### Scenario: Updating slash commands for OpenCode -- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` -- **THEN** refresh each file using shared templates -- **AND** ensure templates include instructions for the relevant workflow stage -- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments - -#### Scenario: Updating slash commands for Windsurf -- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` -- **THEN** refresh each file using shared templates wrapped in OpenSpec markers -- **AND** ensure templates include instructions for the relevant workflow stage -- **AND** skip creating missing files (the update command only refreshes what already exists) - -#### Scenario: Updating slash commands for Kilo Code -- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` -- **THEN** refresh each file using shared templates wrapped in OpenSpec markers -- **AND** ensure templates include instructions for the relevant workflow stage -- **AND** skip creating missing files (the update command only refreshes what already exists) - -#### Scenario: Updating slash commands for Codex -- **GIVEN** the global Codex prompt directory contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` -- **WHEN** a user runs `openspec update` -- **THEN** refresh each file using the shared slash-command templates (including placeholder guidance) -- **AND** preserve any unmanaged content outside the OpenSpec marker block -- **AND** skip creation when a Codex prompt file is missing - -#### Scenario: Updating slash commands for GitHub Copilot -- **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md` -- **THEN** refresh each file using shared templates while preserving the YAML frontmatter -- **AND** update only the OpenSpec-managed block between markers -- **AND** ensure templates include instructions for the relevant workflow stage - -#### Scenario: Updating slash commands for Gemini CLI -- **WHEN** `.gemini/commands/openspec/` contains `proposal.toml`, `apply.toml`, and `archive.toml` -- **THEN** refresh the body of each file using the shared proposal/apply/archive templates -- **AND** replace only the content between `` and `` markers inside the `prompt = """` block so the TOML framing (`description`, `prompt`) stays intact -- **AND** skip creating any missing `.toml` files during update; only pre-existing Gemini commands are refreshed - -#### Scenario: Updating slash commands for iFlow CLI -- **WHEN** `.iflow/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` -- **THEN** refresh each file using shared templates -- **AND** preserve the YAML frontmatter with `name`, `id`, `category`, and `description` fields -- **AND** update only the OpenSpec-managed block between markers -- **AND** ensure templates include instructions for the relevant workflow stage - -#### Scenario: Missing slash command file -- **WHEN** a tool lacks a slash command file -- **THEN** do not create a new file during update - -### Requirement: Archive Command Argument Support -The archive slash command template SHALL support optional change ID arguments for tools that support `$ARGUMENTS` placeholder. - -#### Scenario: Archive command with change ID argument -- **WHEN** a user invokes `/openspec:archive ` with a change ID -- **THEN** the template SHALL instruct the AI to validate the provided change ID against `openspec list` -- **AND** use the provided change ID for archiving if valid -- **AND** fail fast if the provided change ID doesn't match an archivable change - -#### Scenario: Archive command without argument (backward compatibility) -- **WHEN** a user invokes `/openspec:archive` without providing a change ID -- **THEN** the template SHALL instruct the AI to identify the change ID from context or by running `openspec list` -- **AND** proceed with the existing behavior (maintaining backward compatibility) - -#### Scenario: OpenCode archive template generation -- **WHEN** generating the OpenCode archive slash command file -- **THEN** include the `$ARGUMENTS` placeholder in the frontmatter -- **AND** wrap it in a clear structure like `\n $ARGUMENTS\n` to indicate the expected argument -- **AND** include validation steps in the template body to check if the change ID is valid - -## Edge Cases - -### Requirement: Error Handling - -The command SHALL handle edge cases gracefully. - -#### Scenario: File permission errors - -- **WHEN** file write fails -- **THEN** let the error bubble up naturally with file path - -#### Scenario: Missing AI tool files - -- **WHEN** an AI tool configuration file doesn't exist -- **THEN** skip updating that file -- **AND** do not create it - -#### Scenario: Custom directory names - -- **WHEN** considering custom directory names -- **THEN** not supported in this change -- **AND** the default directory name `openspec` SHALL be used - -## Success Criteria - -Users SHALL be able to: -- Update OpenSpec instructions with a single command -- Get the latest AI agent instructions -- See clear confirmation of the update - -The update process SHALL be: -- Simple and fast (no version checking) -- Predictable (same result every time) -- Self-contained (no network required) +- **WHEN** `openspec update` is executed without `--global` +- **THEN** the system SHALL only update project-local files +- **AND** SHALL NOT modify or scan globally-installed files +- **AND** all existing update behavior SHALL remain unchanged diff --git a/openspec/specs/command-generation/spec.md b/openspec/specs/command-generation/spec.md index ea598a75a..c49d853ec 100644 --- a/openspec/specs/command-generation/spec.md +++ b/openspec/specs/command-generation/spec.md @@ -1,23 +1,4 @@ -# command-generation Specification - -## Purpose -Define tool-agnostic command content and adapter contracts for generating tool-specific OpenSpec command files. - -## Requirements -### Requirement: CommandContent interface - -The system SHALL define a tool-agnostic `CommandContent` interface for command data. - -#### Scenario: CommandContent structure - -- **WHEN** defining a command to generate -- **THEN** `CommandContent` SHALL include: - - `id`: string identifier (e.g., 'explore', 'apply') - - `name`: human-readable name (e.g., 'OpenSpec Explore') - - `description`: brief description of command purpose - - `category`: grouping category (e.g., 'OpenSpec') - - `tags`: array of tag strings - - `body`: the command instruction content +## MODIFIED Requirements ### Requirement: ToolCommandAdapter interface @@ -28,70 +9,53 @@ The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting - **WHEN** implementing a tool adapter - **THEN** `ToolCommandAdapter` SHALL require: - `toolId`: string identifier matching `AIToolOption.value` - - `getFilePath(commandId: string)`: returns file path for command (relative from project root, or absolute for global-scoped tools like Codex) + - `getFilePath(commandId: string)`: returns file path for command (relative from project root) - `formatFile(content: CommandContent)`: returns complete file content with frontmatter +- **AND** `ToolCommandAdapter` SHALL optionally support: + - `getGlobalRoot()`: returns absolute path to the tool's global configuration directory, or `null` if the tool has no global filesystem path #### Scenario: Claude adapter formatting - **WHEN** formatting a command for Claude Code - **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields - **AND** file path SHALL follow pattern `.claude/commands/opsx/.md` +- **AND** `getGlobalRoot()` SHALL return `~/.claude/` (macOS/Linux) or `%APPDATA%\Claude\` (Windows) #### Scenario: Cursor adapter formatting - **WHEN** formatting a command for Cursor - **THEN** the adapter SHALL output YAML frontmatter with `name` as `/opsx-`, `id`, `category`, `description` fields - **AND** file path SHALL follow pattern `.cursor/commands/opsx-.md` +- **AND** `getGlobalRoot()` SHALL return `null` #### Scenario: Windsurf adapter formatting - **WHEN** formatting a command for Windsurf - **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields - **AND** file path SHALL follow pattern `.windsurf/workflows/opsx-.md` +- **AND** `getGlobalRoot()` SHALL return `null` -### Requirement: Command generator function - -The system SHALL provide a `generateCommand` function that combines content with adapter. - -#### Scenario: Generate command file - -- **WHEN** calling `generateCommand(content, adapter)` -- **THEN** it SHALL return an object with: - - `path`: the file path from `adapter.getFilePath(content.id)` - - `fileContent`: the formatted content from `adapter.formatFile(content)` - -#### Scenario: Generate multiple commands - -- **WHEN** generating all opsx commands for a tool -- **THEN** the system SHALL iterate over command contents and generate each using the tool's adapter - -### Requirement: CommandAdapterRegistry - -The system SHALL provide a registry for looking up tool adapters. - -#### Scenario: Get adapter by tool ID +## ADDED Requirements -- **WHEN** calling `CommandAdapterRegistry.get('cursor')` -- **THEN** it SHALL return the Cursor adapter or undefined if not registered +### Requirement: CommandAdapterRegistry global filtering -#### Scenario: Get all adapters +The registry SHALL support filtering adapters by global installation support. -- **WHEN** calling `CommandAdapterRegistry.getAll()` -- **THEN** it SHALL return array of all registered adapters +#### Scenario: Get adapters with global support -#### Scenario: Adapter not found +- **WHEN** calling `CommandAdapterRegistry.getGlobalAdapters()` +- **THEN** it SHALL return an array of adapters where `getGlobalRoot()` returns a non-null value -- **WHEN** looking up an adapter for unregistered tool -- **THEN** `CommandAdapterRegistry.get()` SHALL return undefined -- **AND** caller SHALL handle missing adapter appropriately +### Requirement: Codex adapter migration -### Requirement: Shared command body content +The Codex adapter SHALL separate project-local and global path concerns. -The body content of commands SHALL be shared across all tools. +#### Scenario: Codex project-local path -#### Scenario: Same instructions across tools +- **WHEN** calling `getFilePath()` on the Codex adapter +- **THEN** it SHALL return a project-relative path: `.codex/prompts/opsx-.md` -- **WHEN** generating the 'explore' command for Claude and Cursor -- **THEN** both SHALL use the same `body` content -- **AND** only the frontmatter and file path SHALL differ +#### Scenario: Codex global path +- **WHEN** calling `getGlobalRoot()` on the Codex adapter +- **THEN** it SHALL return the absolute path to the Codex home directory (respecting `$CODEX_HOME`, defaulting to `~/.codex/`) diff --git a/openspec/specs/global-install/spec.md b/openspec/specs/global-install/spec.md new file mode 100644 index 000000000..4f7139dd8 --- /dev/null +++ b/openspec/specs/global-install/spec.md @@ -0,0 +1,80 @@ +## ADDED Requirements + +### Requirement: Global root resolution + +The system SHALL resolve a tool's global installation root directory via the adapter interface. + +#### Scenario: Adapter with known global root + +- **WHEN** calling `getGlobalRoot()` on a tool adapter that supports global installation (e.g., Claude Code, OpenCode, Codex) +- **THEN** it SHALL return an absolute path to the tool's global configuration directory +- **AND** the path SHALL follow the tool's own documented convention per platform + +#### Scenario: Adapter without global support + +- **WHEN** calling `getGlobalRoot()` on a tool adapter that has no global filesystem path (e.g., Cursor, Windsurf) +- **THEN** it SHALL return `null` + +#### Scenario: OPENSPEC_GLOBAL_ROOT override + +- **WHEN** the `OPENSPEC_GLOBAL_ROOT` environment variable is set +- **THEN** the system SHALL use the env var value as the base path for all global installs, replacing the adapter's native global root +- **AND** the per-tool subdirectory structure SHALL be preserved under the override path + +### Requirement: Global path derivation for commands + +The system SHALL derive global command file paths from the adapter's global root. + +#### Scenario: Deriving global command path + +- **WHEN** a tool adapter returns a non-null global root +- **AND** the system needs the global path for a command +- **THEN** the system SHALL construct the absolute path by combining the global root with the tool's command path pattern (e.g., `/commands/opsx/.md` for Claude Code) + +### Requirement: Global path derivation for skills + +The system SHALL derive global skill file paths from the adapter's global root. + +#### Scenario: Deriving global skill path + +- **WHEN** a tool adapter returns a non-null global root +- **AND** the system needs the global path for a skill +- **THEN** the system SHALL construct the absolute path by combining the global root with the tool's skill path pattern (e.g., `/skills/openspec-/SKILL.md` for Claude Code) + +### Requirement: Global tool path reference + +The system SHALL use the following global root paths per tool and platform. + +#### Scenario: Claude Code global root + +- **WHEN** resolving the global root for Claude Code +- **THEN** on macOS and Linux it SHALL be `~/.claude/` +- **AND** on Windows it SHALL be `%APPDATA%\Claude\` + +#### Scenario: OpenCode global root + +- **WHEN** resolving the global root for OpenCode +- **THEN** it SHALL respect `$XDG_CONFIG_HOME` if set, defaulting to `~/.config/opencode/` +- **AND** on Windows it SHALL be `%APPDATA%\opencode\` + +#### Scenario: Codex global root + +- **WHEN** resolving the global root for Codex +- **THEN** it SHALL respect `$CODEX_HOME` if set, defaulting to `~/.codex/` +- **AND** on Windows it SHALL be `%USERPROFILE%\.codex\` + +### Requirement: Global and local coexistence + +Global and project-local installations SHALL coexist without conflict. + +#### Scenario: Both global and local installed + +- **WHEN** a tool has both global and project-local OpenSpec files +- **THEN** the project-local files SHALL take precedence per each tool's own resolution order +- **AND** global files serve as a fallback for projects without local installation + +#### Scenario: Update scoping + +- **WHEN** `openspec update` is run without `--global` +- **THEN** the system SHALL only update project-local files +- **AND** SHALL NOT modify globally-installed files diff --git a/src/cli/index.ts b/src/cli/index.ts index 8947736f7..3c5f94dad 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -96,34 +96,46 @@ program .option('--tools ', toolsOptionDescription) .option('--force', 'Auto-cleanup legacy files without prompting') .option('--profile ', 'Override global config profile (core or custom)') - .action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string }) => { + .option('--global', 'Install skills and commands to tool global directories (requires --tools)') + .action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string; global?: boolean }) => { try { - // Validate that the path is a valid directory - const resolvedPath = path.resolve(targetPath); - - try { - const stats = await fs.stat(resolvedPath); - if (!stats.isDirectory()) { - throw new Error(`Path "${targetPath}" is not a directory`); - } - } catch (error: any) { - if (error.code === 'ENOENT') { - // Directory doesn't exist, but we can create it - console.log(`Directory "${targetPath}" doesn't exist, it will be created.`); - } else if (error.message && error.message.includes('not a directory')) { - throw error; - } else { - throw new Error(`Cannot access path "${targetPath}": ${error.message}`); + if (options?.global) { + const { InitCommand } = await import('../core/init.js'); + const initCommand = new InitCommand({ + tools: options?.tools, + force: options?.force, + profile: options?.profile, + global: true, + }); + await initCommand.executeGlobal(); + } else { + // Validate that the path is a valid directory + const resolvedPath = path.resolve(targetPath); + + try { + const stats = await fs.stat(resolvedPath); + if (!stats.isDirectory()) { + throw new Error(`Path "${targetPath}" is not a directory`); + } + } catch (error: any) { + if (error.code === 'ENOENT') { + // Directory doesn't exist, but we can create it + console.log(`Directory "${targetPath}" doesn't exist, it will be created.`); + } else if (error.message && error.message.includes('not a directory')) { + throw error; + } else { + throw new Error(`Cannot access path "${targetPath}": ${error.message}`); + } } - } - const { InitCommand } = await import('../core/init.js'); - const initCommand = new InitCommand({ - tools: options?.tools, - force: options?.force, - profile: options?.profile, - }); - await initCommand.execute(targetPath); + const { InitCommand } = await import('../core/init.js'); + const initCommand = new InitCommand({ + tools: options?.tools, + force: options?.force, + profile: options?.profile, + }); + await initCommand.execute(targetPath); + } } catch (error) { console.log(); // Empty line for spacing ora().fail(`Error: ${(error as Error).message}`); @@ -157,11 +169,17 @@ program .command('update [path]') .description('Update OpenSpec instruction files') .option('--force', 'Force update even when tools are up to date') - .action(async (targetPath = '.', options?: { force?: boolean }) => { + .option('--global', 'Update globally-installed skills and commands') + .action(async (targetPath = '.', options?: { force?: boolean; global?: boolean }) => { try { - const resolvedPath = path.resolve(targetPath); - const updateCommand = new UpdateCommand({ force: options?.force }); - await updateCommand.execute(resolvedPath); + if (options?.global) { + const updateCommand = new UpdateCommand({ force: options?.force, global: true }); + await updateCommand.executeGlobal(); + } else { + const resolvedPath = path.resolve(targetPath); + const updateCommand = new UpdateCommand({ force: options?.force }); + await updateCommand.execute(resolvedPath); + } } catch (error) { console.log(); // Empty line for spacing ora().fail(`Error: ${(error as Error).message}`); diff --git a/src/core/command-generation/adapters/amazon-q.ts b/src/core/command-generation/adapters/amazon-q.ts index 0131c0638..9eba53d4b 100644 --- a/src/core/command-generation/adapters/amazon-q.ts +++ b/src/core/command-generation/adapters/amazon-q.ts @@ -27,4 +27,8 @@ description: ${content.description} ${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/antigravity.ts b/src/core/command-generation/adapters/antigravity.ts index e7a5d4919..266952945 100644 --- a/src/core/command-generation/adapters/antigravity.ts +++ b/src/core/command-generation/adapters/antigravity.ts @@ -27,4 +27,8 @@ description: ${content.description} ${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/auggie.ts b/src/core/command-generation/adapters/auggie.ts index 2a52104c0..0308a9e91 100644 --- a/src/core/command-generation/adapters/auggie.ts +++ b/src/core/command-generation/adapters/auggie.ts @@ -28,4 +28,8 @@ argument-hint: command arguments ${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/claude.ts b/src/core/command-generation/adapters/claude.ts index 532b3a47b..42c102534 100644 --- a/src/core/command-generation/adapters/claude.ts +++ b/src/core/command-generation/adapters/claude.ts @@ -4,6 +4,7 @@ * Formats commands for Claude Code following its frontmatter specification. */ +import os from 'os'; import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; @@ -42,6 +43,13 @@ export const claudeAdapter: ToolCommandAdapter = { return path.join('.claude', 'commands', 'opsx', `${commandId}.md`); }, + getGlobalRoot(): string { + if (process.platform === 'win32') { + return path.join(process.env.APPDATA || os.homedir(), 'Claude'); + } + return path.join(os.homedir(), '.claude'); + }, + formatFile(content: CommandContent): string { return `--- name: ${escapeYamlValue(content.name)} diff --git a/src/core/command-generation/adapters/cline.ts b/src/core/command-generation/adapters/cline.ts index abc643164..1579a8d09 100644 --- a/src/core/command-generation/adapters/cline.ts +++ b/src/core/command-generation/adapters/cline.ts @@ -28,4 +28,8 @@ ${content.description} ${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/codebuddy.ts b/src/core/command-generation/adapters/codebuddy.ts index 54b7eebdc..2a117c24b 100644 --- a/src/core/command-generation/adapters/codebuddy.ts +++ b/src/core/command-generation/adapters/codebuddy.ts @@ -29,4 +29,8 @@ argument-hint: "[command arguments]" ${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/codex.ts b/src/core/command-generation/adapters/codex.ts index 64e73550b..06665647f 100644 --- a/src/core/command-generation/adapters/codex.ts +++ b/src/core/command-generation/adapters/codex.ts @@ -29,7 +29,11 @@ export const codexAdapter: ToolCommandAdapter = { toolId: 'codex', getFilePath(commandId: string): string { - return path.join(getCodexHome(), 'prompts', `opsx-${commandId}.md`); + return path.join('.codex', 'prompts', `opsx-${commandId}.md`); + }, + + getGlobalRoot(): string { + return getCodexHome(); }, formatFile(content: CommandContent): string { diff --git a/src/core/command-generation/adapters/continue.ts b/src/core/command-generation/adapters/continue.ts index f6aac08b0..493c9f35c 100644 --- a/src/core/command-generation/adapters/continue.ts +++ b/src/core/command-generation/adapters/continue.ts @@ -29,4 +29,8 @@ invokable: true ${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/costrict.ts b/src/core/command-generation/adapters/costrict.ts index 17628a124..29181928f 100644 --- a/src/core/command-generation/adapters/costrict.ts +++ b/src/core/command-generation/adapters/costrict.ts @@ -28,4 +28,8 @@ argument-hint: command arguments ${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/crush.ts b/src/core/command-generation/adapters/crush.ts index b4d1a0b9d..9077c80cf 100644 --- a/src/core/command-generation/adapters/crush.ts +++ b/src/core/command-generation/adapters/crush.ts @@ -31,4 +31,8 @@ tags: [${tagsStr}] ${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/cursor.ts b/src/core/command-generation/adapters/cursor.ts index 85adedb03..8e84f3d77 100644 --- a/src/core/command-generation/adapters/cursor.ts +++ b/src/core/command-generation/adapters/cursor.ts @@ -46,4 +46,8 @@ description: ${escapeYamlValue(content.description)} ${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/factory.ts b/src/core/command-generation/adapters/factory.ts index 5031d5dc7..1d485a4c2 100644 --- a/src/core/command-generation/adapters/factory.ts +++ b/src/core/command-generation/adapters/factory.ts @@ -28,4 +28,8 @@ argument-hint: command arguments ${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/gemini.ts b/src/core/command-generation/adapters/gemini.ts index 2c08656f4..8ba19aba3 100644 --- a/src/core/command-generation/adapters/gemini.ts +++ b/src/core/command-generation/adapters/gemini.ts @@ -27,4 +27,8 @@ ${content.body} """ `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/github-copilot.ts b/src/core/command-generation/adapters/github-copilot.ts index 4eac7f1b6..f2e41c930 100644 --- a/src/core/command-generation/adapters/github-copilot.ts +++ b/src/core/command-generation/adapters/github-copilot.ts @@ -27,4 +27,8 @@ description: ${content.description} ${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/iflow.ts b/src/core/command-generation/adapters/iflow.ts index d60a3f0b1..d4ae350f3 100644 --- a/src/core/command-generation/adapters/iflow.ts +++ b/src/core/command-generation/adapters/iflow.ts @@ -30,4 +30,8 @@ description: ${content.description} ${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/kilocode.ts b/src/core/command-generation/adapters/kilocode.ts index bb60c4dd7..c8175c5d3 100644 --- a/src/core/command-generation/adapters/kilocode.ts +++ b/src/core/command-generation/adapters/kilocode.ts @@ -24,4 +24,8 @@ export const kilocodeAdapter: ToolCommandAdapter = { return `${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/kiro.ts b/src/core/command-generation/adapters/kiro.ts index 2e8a4ca4c..9fa55761a 100644 --- a/src/core/command-generation/adapters/kiro.ts +++ b/src/core/command-generation/adapters/kiro.ts @@ -27,4 +27,8 @@ description: ${content.description} ${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/opencode.ts b/src/core/command-generation/adapters/opencode.ts index 2b078fc6c..89bb5a19d 100644 --- a/src/core/command-generation/adapters/opencode.ts +++ b/src/core/command-generation/adapters/opencode.ts @@ -4,6 +4,7 @@ * Formats commands for OpenCode following its frontmatter specification. */ +import os from 'os'; import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; import { transformToHyphenCommands } from '../../../utils/command-references.js'; @@ -20,6 +21,16 @@ export const opencodeAdapter: ToolCommandAdapter = { return path.join('.opencode', 'command', `opsx-${commandId}.md`); }, + getGlobalRoot(): string { + if (process.platform === 'win32') { + return path.join(process.env.APPDATA || os.homedir(), 'opencode'); + } + const xdgConfig = process.env.XDG_CONFIG_HOME?.trim(); + return xdgConfig + ? path.join(xdgConfig, 'opencode') + : path.join(os.homedir(), '.config', 'opencode'); + }, + formatFile(content: CommandContent): string { // Transform command references from colon to hyphen format for OpenCode const transformedBody = transformToHyphenCommands(content.body); diff --git a/src/core/command-generation/adapters/pi.ts b/src/core/command-generation/adapters/pi.ts index cb8d2b331..e0c2689ad 100644 --- a/src/core/command-generation/adapters/pi.ts +++ b/src/core/command-generation/adapters/pi.ts @@ -43,4 +43,8 @@ description: ${escapeYamlValue(content.description)} ${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/qoder.ts b/src/core/command-generation/adapters/qoder.ts index 608fc9ae2..fed2ab458 100644 --- a/src/core/command-generation/adapters/qoder.ts +++ b/src/core/command-generation/adapters/qoder.ts @@ -31,4 +31,8 @@ tags: [${tagsStr}] ${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/qwen.ts b/src/core/command-generation/adapters/qwen.ts index 0ee640b3c..5e1ec0f90 100644 --- a/src/core/command-generation/adapters/qwen.ts +++ b/src/core/command-generation/adapters/qwen.ts @@ -27,4 +27,8 @@ ${content.body} """ `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/roocode.ts b/src/core/command-generation/adapters/roocode.ts index 529298578..51bf3a933 100644 --- a/src/core/command-generation/adapters/roocode.ts +++ b/src/core/command-generation/adapters/roocode.ts @@ -28,4 +28,8 @@ ${content.description} ${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/adapters/windsurf.ts b/src/core/command-generation/adapters/windsurf.ts index 59c86d8e0..9e14a4c8c 100644 --- a/src/core/command-generation/adapters/windsurf.ts +++ b/src/core/command-generation/adapters/windsurf.ts @@ -54,4 +54,8 @@ tags: ${formatTagsArray(content.tags)} ${content.body} `; }, + + getGlobalRoot(): null { + return null; + }, }; diff --git a/src/core/command-generation/global-root.ts b/src/core/command-generation/global-root.ts new file mode 100644 index 000000000..0529f6ab6 --- /dev/null +++ b/src/core/command-generation/global-root.ts @@ -0,0 +1,25 @@ +/** + * Global Root Resolution + * + * Resolves the global installation root for a tool adapter, + * respecting the OPENSPEC_GLOBAL_ROOT env var override. + */ + +import path from 'path'; +import type { ToolCommandAdapter } from './types.js'; + +/** + * Resolves the global root path for a tool adapter. + * If OPENSPEC_GLOBAL_ROOT is set, uses that as the base with the toolId as subdirectory. + * Otherwise, falls back to the adapter's own getGlobalRoot(). + * + * @param adapter - The tool command adapter + * @returns Absolute path to the global root, or null if the tool has no global support + */ +export function resolveGlobalRoot(adapter: ToolCommandAdapter): string | null { + const envOverride = process.env.OPENSPEC_GLOBAL_ROOT?.trim(); + if (envOverride) { + return path.resolve(envOverride, adapter.toolId); + } + return adapter.getGlobalRoot?.() ?? null; +} diff --git a/src/core/command-generation/index.ts b/src/core/command-generation/index.ts index a067f33b2..fddc69ff0 100644 --- a/src/core/command-generation/index.ts +++ b/src/core/command-generation/index.ts @@ -29,5 +29,8 @@ export { CommandAdapterRegistry } from './registry.js'; // Generator functions export { generateCommand, generateCommands } from './generator.js'; +// Global root resolution +export { resolveGlobalRoot } from './global-root.js'; + // Adapters (for direct access if needed) export { claudeAdapter, cursorAdapter, windsurfAdapter } from './adapters/index.js'; diff --git a/src/core/command-generation/registry.ts b/src/core/command-generation/registry.ts index a69a98adc..9d6c98e2a 100644 --- a/src/core/command-generation/registry.ts +++ b/src/core/command-generation/registry.ts @@ -96,4 +96,14 @@ export class CommandAdapterRegistry { static has(toolId: string): boolean { return CommandAdapterRegistry.adapters.has(toolId); } + + /** + * Get all adapters that support global installation. + * @returns Array of adapters where getGlobalRoot() returns a non-null value + */ + static getGlobalAdapters(): ToolCommandAdapter[] { + return Array.from(CommandAdapterRegistry.adapters.values()).filter( + (adapter) => adapter.getGlobalRoot?.() != null + ); + } } diff --git a/src/core/command-generation/types.ts b/src/core/command-generation/types.ts index 582d8c784..5f64508b6 100644 --- a/src/core/command-generation/types.ts +++ b/src/core/command-generation/types.ts @@ -33,10 +33,9 @@ export interface ToolCommandAdapter { /** Tool identifier matching AIToolOption.value (e.g., 'claude', 'cursor') */ toolId: string; /** - * Returns the file path for a command. + * Returns the file path for a command, relative to the project root. * @param commandId - The command identifier (e.g., 'explore') * @returns Path from project root (e.g., '.claude/commands/opsx/explore.md'). - * May be absolute for tools with global-scoped prompts (e.g., Codex). */ getFilePath(commandId: string): string; /** @@ -45,13 +44,19 @@ export interface ToolCommandAdapter { * @returns Complete file content ready to write */ formatFile(content: CommandContent): string; + /** + * Returns the tool's global configuration root directory. + * @returns Absolute path to the global root (e.g., '~/.claude/'), or null + * if the tool has no global filesystem path. + */ + getGlobalRoot?(): string | null; } /** * Result of generating a command file. */ export interface GeneratedCommand { - /** File path from project root, or absolute for global-scoped tools */ + /** File path from project root, or absolute for global installs */ path: string; /** Complete file content (frontmatter + body) */ fileContent: string; diff --git a/src/core/init.ts b/src/core/init.ts index cf72a5b6f..f17ae06c3 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -23,6 +23,7 @@ import { serializeConfig } from './config-prompts.js'; import { generateCommands, CommandAdapterRegistry, + resolveGlobalRoot, } from './command-generation/index.js'; import { detectLegacyArtifacts, @@ -83,6 +84,7 @@ type InitCommandOptions = { force?: boolean; interactive?: boolean; profile?: string; + global?: boolean; }; // ----------------------------------------------------------------------------- @@ -94,12 +96,14 @@ export class InitCommand { private readonly force: boolean; private readonly interactiveOption?: boolean; private readonly profileOverride?: string; + private readonly globalMode: boolean; constructor(options: InitCommandOptions = {}) { this.toolsArg = options.tools; this.force = options.force ?? false; this.interactiveOption = options.interactive; this.profileOverride = options.profile; + this.globalMode = options.global ?? false; } async execute(targetPath: string): Promise { @@ -154,6 +158,137 @@ export class InitCommand { this.displaySuccessMessage(projectPath, validatedTools, results, configStatus); } + // ═══════════════════════════════════════════════════════════ + // GLOBAL MODE + // ═══════════════════════════════════════════════════════════ + + async executeGlobal(): Promise { + // Require --tools with --global + if (typeof this.toolsArg === 'undefined') { + throw new Error( + '--tools is required with --global. Use --tools all to install for all tools with a known global path.' + ); + } + + // Resolve profile settings + const globalConfig = getGlobalConfig(); + const profile: Profile = this.resolveProfileOverride() ?? globalConfig.profile ?? 'core'; + const delivery: Delivery = globalConfig.delivery ?? 'both'; + const workflows = getProfileWorkflows(profile, globalConfig.workflows); + + // Parse --tools argument + const requestedToolIds = this.resolveToolsArgForGlobal(); + + // Resolve adapters and validate global support + const toolResults: Array<{ toolId: string; globalRoot: string; name: string }> = []; + const skippedTools: string[] = []; + + for (const toolId of requestedToolIds) { + const adapter = CommandAdapterRegistry.get(toolId); + if (!adapter) { + skippedTools.push(toolId); + continue; + } + + const globalRoot = resolveGlobalRoot(adapter); + if (!globalRoot) { + skippedTools.push(toolId); + continue; + } + + const tool = AI_TOOLS.find((t) => t.value === toolId); + toolResults.push({ toolId, globalRoot, name: tool?.name || toolId }); + } + + // If all requested tools were skipped (no global support), error out + if (toolResults.length === 0) { + const msg = skippedTools.length > 0 + ? `No tools with global support found. Skipped: ${skippedTools.join(', ')}` + : 'No valid tools specified.'; + throw new Error(msg); + } + + // Get skill and command templates filtered by profile + const shouldGenerateSkills = delivery !== 'commands'; + const shouldGenerateCommands = delivery !== 'skills'; + const skillTemplates = shouldGenerateSkills ? getSkillTemplates(workflows) : []; + const commandContents = shouldGenerateCommands ? getCommandContents(workflows) : []; + + let totalFiles = 0; + + for (const { toolId, globalRoot, name } of toolResults) { + const spinner = ora(`Installing globally for ${name}...`).start(); + + try { + // Generate skills to global path + if (shouldGenerateSkills) { + const skillsDir = path.join(globalRoot, 'skills'); + for (const { template, dirName } of skillTemplates) { + const skillDir = path.join(skillsDir, dirName); + const skillFile = path.join(skillDir, 'SKILL.md'); + + const transformer = toolId === 'opencode' ? transformToHyphenCommands : undefined; + const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); + await FileSystemUtils.writeFile(skillFile, skillContent); + totalFiles++; + } + } + + // Generate commands to global path + if (shouldGenerateCommands) { + const adapter = CommandAdapterRegistry.get(toolId); + if (adapter) { + const generatedCommands = generateCommands(commandContents, adapter); + for (const cmd of generatedCommands) { + // Derive global command path from global root + relative path pattern + const commandFile = path.join(globalRoot, cmd.path.replace(/^\.?[^/\\]+[/\\]/, '')); + await FileSystemUtils.writeFile(commandFile, cmd.fileContent); + totalFiles++; + } + } + } + + spinner.succeed(`Installed globally for ${name} → ${globalRoot}`); + } catch (error) { + spinner.fail(`Failed for ${name}: ${(error as Error).message}`); + } + } + + // Output + console.log(); + console.log(chalk.bold('Global Installation Complete')); + console.log(); + console.log(`Installed: ${toolResults.map((t) => t.name).join(', ')}`); + console.log(`${totalFiles} file(s) written.`); + + if (skippedTools.length > 0) { + console.log(chalk.dim(`Skipped (no global support): ${skippedTools.join(', ')}`)); + } + } + + private resolveToolsArgForGlobal(): string[] { + const raw = (this.toolsArg ?? '').trim(); + + if (raw.toLowerCase() === 'all') { + // Return only tools that have global support + return CommandAdapterRegistry.getGlobalAdapters().map((a) => a.toolId); + } + + // Parse comma-separated tool IDs (reuse validation logic) + const tokens = raw + .split(',') + .map((token) => token.trim().toLowerCase()) + .filter((token) => token.length > 0); + + if (tokens.length === 0) { + throw new Error( + '--tools requires at least one tool ID. Use --tools all for all tools with global support.' + ); + } + + return tokens; + } + // ═══════════════════════════════════════════════════════════ // VALIDATION & SETUP // ═══════════════════════════════════════════════════════════ diff --git a/src/core/update.ts b/src/core/update.ts index 62db8a08f..57c828253 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -16,6 +16,7 @@ import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js'; import { generateCommands, CommandAdapterRegistry, + resolveGlobalRoot, } from './command-generation/index.js'; import { getToolVersionStatus, @@ -57,6 +58,8 @@ const { version: OPENSPEC_VERSION } = require('../../package.json'); export interface UpdateCommandOptions { /** Force update even when tools are up to date */ force?: boolean; + /** Update globally-installed files instead of project-local */ + global?: boolean; } /** @@ -74,9 +77,11 @@ export function scanInstalledWorkflows(projectPath: string, toolIds: string[]): export class UpdateCommand { private readonly force: boolean; + private readonly globalMode: boolean; constructor(options: UpdateCommandOptions = {}) { this.force = options.force ?? false; + this.globalMode = options.global ?? false; } async execute(projectPath: string): Promise { @@ -699,4 +704,89 @@ export class UpdateCommand { return newlyConfigured; } + + // ═══════════════════════════════════════════════════════════ + // GLOBAL UPDATE + // ═══════════════════════════════════════════════════════════ + + async executeGlobal(): Promise { + const globalConfig = getGlobalConfig(); + const profile = globalConfig.profile ?? 'core'; + const delivery: Delivery = globalConfig.delivery ?? 'both'; + const workflows = getProfileWorkflows(profile, globalConfig.workflows); + + const shouldGenerateSkills = delivery !== 'commands'; + const shouldGenerateCommands = delivery !== 'skills'; + const skillTemplates = shouldGenerateSkills ? getSkillTemplates(workflows) : []; + const commandContents = shouldGenerateCommands ? getCommandContents(workflows) : []; + + // Find adapters with global support that have files installed + const globalAdapters = CommandAdapterRegistry.getGlobalAdapters(); + let updatedCount = 0; + let toolsUpdated = 0; + + for (const adapter of globalAdapters) { + const globalRoot = resolveGlobalRoot(adapter); + if (!globalRoot) continue; + + // Check if any OpenSpec files exist in this global root + const skillsDir = path.join(globalRoot, 'skills'); + const hasSkills = fs.existsSync(skillsDir) && + fs.readdirSync(skillsDir).some((d) => d.startsWith('openspec-')); + + // Check for commands by looking at the adapter's command path pattern + let hasCommands = false; + if (shouldGenerateCommands) { + const testPath = adapter.getFilePath('explore'); + const globalCmdPath = path.join(globalRoot, testPath.replace(/^\.?[^/\\]+[/\\]/, '')); + hasCommands = fs.existsSync(globalCmdPath); + } + + if (!hasSkills && !hasCommands) continue; + + const tool = AI_TOOLS.find((t) => t.value === adapter.toolId); + const toolName = tool?.name || adapter.toolId; + const spinner = ora(`Updating global files for ${toolName}...`).start(); + + try { + // Regenerate skills + if (shouldGenerateSkills && hasSkills) { + const skillsDirPath = path.join(globalRoot, 'skills'); + for (const { template, dirName } of skillTemplates) { + const skillDir = path.join(skillsDirPath, dirName); + const skillFile = path.join(skillDir, 'SKILL.md'); + + const transformer = adapter.toolId === 'opencode' ? transformToHyphenCommands : undefined; + const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); + await FileSystemUtils.writeFile(skillFile, skillContent); + updatedCount++; + } + } + + // Regenerate commands + if (shouldGenerateCommands && hasCommands) { + const generatedCommands = generateCommands(commandContents, adapter); + for (const cmd of generatedCommands) { + const commandFile = path.join(globalRoot, cmd.path.replace(/^\.?[^/\\]+[/\\]/, '')); + await FileSystemUtils.writeFile(commandFile, cmd.fileContent); + updatedCount++; + } + } + + spinner.succeed(`Updated global files for ${toolName} → ${globalRoot}`); + toolsUpdated++; + } catch (error) { + spinner.fail(`Failed to update ${toolName}: ${(error as Error).message}`); + } + } + + console.log(); + if (toolsUpdated === 0) { + console.log('No globally-installed OpenSpec files found.'); + console.log(chalk.dim('Install globally first: openspec init --global --tools ')); + } else { + console.log(chalk.bold('Global Update Complete')); + console.log(`Updated ${updatedCount} file(s) across ${toolsUpdated} tool(s).`); + } + } } diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index 5e4444ddb..5f0ccb3d2 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -207,23 +207,20 @@ describe('command-generation/adapters', () => { expect(codexAdapter.toolId).toBe('codex'); }); - it('should return an absolute path', () => { + it('should return a project-relative path from getFilePath', () => { const filePath = codexAdapter.getFilePath('explore'); - expect(path.isAbsolute(filePath)).toBe(true); + expect(path.isAbsolute(filePath)).toBe(false); + expect(filePath).toBe(path.join('.codex', 'prompts', 'opsx-explore.md')); }); - it('should generate path ending with correct structure', () => { - const filePath = codexAdapter.getFilePath('explore'); - expect(filePath).toMatch(/prompts[/\\]opsx-explore\.md$/); - }); - - it('should default to homedir/.codex', () => { + it('should return absolute global root from getGlobalRoot', () => { const original = process.env.CODEX_HOME; delete process.env.CODEX_HOME; try { - const filePath = codexAdapter.getFilePath('explore'); - const expected = path.join(os.homedir(), '.codex', 'prompts', 'opsx-explore.md'); - expect(filePath).toBe(expected); + const globalRoot = codexAdapter.getGlobalRoot!(); + expect(globalRoot).not.toBeNull(); + expect(path.isAbsolute(globalRoot!)).toBe(true); + expect(globalRoot).toBe(path.join(os.homedir(), '.codex')); } finally { if (original !== undefined) { process.env.CODEX_HOME = original; @@ -231,12 +228,12 @@ describe('command-generation/adapters', () => { } }); - it('should respect CODEX_HOME env var', () => { + it('should respect CODEX_HOME env var in getGlobalRoot', () => { const original = process.env.CODEX_HOME; process.env.CODEX_HOME = '/custom/codex-home'; try { - const filePath = codexAdapter.getFilePath('explore'); - expect(filePath).toBe(path.join(path.resolve('/custom/codex-home'), 'prompts', 'opsx-explore.md')); + const globalRoot = codexAdapter.getGlobalRoot!(); + expect(globalRoot).toBe(path.resolve('/custom/codex-home')); } finally { if (original !== undefined) { process.env.CODEX_HOME = original; diff --git a/test/core/command-generation/global-install.test.ts b/test/core/command-generation/global-install.test.ts new file mode 100644 index 000000000..e76c33d0b --- /dev/null +++ b/test/core/command-generation/global-install.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import os from 'os'; +import path from 'path'; +import { claudeAdapter } from '../../../src/core/command-generation/adapters/claude.js'; +import { opencodeAdapter } from '../../../src/core/command-generation/adapters/opencode.js'; +import { codexAdapter } from '../../../src/core/command-generation/adapters/codex.js'; +import { cursorAdapter } from '../../../src/core/command-generation/adapters/cursor.js'; +import { windsurfAdapter } from '../../../src/core/command-generation/adapters/windsurf.js'; +import { CommandAdapterRegistry } from '../../../src/core/command-generation/registry.js'; +import { resolveGlobalRoot } from '../../../src/core/command-generation/global-root.js'; + +describe('getGlobalRoot()', () => { + describe('claudeAdapter', () => { + it('should return ~/.claude/ on macOS/Linux', () => { + if (process.platform !== 'win32') { + const root = claudeAdapter.getGlobalRoot!(); + expect(root).toBe(path.join(os.homedir(), '.claude')); + } + }); + + it('should return an absolute path', () => { + const root = claudeAdapter.getGlobalRoot!(); + expect(root).not.toBeNull(); + expect(path.isAbsolute(root!)).toBe(true); + }); + }); + + describe('opencodeAdapter', () => { + let originalXdg: string | undefined; + + beforeEach(() => { + originalXdg = process.env.XDG_CONFIG_HOME; + }); + + afterEach(() => { + if (originalXdg !== undefined) { + process.env.XDG_CONFIG_HOME = originalXdg; + } else { + delete process.env.XDG_CONFIG_HOME; + } + }); + + it('should return ~/.config/opencode/ by default on macOS/Linux', () => { + delete process.env.XDG_CONFIG_HOME; + if (process.platform !== 'win32') { + const root = opencodeAdapter.getGlobalRoot!(); + expect(root).toBe(path.join(os.homedir(), '.config', 'opencode')); + } + }); + + it('should respect XDG_CONFIG_HOME', () => { + process.env.XDG_CONFIG_HOME = '/custom/config'; + if (process.platform !== 'win32') { + const root = opencodeAdapter.getGlobalRoot!(); + expect(root).toBe(path.join('/custom/config', 'opencode')); + } + }); + + it('should return an absolute path', () => { + const root = opencodeAdapter.getGlobalRoot!(); + expect(root).not.toBeNull(); + expect(path.isAbsolute(root!)).toBe(true); + }); + }); + + describe('codexAdapter', () => { + let originalCodexHome: string | undefined; + + beforeEach(() => { + originalCodexHome = process.env.CODEX_HOME; + }); + + afterEach(() => { + if (originalCodexHome !== undefined) { + process.env.CODEX_HOME = originalCodexHome; + } else { + delete process.env.CODEX_HOME; + } + }); + + it('should return ~/.codex/ by default', () => { + delete process.env.CODEX_HOME; + const root = codexAdapter.getGlobalRoot!(); + expect(root).toBe(path.join(os.homedir(), '.codex')); + }); + + it('should respect CODEX_HOME env var', () => { + process.env.CODEX_HOME = '/custom/codex'; + const root = codexAdapter.getGlobalRoot!(); + expect(root).toBe(path.resolve('/custom/codex')); + }); + }); + + describe('adapters without global support', () => { + it('cursorAdapter should return null', () => { + expect(cursorAdapter.getGlobalRoot!()).toBeNull(); + }); + + it('windsurfAdapter should return null', () => { + expect(windsurfAdapter.getGlobalRoot!()).toBeNull(); + }); + }); +}); + +describe('resolveGlobalRoot()', () => { + let originalEnv: string | undefined; + + beforeEach(() => { + originalEnv = process.env.OPENSPEC_GLOBAL_ROOT; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.OPENSPEC_GLOBAL_ROOT = originalEnv; + } else { + delete process.env.OPENSPEC_GLOBAL_ROOT; + } + }); + + it('should return adapter global root when env var is not set', () => { + delete process.env.OPENSPEC_GLOBAL_ROOT; + const root = resolveGlobalRoot(claudeAdapter); + expect(root).toBe(claudeAdapter.getGlobalRoot!()); + }); + + it('should return null for adapters without global support when env var is not set', () => { + delete process.env.OPENSPEC_GLOBAL_ROOT; + const root = resolveGlobalRoot(cursorAdapter); + expect(root).toBeNull(); + }); + + it('should override with OPENSPEC_GLOBAL_ROOT when set', () => { + process.env.OPENSPEC_GLOBAL_ROOT = '/override/path'; + const root = resolveGlobalRoot(claudeAdapter); + expect(root).toBe(path.resolve('/override/path', 'claude')); + }); + + it('should use env var even for adapters without native global support', () => { + process.env.OPENSPEC_GLOBAL_ROOT = '/override/path'; + const root = resolveGlobalRoot(cursorAdapter); + expect(root).toBe(path.resolve('/override/path', 'cursor')); + }); +}); + +describe('CommandAdapterRegistry.getGlobalAdapters()', () => { + it('should return only adapters with global support', () => { + const globalAdapters = CommandAdapterRegistry.getGlobalAdapters(); + expect(globalAdapters.length).toBeGreaterThanOrEqual(3); // claude, opencode, codex + + const toolIds = globalAdapters.map((a) => a.toolId); + expect(toolIds).toContain('claude'); + expect(toolIds).toContain('opencode'); + expect(toolIds).toContain('codex'); + }); + + it('should not include adapters without global support', () => { + const globalAdapters = CommandAdapterRegistry.getGlobalAdapters(); + const toolIds = globalAdapters.map((a) => a.toolId); + expect(toolIds).not.toContain('cursor'); + expect(toolIds).not.toContain('windsurf'); + }); + + it('should return fewer adapters than getAll()', () => { + const all = CommandAdapterRegistry.getAll(); + const global = CommandAdapterRegistry.getGlobalAdapters(); + expect(global.length).toBeLessThan(all.length); + }); +}); diff --git a/test/core/global-init.test.ts b/test/core/global-init.test.ts new file mode 100644 index 000000000..3e6941d4e --- /dev/null +++ b/test/core/global-init.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { InitCommand } from '../../src/core/init.js'; +import { UpdateCommand } from '../../src/core/update.js'; +import { claudeAdapter } from '../../src/core/command-generation/adapters/claude.js'; + +vi.mock('../../src/ui/welcome-screen.js', () => ({ + showWelcomeScreen: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../src/prompts/searchable-multi-select.js', () => ({ + searchableMultiSelect: vi.fn(), +})); + +describe('Global Init', () => { + let globalRoot: string; + let configTempDir: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(async () => { + globalRoot = path.join(os.tmpdir(), `openspec-global-test-${Date.now()}`); + await fs.mkdir(globalRoot, { recursive: true }); + configTempDir = path.join(os.tmpdir(), `openspec-config-global-${Date.now()}`); + await fs.mkdir(configTempDir, { recursive: true }); + originalEnv = { ...process.env }; + process.env.OPENSPEC_GLOBAL_ROOT = globalRoot; + process.env.XDG_CONFIG_HOME = configTempDir; + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + process.env = originalEnv; + await fs.rm(globalRoot, { recursive: true, force: true }); + await fs.rm(configTempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + describe('openspec init --global --tools claude', () => { + it('should write skills to global path', async () => { + const initCommand = new InitCommand({ tools: 'claude', global: true }); + await initCommand.executeGlobal(); + + const claudeRoot = path.join(globalRoot, 'claude'); + const skillsDir = path.join(claudeRoot, 'skills'); + const skillEntries = await fs.readdir(skillsDir); + expect(skillEntries.some((e) => e.startsWith('openspec-'))).toBe(true); + }); + + it('should write commands to global path', async () => { + const initCommand = new InitCommand({ tools: 'claude', global: true }); + await initCommand.executeGlobal(); + + const claudeRoot = path.join(globalRoot, 'claude'); + const commandsDir = path.join(claudeRoot, 'commands', 'opsx'); + const cmdEntries = await fs.readdir(commandsDir); + expect(cmdEntries.some((e) => e.endsWith('.md'))).toBe(true); + }); + + it('should not create openspec/ directory structure', async () => { + const initCommand = new InitCommand({ tools: 'claude', global: true }); + await initCommand.executeGlobal(); + + // No openspec/ should exist in globalRoot + const entries = await fs.readdir(globalRoot); + expect(entries).not.toContain('openspec'); + }); + }); + + describe('openspec init --global without --tools', () => { + it('should throw an error requiring --tools', async () => { + const initCommand = new InitCommand({ global: true }); + await expect(initCommand.executeGlobal()).rejects.toThrow('--tools is required with --global'); + }); + }); + + describe('openspec init --global --tools all', () => { + it('should install for all tools with global support', async () => { + const initCommand = new InitCommand({ tools: 'all', global: true }); + await initCommand.executeGlobal(); + + // Claude, OpenCode, and Codex all have native global support + // With OPENSPEC_GLOBAL_ROOT set, all adapters get a path — but "all" resolves + // via getGlobalAdapters() which only returns natively-supported ones + const entries = await fs.readdir(globalRoot); + expect(entries).toContain('claude'); + expect(entries).toContain('opencode'); + expect(entries).toContain('codex'); + }); + }); + + describe('openspec init --global --tools claude,opencode', () => { + it('should install for multiple comma-separated tools', async () => { + const initCommand = new InitCommand({ tools: 'claude,opencode', global: true }); + await initCommand.executeGlobal(); + + const entries = await fs.readdir(globalRoot); + expect(entries).toContain('claude'); + expect(entries).toContain('opencode'); + }); + }); + + describe('openspec init --global --tools cursor', () => { + it('should error when all specified tools lack global support', async () => { + // With OPENSPEC_GLOBAL_ROOT set, cursor will actually have a path + // So we need to unset it and use the adapter's native getGlobalRoot which returns null + delete process.env.OPENSPEC_GLOBAL_ROOT; + const initCommand = new InitCommand({ tools: 'cursor', global: true }); + await expect(initCommand.executeGlobal()).rejects.toThrow('No tools with global support found'); + }); + }); + + describe('openspec init --global --tools claude,cursor (mixed support)', () => { + it('should install for supported tools and skip unsupported ones', async () => { + delete process.env.OPENSPEC_GLOBAL_ROOT; + const initCommand = new InitCommand({ tools: 'claude,cursor', global: true }); + await initCommand.executeGlobal(); + + // Claude should be installed at its native global root + const claudeRoot = claudeAdapter.getGlobalRoot!(); + const skillsDir = path.join(claudeRoot, 'skills'); + const hasSkills = await fs.readdir(skillsDir).then( + (entries) => entries.some((e) => e.startsWith('openspec-')), + () => false + ); + expect(hasSkills).toBe(true); + }); + }); + + describe('openspec init --global --tools (empty)', () => { + it('should error on empty tools value', async () => { + const initCommand = new InitCommand({ tools: '', global: true }); + await expect(initCommand.executeGlobal()).rejects.toThrow('--tools requires at least one tool ID'); + }); + + it('should error on whitespace-only tools value', async () => { + const initCommand = new InitCommand({ tools: ' ', global: true }); + await expect(initCommand.executeGlobal()).rejects.toThrow('--tools requires at least one tool ID'); + }); + }); + + describe('openspec update --global', () => { + it('should update globally-installed files', async () => { + // First install globally + const initCommand = new InitCommand({ tools: 'claude', global: true }); + await initCommand.executeGlobal(); + + // Then update + const updateCommand = new UpdateCommand({ global: true }); + await updateCommand.executeGlobal(); + + // Verify files still exist + const claudeRoot = path.join(globalRoot, 'claude'); + const skillsDir = path.join(claudeRoot, 'skills'); + const skillEntries = await fs.readdir(skillsDir); + expect(skillEntries.some((e) => e.startsWith('openspec-'))).toBe(true); + }); + + it('should regenerate content on update', async () => { + // First install globally + const initCommand = new InitCommand({ tools: 'claude', global: true }); + await initCommand.executeGlobal(); + + // Tamper with a skill file + const claudeRoot = path.join(globalRoot, 'claude'); + const skillsDir = path.join(claudeRoot, 'skills'); + const skillEntries = await fs.readdir(skillsDir); + const firstSkill = skillEntries.find((e) => e.startsWith('openspec-'))!; + const skillFile = path.join(skillsDir, firstSkill, 'SKILL.md'); + await fs.writeFile(skillFile, '# tampered content\n'); + + // Update should regenerate + const updateCommand = new UpdateCommand({ global: true }); + await updateCommand.executeGlobal(); + + const content = await fs.readFile(skillFile, 'utf-8'); + expect(content).not.toBe('# tampered content\n'); + expect(content).toContain('---'); + }); + + it('should show message when no global files exist', async () => { + const updateCommand = new UpdateCommand({ global: true }); + await updateCommand.executeGlobal(); + + // Should not throw, just show a message + }); + }); +}); From d55d93354c5bed997bd0fecb57cf94a7c49eb9a1 Mon Sep 17 00:00:00 2001 From: Patrick <4002194+askpatrickw@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:48:45 -0800 Subject: [PATCH 2/3] fix: address review feedback on global init/update - Use generated command list for global command detection instead of hard-coded 'explore' probe, ensuring all active workflows are checked - Mock claudeAdapter.getGlobalRoot() in test to avoid writing to real ~/.claude directory during test execution Co-Authored-By: Claude Opus 4.6 --- src/core/update.ts | 16 ++++++++-------- test/core/global-init.test.ts | 8 +++++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/core/update.ts b/src/core/update.ts index 57c828253..592951057 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -734,13 +734,14 @@ export class UpdateCommand { const hasSkills = fs.existsSync(skillsDir) && fs.readdirSync(skillsDir).some((d) => d.startsWith('openspec-')); - // Check for commands by looking at the adapter's command path pattern - let hasCommands = false; - if (shouldGenerateCommands) { - const testPath = adapter.getFilePath('explore'); - const globalCmdPath = path.join(globalRoot, testPath.replace(/^\.?[^/\\]+[/\\]/, '')); - hasCommands = fs.existsSync(globalCmdPath); - } + // Check for commands by checking if any generated command files exist globally + const generatedCommands = shouldGenerateCommands + ? generateCommands(commandContents, adapter) + : []; + const hasCommands = generatedCommands.some((cmd) => { + const globalCmdPath = path.join(globalRoot, cmd.path.replace(/^\.?[^/\\]+[/\\]/, '')); + return fs.existsSync(globalCmdPath); + }); if (!hasSkills && !hasCommands) continue; @@ -765,7 +766,6 @@ export class UpdateCommand { // Regenerate commands if (shouldGenerateCommands && hasCommands) { - const generatedCommands = generateCommands(commandContents, adapter); for (const cmd of generatedCommands) { const commandFile = path.join(globalRoot, cmd.path.replace(/^\.?[^/\\]+[/\\]/, '')); await FileSystemUtils.writeFile(commandFile, cmd.fileContent); diff --git a/test/core/global-init.test.ts b/test/core/global-init.test.ts index 3e6941d4e..376016ef1 100644 --- a/test/core/global-init.test.ts +++ b/test/core/global-init.test.ts @@ -114,12 +114,14 @@ describe('Global Init', () => { describe('openspec init --global --tools claude,cursor (mixed support)', () => { it('should install for supported tools and skip unsupported ones', async () => { delete process.env.OPENSPEC_GLOBAL_ROOT; + // Mock getGlobalRoot to avoid writing to the real ~/.claude directory + const tempClaudeRoot = path.join(globalRoot, 'claude-native'); + vi.spyOn(claudeAdapter, 'getGlobalRoot').mockReturnValue(tempClaudeRoot); const initCommand = new InitCommand({ tools: 'claude,cursor', global: true }); await initCommand.executeGlobal(); - // Claude should be installed at its native global root - const claudeRoot = claudeAdapter.getGlobalRoot!(); - const skillsDir = path.join(claudeRoot, 'skills'); + // Claude should be installed at the mocked global root + const skillsDir = path.join(tempClaudeRoot, 'skills'); const hasSkills = await fs.readdir(skillsDir).then( (entries) => entries.some((e) => e.startsWith('openspec-')), () => false From 37f4d8fc224c446c6483aa21c9574bc4b34ee6c0 Mon Sep 17 00:00:00 2001 From: Patrick <4002194+askpatrickw@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:24:43 -0800 Subject: [PATCH 3/3] fix: use guarded error extraction in global update catch block Co-Authored-By: Claude Opus 4.6 --- src/core/update.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/update.ts b/src/core/update.ts index 592951057..a6c50c9ed 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -776,7 +776,7 @@ export class UpdateCommand { spinner.succeed(`Updated global files for ${toolName} → ${globalRoot}`); toolsUpdated++; } catch (error) { - spinner.fail(`Failed to update ${toolName}: ${(error as Error).message}`); + spinner.fail(`Failed to update ${toolName}: ${error instanceof Error ? error.message : String(error)}`); } }