diff --git a/docs/supported-tools.md b/docs/supported-tools.md index fb568832c0..42fda0a10e 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -38,6 +38,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | Factory Droid (`factory`) | `.factory/skills/openspec-*/SKILL.md` | `.factory/commands/opsx-.md` | | Gemini CLI (`gemini`) | `.gemini/skills/openspec-*/SKILL.md` | `.gemini/commands/opsx/.toml` | | GitHub Copilot (`github-copilot`) | `.github/skills/openspec-*/SKILL.md` | `.github/prompts/opsx-.prompt.md`\*\* | +| Hermes Agent (`hermes`) | `~/.hermes/skills/openspec-*/SKILL.md`\*\*\* | Not generated (no command adapter; use skill-based `/skill:openspec-*` invocations) | | iFlow (`iflow`) | `.iflow/skills/openspec-*/SKILL.md` | `.iflow/commands/opsx-.md` | | Junie (`junie`) | `.junie/skills/openspec-*/SKILL.md` | `.junie/commands/opsx-.md` | | Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilocode/workflows/opsx-.md` | @@ -58,6 +59,8 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch \*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly. +\*\*\* Hermes Agent installs skills to the user-global `~/.hermes/skills/` directory (not project-local) because Hermes discovers skills globally. A project-local marker directory (`.hermes/skills/`) is created during initialization for auto-detection and update-bookkeeping; the `.hermes` directory at the project root triggers tool detection, while `.hermes/skills/` inside it marks the project as OpenSpec-configured. Use `/skill:openspec-*` to invoke in-session. + ## Non-Interactive Setup For CI/CD or scripted setup, use `--tools` (and optionally `--profile`): @@ -76,7 +79,7 @@ openspec init --tools none openspec init --profile core ``` -**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf` +**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf` ## Workflow-Dependent Installation diff --git a/openspec/changes/add-hermes-agent-support/.openspec.yaml b/openspec/changes/add-hermes-agent-support/.openspec.yaml new file mode 100644 index 0000000000..68b717479d --- /dev/null +++ b/openspec/changes/add-hermes-agent-support/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-11 diff --git a/openspec/changes/add-hermes-agent-support/README.md b/openspec/changes/add-hermes-agent-support/README.md new file mode 100644 index 0000000000..b682c3725c --- /dev/null +++ b/openspec/changes/add-hermes-agent-support/README.md @@ -0,0 +1,3 @@ +# add-hermes-agent-support + +Add Hermes Agent as a supported tool with global skill installation diff --git a/openspec/changes/add-hermes-agent-support/design.md b/openspec/changes/add-hermes-agent-support/design.md new file mode 100644 index 0000000000..f388fc3987 --- /dev/null +++ b/openspec/changes/add-hermes-agent-support/design.md @@ -0,0 +1,89 @@ +## Context + +OpenSpec currently supports 31 AI coding agents via the `AI_TOOLS` array in `config.ts`. Each tool entry has a `skillsDir` field (e.g., `.claude`) that serves two purposes simultaneously: + +1. **Detection**: `getAvailableTools()` checks if `/` exists to detect the tool. +2. **Installation**: `init` and `update` write skills to `//skills/`. + +This dual-purpose design works for all current tools because they all discover skills from a project-local directory. Hermes Agent is different — it discovers skills from a user-global directory (`~/.hermes/skills/`), not from the project tree. + +SpecKit (GitHub Spec Kit) already solved this exact problem with a `detect_dir` vs `dir` separation in its integration model. This change ports that pattern to OpenSpec via a generic `installDir` field. + +## Goals / Non-Goals + +**Goals:** + +- Enable `openspec init --tools hermes` to install OpenSpec skills where Hermes discovers them (`~/.hermes/skills/openspec-*/SKILL.md`). +- Maintain correct project-level detection ("is Hermes configured for this project?") without being polluted by global skills that may exist from other projects. +- Introduce a generic mechanism (`installDir`) that any future global-scope agent can use, not a Hermes-specific hack. +- Centralize path resolution in shared helpers to prevent drift across `init`, `update`, `profile-sync-drift`, and `migration`. + +**Non-Goals:** + +- Adding a Hermes command adapter — Hermes invokes skills via `/skill:openspec-*`, matching the Kimi/ForgeCode/Vibe pattern. +- Modifying Hermes itself to support project-level skill discovery. +- Reworking `delivery=commands` behavior for global-install tools. + +## Decisions + +### 1. `installDir` field on `AIToolOption` + +Add `installDir?: string` to the `AIToolOption` interface. When set, skills are installed to the resolved path (with `~` expansion) instead of `//skills`. When unset, behavior is unchanged. + +**Why a new field instead of overloading `skillsDir`:** `skillsDir` is already used for detection by `getAvailableTools()` — pointing it at `~/.hermes/skills` would cause false detection in every project on the machine. Keeping `skillsDir` as the project-local detection marker and adding `installDir` as the optional installation override cleanly separates the two concerns. + +**Alternative considered:** SpecKit's `detect_dir` + `dir` pair. This would require two new fields and a larger refactor of every consumer that reads `skillsDir`. A single optional `installDir` override is simpler and sufficient — `skillsDir` already works as the detection marker. + +### 2. Project-local marker directory for global-install tools + +When `installDir` is set, `init` and `update` create an empty project-local directory at `//skills/` (the marker directory). This serves: + +- **Detection**: `getAvailableTools()` finds `.hermes/` and reports Hermes as available. +- **Configuration tracking**: `getToolSkillStatus()` checks the marker directory to determine if Hermes is "configured" for this project, rather than checking the global path (which may contain skills from other projects). +- **Update bookkeeping**: `profile-sync-drift` and `migration` use the marker to know which tools are active. + +Hermes itself ignores this directory — skills live in `~/.hermes/skills/`. The marker is purely an OpenSpec bookkeeping artifact. + +### 3. `resolveSkillsDir()` and `resolveMarkerDir()` shared helpers + +Centralize path resolution in `tool-detection.ts`: + +- `resolveSkillsDir(tool, projectRoot)`: returns `installDir` (expanded) if set, else `//skills`. +- `resolveMarkerDir(tool, projectRoot)`: always returns `//skills` (the project-local path, regardless of `installDir`). + +All consumers (`init`, `update`, `profile-sync-drift`, `migration`, `tool-detection`) call these helpers instead of inlining `path.join(projectPath, tool.skillsDir, 'skills')`. This prevents drift when a future tool adds `installDir`. + +### 4. `getToolSkillStatus` marker-based configured check for global-install tools + +For tools with `installDir`, `configured` is determined by marker directory existence, not by checking the global install path. This prevents a machine-wide `~/.hermes/skills/openspec-*` from causing every project to report Hermes as "configured". `skillCount` and `fullyConfigured` still check the global path (when configured) to report accurate skill counts. + +### 5. No command adapter + +- **No command adapter** — Hermes exposes skills dynamically as `/skill:` in-session. No file-based command directory exists. This matches the Kimi CLI, ForgeCode, and Mistral Vibe pattern — adapterless tools that are valid for skill generation but skip command-file generation. + +### 6. Additive/overwrite-only update for global-install tools + +When `installDir` is set, `update` and `init` SHALL NOT remove skill directories from the global path. The `removeUnselectedSkillDirs` and `removeSkillDirs` cleanup functions are skipped for global-install tools. Rationale: + +- Global skills are shared across all projects on the machine. A per-project profile or delivery change must not delete skills that other projects depend on. +- Hermes has no command adapter — `delivery=commands` cannot substitute skills with commands. Deleting skills leaves the user with nothing. +- Residual skill directories (not in the current profile) are harmless — Hermes displays them as available, and their content remains valid. + +**What still happens during update for global-install tools:** +- Overwrite existing skill files with new version content ✓ +- Create newly-selected skill directories ✓ +- Create/maintain the project-local marker directory ✓ + +**What is skipped:** +- `removeUnselectedSkillDirs` — no deletion of non-profile skill dirs +- `removeSkillDirs` — no deletion on delivery=commands switch + +For project-local tools (no `installDir`), deletion behavior is unchanged. + +## Risks / Trade-offs + +- **Global skills are shared across projects**: Running `openspec init --tools hermes` in project A installs skills globally; project B on the same machine will see those skills too. This is inherent to Hermes's global discovery model and matches how Hermes users already manage skills. The marker directory correctly scopes "configured for this project" detection. + +- **`openspec update` overwrites global skills**: If two projects use different OpenSpec versions, updating one overwrites the global skill files. This is acceptable because OpenSpec skills are version-pinned — all projects should use the same OpenSpec version. Skill directories not in the current profile are left in place (additive-only); they remain valid but may appear as "extra" workflows to Hermes. + +- **`getToolSkillStatus` reads from global path for `skillCount`**: This means `skillCount` may report non-zero even in a fresh project if global skills exist. The `configured` flag correctly uses the marker, so this only affects display, not detection logic. diff --git a/openspec/changes/add-hermes-agent-support/proposal.md b/openspec/changes/add-hermes-agent-support/proposal.md new file mode 100644 index 0000000000..f2a6ca5677 --- /dev/null +++ b/openspec/changes/add-hermes-agent-support/proposal.md @@ -0,0 +1,47 @@ +## Why + +Hermes Agent is an open-source AI agent framework by Nous Research that runs in the terminal, messaging platforms, and IDEs. It is in the same category as Claude Code, Codex, and Gemini CLI — all already supported by OpenSpec. Hermes users cannot use `openspec init --tools hermes` because Hermes is absent from the supported tool list. + +Unlike existing supported tools, Hermes discovers skills from a user-global directory (`~/.hermes/skills/`) rather than a project-local path. OpenSpec's current architecture assumes `skillsDir` serves double duty — both detection marker and installation target — which breaks for global-scope tools. This change introduces a generic `installDir` field to separate installation path from detection path, enabling Hermes support and any future global-scope agent. + +## What Changes + +- Add `installDir` optional field to `AIToolOption` interface — when set, skills are installed to the specified (global) path instead of `//skills`. The `~` prefix is expanded to the user's home directory. +- Add Hermes Agent to `AI_TOOLS` with `skillsDir: '.hermes'` (project-local marker for detection) and `installDir: '~/.hermes/skills'` (global installation target). No command adapter — Hermes invokes skills via `/skill:openspec-*` in-session, matching the Kimi/ForgeCode/Vibe pattern. +- When `installDir` is set, `init` and `update` create a project-local marker directory (`.hermes/skills/`) so detection and update-bookkeeping know the tool is configured for this project, while skills are written to the global directory where Hermes discovers them. +- `getToolSkillStatus` uses the marker directory (not the global install path) to determine "configured" for global-install tools, preventing global skills from polluting project-level tool detection. +- Add `resolveSkillsDir()` and `resolveMarkerDir()` helper functions in `tool-detection.ts` as the single source of truth for path resolution, replacing inline `path.join(projectPath, tool.skillsDir, 'skills')` calls across `init.ts`, `update.ts`, `profile-sync-drift.ts`, and `migration.ts`. +- Update `docs/supported-tools.md` with Hermes Agent row, tool ID, and footnotes. +- No command adapter is added — Hermes exposes skills dynamically as `/skill:`. +- When `installDir` is set, `update` and `init` SHALL NOT delete skill directories from the global path — update is additive/overwrite-only for global-install tools. This prevents a per-project profile or delivery change from destroying skills that other projects on the same machine depend on. + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `ai-tool-paths`: define the `.hermes` skills root and `~/.hermes/skills` global install path for Hermes Agent. +- `cli-init`: clarify that tools with `installDir` install skills to a global path while creating a project-local marker directory for detection. +- `cli-update`: skill regeneration for `installDir` tools writes to the global path and maintains the project-local marker. + +## Impact + +- `src/core/config.ts` — add `installDir` field to `AIToolOption`; add Hermes Agent entry to `AI_TOOLS`. +- `src/core/shared/tool-detection.ts` — add `resolveSkillsDir()` and `resolveMarkerDir()` helpers; update `getToolSkillStatus` to use marker directory for global-install tools; update `getToolVersionStatus` to read version from the resolved (global) path. +- `src/core/shared/index.ts` — export new helpers. +- `src/core/init.ts` — use `resolveSkillsDir`/`resolveMarkerDir`; create marker directory for `installDir` tools. +- `src/core/update.ts` — use `resolveSkillsDir`/`resolveMarkerDir`; create marker directory for `installDir` tools (two call sites). +- `src/core/profile-sync-drift.ts` — use `resolveSkillsDir` (two call sites). +- `src/core/migration.ts` — use `resolveSkillsDir`. +- `docs/supported-tools.md` — add Hermes Agent row, tool ID, footnotes. +- `test/core/available-tools.test.ts` — add Hermes detection test. +- `test/core/shared/tool-detection.test.ts` — add `resolveSkillsDir`, `resolveMarkerDir`, Hermes `getToolSkillStatus`/`getToolVersionStatus` tests. + +## Non-Goals + +- Adding a Hermes command adapter (`src/core/command-generation/adapters/hermes.ts`). +- Changing Hermes itself to support project-level skill discovery — that is a Hermes feature, not an OpenSpec concern. +- Reworking `delivery=commands` behavior for global-install tools beyond ensuring skills are not deleted (no command adapter substitution). diff --git a/openspec/changes/add-hermes-agent-support/specs/ai-tool-paths/spec.md b/openspec/changes/add-hermes-agent-support/specs/ai-tool-paths/spec.md new file mode 100644 index 0000000000..219de30157 --- /dev/null +++ b/openspec/changes/add-hermes-agent-support/specs/ai-tool-paths/spec.md @@ -0,0 +1,101 @@ +# ai-tool-paths Delta Specification + +## MODIFIED Requirements + +### Requirement: AIToolOption skillsDir field + +The `AIToolOption` interface SHALL include an optional `skillsDir` field for skill generation path configuration, and an optional `installDir` field for overriding the skill installation directory. + +#### Scenario: Interface includes skillsDir field + +- **WHEN** a tool entry is defined in `AI_TOOLS` that supports skill generation +- **THEN** it SHALL include a `skillsDir` field specifying the project-local base directory (e.g., `.claude`) + +#### Scenario: Skills path follows Agent Skills spec + +- **WHEN** generating skills for a tool with `skillsDir: '.claude'` and no `installDir` +- **THEN** skills SHALL be written to `//skills/` +- **AND** the `/skills` suffix is appended per Agent Skills specification + +#### Scenario: installDir overrides installation path + +- **WHEN** a tool has `installDir` set (e.g., `'~/.hermes/skills'`) +- **THEN** skills SHALL be written to the expanded `installDir` path (with `~` expanded to the user home directory) +- **AND** the `skillsDir` field SHALL still be used as the project-local detection marker + +#### Scenario: installDir supports tilde expansion + +- **WHEN** `installDir` starts with `~` +- **THEN** the `~` SHALL be expanded to the operating system home directory +- **AND** the resulting path SHALL be absolute + +### Requirement: Path configuration for supported tools + +The `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent Skills specification. + +#### Scenario: Claude Code paths defined + +- **WHEN** looking up the `claude` tool +- **THEN** `skillsDir` SHALL be `.claude` + +#### Scenario: Cursor paths defined + +- **WHEN** looking up the `cursor` tool +- **THEN** `skillsDir` SHALL be `.cursor` + +#### Scenario: Windsurf paths defined + +- **WHEN** looking up the `windsurf` tool +- **THEN** `skillsDir` SHALL be `.windsurf` + +#### Scenario: Kimi CLI paths defined + +- **WHEN** looking up the `kimi` tool +- **THEN** `skillsDir` SHALL be `.kimi` + +#### Scenario: Hermes Agent paths defined + +- **WHEN** looking up the `hermes` tool +- **THEN** `skillsDir` SHALL be `.hermes` +- **AND** `installDir` SHALL be `'~/.hermes/skills'` + +#### Scenario: Tools without skillsDir + +- **WHEN** a tool has no `skillsDir` defined +- **THEN** skill generation SHALL error with message indicating the tool is not supported + +### Requirement: Cross-platform path handling + +The system SHALL handle paths correctly across operating systems. + +#### Scenario: Path construction on Windows + +- **WHEN** constructing skill paths on Windows +- **THEN** the system SHALL use `path.join()` for all path construction +- **AND** SHALL NOT hardcode forward slashes + +#### Scenario: Path construction on Unix + +- **WHEN** constructing skill paths on macOS or Linux +- **THEN** the system SHALL use `path.join()` for consistency + +### Requirement: Skills directory resolution helpers + +The system SHALL provide shared helper functions to resolve skill installation and marker directories consistently across all consumers. + +#### Scenario: Resolving skills directory for project-local tools + +- **WHEN** a tool has no `installDir` set +- **THEN** `resolveSkillsDir` SHALL return `//skills` + +#### Scenario: Resolving skills directory for global-install tools + +- **WHEN** a tool has `installDir` set (e.g., `'~/.hermes/skills'`) +- **THEN** `resolveSkillsDir` SHALL return the expanded absolute path +- **AND** SHALL ignore `projectRoot` + +#### Scenario: Resolving marker directory + +- **WHEN** resolving the project-local marker directory for any tool +- **THEN** `resolveMarkerDir` SHALL always return `//skills` +- **AND** SHALL ignore `installDir` diff --git a/openspec/changes/add-hermes-agent-support/specs/cli-init/spec.md b/openspec/changes/add-hermes-agent-support/specs/cli-init/spec.md new file mode 100644 index 0000000000..a9e9c4a786 --- /dev/null +++ b/openspec/changes/add-hermes-agent-support/specs/cli-init/spec.md @@ -0,0 +1,53 @@ +# cli-init Delta Specification + +## MODIFIED Requirements + +### Requirement: AI Tool Configuration + +The command SHALL configure AI coding assistants with skills and slash commands using a searchable multi-select experience. + +#### Scenario: Prompting for AI tool selection + +- **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: Selecting tools to configure + +- **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 + +#### Scenario: Installing skills for a global-install tool + +- **GIVEN** a selected tool has `installDir` set (e.g., Hermes Agent with `installDir: '~/.hermes/skills'`) +- **WHEN** initialization includes skill generation +- **THEN** skills SHALL be written to the expanded `installDir` path (e.g., `~/.hermes/skills/openspec-*/SKILL.md`) +- **AND** a project-local marker directory SHALL be created at `//skills/` (e.g., `.hermes/skills/`) +- **AND** the marker directory enables auto-detection and update-bookkeeping for the tool in this project + +#### Scenario: Global-install tool skips command-file generation + +- **GIVEN** a selected tool has `installDir` set and no registered command adapter +- **WHEN** initialization includes command generation +- **THEN** command-file generation SHALL be skipped for that tool +- **AND** the command output SHALL include `Commands skipped for: (no adapter)` + +#### Scenario: Hermes Agent initialization + +- **WHEN** the user selects Hermes Agent during initialization +- **THEN** OpenSpec SHALL treat it as a supported tool with `skillsDir: '.hermes'` and `installDir: '~/.hermes/skills'` +- **AND** skills SHALL be written to `~/.hermes/skills/openspec-*/SKILL.md` +- **AND** a marker directory SHALL be created at `.hermes/skills/` + +#### Scenario: Global-install tool skips skill directory deletion + +- **GIVEN** a selected tool has `installDir` set and `delivery` is set to `commands` +- **WHEN** initialization runs with `shouldGenerateSkills = false` +- **THEN** `removeSkillDirs` SHALL NOT be called on the global install path +- **AND** existing global skills SHALL be preserved diff --git a/openspec/changes/add-hermes-agent-support/specs/cli-update/spec.md b/openspec/changes/add-hermes-agent-support/specs/cli-update/spec.md new file mode 100644 index 0000000000..e4b4dc0758 --- /dev/null +++ b/openspec/changes/add-hermes-agent-support/specs/cli-update/spec.md @@ -0,0 +1,66 @@ +# cli-update Delta Specification + +## MODIFIED Requirements + +### 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 + +#### Scenario: Updating skills for a global-install tool + +- **GIVEN** a configured tool has `installDir` set (e.g., Hermes Agent with `installDir: '~/.hermes/skills'`) +- **WHEN** the update command refreshes skills for that tool +- **THEN** skills SHALL be regenerated at the expanded `installDir` path +- **AND** the project-local marker directory SHALL be created or maintained at `//skills/` + +#### Scenario: Hermes Agent update + +- **WHEN** updating Hermes Agent skills +- **THEN** skills SHALL be regenerated at `~/.hermes/skills/openspec-*/SKILL.md` +- **AND** the marker directory at `.hermes/skills/` SHALL be created if it does not exist +- **AND** command-file generation SHALL be skipped because no Hermes adapter is registered + +#### Scenario: Global-install tool skips skill directory deletion on profile change + +- **GIVEN** a configured tool has `installDir` set and the active profile excludes workflows that were previously installed +- **WHEN** the update command runs skill regeneration +- **THEN** skill directories for non-profile workflows SHALL NOT be removed from the global install path +- **AND** only skill directories in the active profile SHALL be overwritten or created + +#### Scenario: Global-install tool skips skill directory deletion on delivery change + +- **GIVEN** a configured tool has `installDir` set and `delivery` is set to `commands` +- **WHEN** the update command runs +- **THEN** `removeSkillDirs` SHALL NOT be called on the global install path +- **AND** existing skills SHALL remain in place diff --git a/openspec/changes/add-hermes-agent-support/tasks.md b/openspec/changes/add-hermes-agent-support/tasks.md new file mode 100644 index 0000000000..dd972be4e7 --- /dev/null +++ b/openspec/changes/add-hermes-agent-support/tasks.md @@ -0,0 +1,52 @@ +## 1. Change Artifacts + +- [x] 1.1 Write proposal, design, and spec deltas for Hermes Agent support +- [x] 1.2 Validate change artifacts with `openspec validate` + +## 2. Tool Metadata + +- [x] 2.1 Add `installDir` optional field to `AIToolOption` interface in `src/core/config.ts` +- [x] 2.2 Add Hermes Agent entry to `AI_TOOLS` with `skillsDir: '.hermes'` and `installDir: '~/.hermes/skills'` + +## 3. Path Resolution Helpers + +- [x] 3.1 Add `resolveSkillsDir()` and `resolveMarkerDir()` to `src/core/shared/tool-detection.ts` +- [x] 3.2 Export new helpers from `src/core/shared/index.ts` + +## 4. Detection Logic + +- [x] 4.1 Update `getToolSkillStatus` to use marker directory for `installDir` tools +- [x] 4.2 Update `getToolVersionStatus` to read version from resolved (global) path + +## 5. Installation Logic + +- [x] 5.1 Update `src/core/init.ts` `generateSkillsAndCommands` to use `resolveSkillsDir`/`resolveMarkerDir` and create marker directory for `installDir` tools +- [x] 5.2 Update `src/core/update.ts` (two call sites) to use `resolveSkillsDir`/`resolveMarkerDir` and create marker directory +- [x] 5.3 Update `src/core/profile-sync-drift.ts` (two call sites) to use `resolveSkillsDir` +- [x] 5.4 Update `src/core/migration.ts` to use `resolveSkillsDir` + +## 6. Documentation + +- [x] 6.1 Add Hermes Agent row to `docs/supported-tools.md` table +- [x] 6.2 Add `hermes` to tool IDs list in `docs/supported-tools.md` +- [x] 6.3 Add footnote explaining global installation behavior + +## 7. Tests + +- [x] 7.1 Add Hermes detection test to `test/core/available-tools.test.ts` +- [x] 7.2 Add `resolveSkillsDir` and `resolveMarkerDir` tests to `test/core/shared/tool-detection.test.ts` +- [x] 7.3 Add Hermes `getToolSkillStatus` and `getToolVersionStatus` tests + +## 8. Validation + +- [x] 8.1 Run `pnpm run build` — confirms TypeScript compilation +- [x] 8.2 Run `pnpm run lint` — confirms zero lint errors +- [x] 8.3 Run `pnpm test` — confirms all 1887 tests pass + +## 9. Global Skill Deletion Safety (PR Review Feedback) + +- [x] 9.1 Add `installDir` guard in `update.ts` to skip `removeUnselectedSkillDirs` when `tool.installDir` is set +- [x] 9.2 Add `installDir` guard in `update.ts` to skip `removeSkillDirs` when `tool.installDir` is set +- [x] 9.3 Add `installDir` guard in `init.ts` to skip `removeSkillDirs` when `tool.installDir` is set +- [x] 9.4 Add regression test: custom→core profile switch with global Hermes skills — assert non-core skill dirs are preserved in `~/.hermes/skills/` +- [x] 9.5 Add regression test: `delivery=commands` with global Hermes — assert skill dirs are not deleted from `~/.hermes/skills/` diff --git a/src/core/config.ts b/src/core/config.ts index 55062273d0..f990524477 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -16,6 +16,7 @@ export interface AIToolOption { successLabel?: string; skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec detectionPaths?: string[]; // Override skillsDir for auto-detection; any path existing triggers detection + installDir?: string; // Override the skill installation directory (supports ~ for global paths, e.g. '~/.hermes/skills') } export const AI_TOOLS: AIToolOption[] = [ @@ -35,6 +36,7 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' }, { name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' }, { name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', detectionPaths: ['.github/copilot-instructions.md', '.github/instructions', '.github/workflows/copilot-setup-steps.yml', '.github/prompts', '.github/agents', '.github/skills', '.github/.mcp.json'] }, + { name: 'Hermes Agent', value: 'hermes', available: true, successLabel: 'Hermes Agent', skillsDir: '.hermes', installDir: '~/.hermes/skills' }, { name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' }, { name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie' }, { name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' }, diff --git a/src/core/init.ts b/src/core/init.ts index b6ab31ab77..84e596146e 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -41,6 +41,8 @@ import { getSkillTemplates, getCommandContents, generateSkillContent, + resolveSkillsDir, + resolveMarkerDir, type ToolSkillStatus, } from './shared/index.js'; import { getGlobalConfig, type Delivery, type Profile } from './global-config.js'; @@ -556,11 +558,19 @@ export class InitCommand { const spinner = ora(`Setting up ${tool.name}...`).start(); try { + // Resolve the tool's installation directory. + // When installDir is set (e.g. '~/.hermes/skills'), skills are + // installed globally — the tool discovers them from a user-wide + // directory, not from the project tree. A project-local marker + // directory (.hermes/skills/) is still created so that auto-detection + // and update commands know the tool is configured for this project. + const toolConfig = AI_TOOLS.find((t) => t.value === tool.value); + const skillsDir = toolConfig + ? resolveSkillsDir(toolConfig, projectPath) + : path.join(projectPath, tool.skillsDir, 'skills'); + // Generate skill files if delivery includes skills if (shouldGenerateSkills) { - // Use tool-specific skillsDir - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); - // Create skill directories and SKILL.md files for (const { template, dirName } of skillTemplates) { const skillDir = path.join(skillsDir, dirName); @@ -574,9 +584,20 @@ export class InitCommand { // Write the skill file await FileSystemUtils.writeFile(skillFile, skillContent); } + + // For global-install tools, create a project-local marker directory + // so available-tools detection and update commands know the tool is + // configured for this project. Hermes itself ignores this directory + // — skills live in ~/.hermes/skills/. + if (toolConfig?.installDir) { + const markerDir = resolveMarkerDir(toolConfig, projectPath); + await FileSystemUtils.createDirectory(markerDir); + } } - if (!shouldGenerateSkills) { - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + // Skip skill directory deletion for global-install tools — + // deletion would destroy shared global state that other projects + // depend on, and there is no command adapter to substitute. + if (!shouldGenerateSkills && !toolConfig?.installDir) { removedSkillCount += await this.removeSkillDirs(skillsDir); } diff --git a/src/core/migration.ts b/src/core/migration.ts index 48aaa41eee..a5f2be8cd6 100644 --- a/src/core/migration.ts +++ b/src/core/migration.ts @@ -10,6 +10,7 @@ import { getGlobalConfig, getGlobalConfigPath, saveGlobalConfig, type Delivery } import { CommandAdapterRegistry } from './command-generation/index.js'; import { WORKFLOW_TO_SKILL_DIR } from './profile-sync-drift.js'; import { ALL_WORKFLOWS } from './profiles.js'; +import { resolveMarkerDir, resolveSkillsDir } from './shared/index.js'; import path from 'path'; import * as fs from 'fs'; @@ -29,7 +30,18 @@ function scanInstalledWorkflowArtifacts( for (const tool of tools) { if (!tool.skillsDir) continue; - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + + // Global-install tools (e.g. Hermes): only scan their global skills + // directory when the project-local marker directory exists, so a + // project that merely has the tool's detection directory (e.g. + // .hermes/) does not inherit workflows from another project's global + // install. + if (tool.installDir) { + const markerDir = resolveMarkerDir(tool, projectPath); + if (!fs.existsSync(markerDir)) continue; + } + + const skillsDir = resolveSkillsDir(tool, projectPath); for (const workflowId of ALL_WORKFLOWS) { const skillDirName = WORKFLOW_TO_SKILL_DIR[workflowId]; diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 488d16cfdc..0d7a0a5040 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -4,7 +4,7 @@ import { AI_TOOLS } from './config.js'; import type { Delivery } from './global-config.js'; import { ALL_WORKFLOWS } from './profiles.js'; import { CommandAdapterRegistry } from './command-generation/index.js'; -import { COMMAND_IDS, getConfiguredTools } from './shared/index.js'; +import { COMMAND_IDS, getConfiguredTools, resolveSkillsDir } from './shared/index.js'; type WorkflowId = (typeof ALL_WORKFLOWS)[number]; @@ -97,7 +97,7 @@ export function hasToolProfileOrDeliveryDrift( const knownDesiredWorkflows = toKnownWorkflows(desiredWorkflows); const desiredWorkflowSet = new Set(knownDesiredWorkflows); - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const skillsDir = resolveSkillsDir(tool, projectPath); const adapter = CommandAdapterRegistry.get(toolId); const shouldGenerateSkills = delivery !== 'commands'; const shouldGenerateCommands = delivery !== 'skills'; @@ -185,7 +185,7 @@ function getInstalledWorkflowsForTool( if (!tool?.skillsDir) return []; const installed = new Set(); - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const skillsDir = resolveSkillsDir(tool, projectPath); if (options.includeSkills) { for (const workflow of ALL_WORKFLOWS) { diff --git a/src/core/shared/index.ts b/src/core/shared/index.ts index 32b965696a..2cd17610af 100644 --- a/src/core/shared/index.ts +++ b/src/core/shared/index.ts @@ -18,6 +18,8 @@ export { getToolVersionStatus, getConfiguredTools, getAllToolVersionStatus, + resolveSkillsDir, + resolveMarkerDir, } from './tool-detection.js'; export { diff --git a/src/core/shared/tool-detection.ts b/src/core/shared/tool-detection.ts index 30622209dc..b7d163407c 100644 --- a/src/core/shared/tool-detection.ts +++ b/src/core/shared/tool-detection.ts @@ -5,8 +5,36 @@ */ import path from 'path'; +import os from 'os'; import * as fs from 'fs'; -import { AI_TOOLS } from '../config.js'; +import { AI_TOOLS, type AIToolOption } from '../config.js'; + +/** + * Resolve the skills directory for a tool. + * + * Most tools install skills into a project-local directory + * (``//skills``). Tools with ``installDir`` set + * (e.g. Hermes Agent → ``~/.hermes/skills``) store skills in a user-global + * directory; this function expands ``~`` and returns the absolute path. + */ +export function resolveSkillsDir(tool: AIToolOption, projectRoot: string): string { + if (tool.installDir) { + return path.resolve(tool.installDir.replace(/^~/, os.homedir())); + } + return path.join(projectRoot, tool.skillsDir!, 'skills'); +} + +/** + * Resolve the project-local marker directory for a tool. + * + * For global-install tools this is an empty directory used solely for + * auto-detection and update-bookkeeping. For project-local tools it is + * the same as the skills directory. + */ +export function resolveMarkerDir(tool: AIToolOption, projectRoot: string): string { + return path.join(projectRoot, tool.skillsDir!, 'skills'); +} + /** * Names of skill directories created by openspec init. @@ -85,6 +113,13 @@ export function getToolsWithSkillsDir(): string[] { /** * Checks which skill files exist for a tool. + * + * For project-local tools (no installDir), skill files are checked in + * ``//skills/``. For global-install tools + * (installDir set, e.g. Hermes), "configured" is determined by the + * project-local marker directory existence — the global skills directory + * may contain skills from other projects, so its presence does not mean + * this project has the tool configured. */ export function getToolSkillStatus(projectRoot: string, toolId: string): ToolSkillStatus { const tool = AI_TOOLS.find((t) => t.value === toolId); @@ -92,7 +127,30 @@ export function getToolSkillStatus(projectRoot: string, toolId: string): ToolSki return { configured: false, fullyConfigured: false, skillCount: 0 }; } - const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills'); + if (tool.installDir) { + // Global-install tool: "configured" = marker directory exists. + // skillCount/fullyConfigured reflect the global skills directory. + const markerDir = resolveMarkerDir(tool, projectRoot); + const configured = fs.existsSync(markerDir); + if (!configured) { + return { configured: false, fullyConfigured: false, skillCount: 0 }; + } + const skillsDir = resolveSkillsDir(tool, projectRoot); + let skillCount = 0; + for (const skillName of SKILL_NAMES) { + const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); + if (fs.existsSync(skillFile)) { + skillCount++; + } + } + return { + configured: true, + fullyConfigured: skillCount === SKILL_NAMES.length, + skillCount, + }; + } + + const skillsDir = resolveSkillsDir(tool, projectRoot); let skillCount = 0; for (const skillName of SKILL_NAMES) { @@ -175,7 +233,7 @@ export function getToolVersionStatus( }; } - const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills'); + const skillsDir = resolveSkillsDir(tool, projectRoot); let generatedByVersion: string | null = null; // Find the first skill file that exists and read its version diff --git a/src/core/update.ts b/src/core/update.ts index eab233c7d5..b551bf410a 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -23,6 +23,8 @@ import { getCommandContents, generateSkillContent, getToolsWithSkillsDir, + resolveSkillsDir, + resolveMarkerDir, type ToolVersionStatus, } from './shared/index.js'; import { @@ -188,7 +190,7 @@ export class UpdateCommand { const spinner = ora(`Updating ${tool.name}...`).start(); try { - const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills'); + const skillsDir = resolveSkillsDir(tool, resolvedProjectPath); // Generate skill files if delivery includes skills if (shouldGenerateSkills) { @@ -202,11 +204,23 @@ export class UpdateCommand { await FileSystemUtils.writeFile(skillFile, skillContent); } - removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, desiredWorkflows); + // For global-install tools, create a project-local marker directory + if (tool.installDir) { + const markerDir = resolveMarkerDir(tool, resolvedProjectPath); + await FileSystemUtils.createDirectory(markerDir); + } + + // Global-install tools are additive-only: never delete skill directories + // from the shared global path, as other projects may depend on them. + if (!tool.installDir) { + removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, desiredWorkflows); + } } // Delete skill directories if delivery is commands-only - if (!shouldGenerateSkills) { + // Skip for global-install tools — they have no command adapter to + // substitute skills, and deletion would destroy shared global state. + if (!shouldGenerateSkills && !tool.installDir) { removedSkillCount += await this.removeSkillDirs(skillsDir); } @@ -682,7 +696,7 @@ export class UpdateCommand { const spinner = ora(`Setting up ${tool.name}...`).start(); try { - const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); + const skillsDir = resolveSkillsDir(tool, projectPath); // Create skill files when delivery includes skills if (shouldGenerateSkills) { @@ -695,6 +709,12 @@ export class UpdateCommand { const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } + + // For global-install tools, create a project-local marker directory + if (tool.installDir) { + const markerDir = resolveMarkerDir(tool, projectPath); + await FileSystemUtils.createDirectory(markerDir); + } } // Create commands when delivery includes commands diff --git a/test/core/available-tools.test.ts b/test/core/available-tools.test.ts index 13a3fd7cd1..f278503224 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -178,5 +178,21 @@ describe('available-tools', () => { expect(ohMyPiTool?.name).toBe('Oh My Pi'); expect(ohMyPiTool?.skillsDir).toBe('.omp'); }); + + it('should detect Hermes Agent when .hermes directory exists', async () => { + // Hermes uses skillsDir: '.hermes' with installDir: '~/.hermes/skills' + // Detection still uses the project-local skillsDir + await fs.mkdir(path.join(testDir, '.hermes'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).toContain('hermes'); + + const hermesTool = tools.find((t) => t.value === 'hermes'); + expect(hermesTool).toBeDefined(); + expect(hermesTool?.name).toBe('Hermes Agent'); + expect(hermesTool?.skillsDir).toBe('.hermes'); + expect(hermesTool?.installDir).toBe('~/.hermes/skills'); + }); }); }); diff --git a/test/core/migration.test.ts b/test/core/migration.test.ts index 409206e94d..bafff637b4 100644 --- a/test/core/migration.test.ts +++ b/test/core/migration.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import path from 'path'; import os from 'os'; import { randomUUID } from 'crypto'; @@ -10,6 +10,7 @@ import { saveGlobalConfig, getGlobalConfigPath } from '../../src/core/global-con import { migrateIfNeeded, scanInstalledWorkflows } from '../../src/core/migration.js'; const CLAUDE_TOOL = AI_TOOLS.find((tool) => tool.value === 'claude') as AIToolOption | undefined; +const HERMES_TOOL = AI_TOOLS.find((tool) => tool.value === 'hermes') as AIToolOption | undefined; function ensureClaudeTool(): AIToolOption { if (!CLAUDE_TOOL) { @@ -18,6 +19,13 @@ function ensureClaudeTool(): AIToolOption { return CLAUDE_TOOL; } +function ensureHermesTool(): AIToolOption { + if (!HERMES_TOOL) { + throw new Error('Hermes tool definition not found'); + } + return HERMES_TOOL; +} + async function writeSkill(projectPath: string, dirName: string): Promise { const skillFile = path.join(projectPath, '.claude', 'skills', dirName, 'SKILL.md'); await fsp.mkdir(path.dirname(skillFile), { recursive: true }); @@ -147,4 +155,61 @@ describe('migration', () => { migrateIfNeeded(projectDir, [ensureClaudeTool()]); expect(fs.existsSync(getGlobalConfigPath())).toBe(false); }); + + describe('Hermes global-install isolation', () => { + let fakeHome: string; + let homedirSpy: ReturnType; + + beforeEach(async () => { + // Redirect os.homedir() so the global ~/.hermes/skills path is + // fully under our control. + fakeHome = path.join(os.tmpdir(), `openspec-hermes-home-${randomUUID()}`); + await fsp.mkdir(fakeHome, { recursive: true }); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + }); + + afterEach(async () => { + homedirSpy.mockRestore(); + await fsp.rm(fakeHome, { recursive: true, force: true }); + }); + + async function writeGlobalHermesSkill(skillName: string): Promise { + const skillFile = path.join(fakeHome, '.hermes', 'skills', skillName, 'SKILL.md'); + await fsp.mkdir(path.dirname(skillFile), { recursive: true }); + await fsp.writeFile(skillFile, '---\nmetadata:\n generatedBy: "1.6.0"\n---\n', 'utf-8'); + } + + it('does not inherit global Hermes skills when project marker is absent', async () => { + // Global ~/.hermes/skills has two OpenSpec skills installed by another project. + await writeGlobalHermesSkill('openspec-explore'); + await writeGlobalHermesSkill('openspec-new-change'); + // Project has .hermes/ (detected by getAvailableTools) but NO marker + // directory .hermes/skills/ — so it was never configured for OpenSpec. + await fsp.mkdir(path.join(projectDir, '.hermes'), { recursive: true }); + + const hermes = ensureHermesTool(); + const workflows = scanInstalledWorkflows(projectDir, [hermes]); + expect(workflows).toEqual([]); + + migrateIfNeeded(projectDir, [hermes]); + expect(fs.existsSync(getGlobalConfigPath())).toBe(false); + }); + + it('scans global Hermes skills when project marker exists', async () => { + await writeGlobalHermesSkill('openspec-explore'); + await writeGlobalHermesSkill('openspec-apply-change'); + // Project-local marker directory exists → OpenSpec was initialized here. + await fsp.mkdir(path.join(projectDir, '.hermes', 'skills'), { recursive: true }); + + const hermes = ensureHermesTool(); + const workflows = scanInstalledWorkflows(projectDir, [hermes]); + expect(workflows).toEqual(['explore', 'apply']); + + migrateIfNeeded(projectDir, [hermes]); + const config = readRawConfig(); + expect(config.profile).toBe('custom'); + expect(config.workflows).toEqual(['explore', 'apply']); + expect(config.delivery).toBe('skills'); + }); + }); }); diff --git a/test/core/shared/tool-detection.test.ts b/test/core/shared/tool-detection.test.ts index eb3c04f97d..61448af799 100644 --- a/test/core/shared/tool-detection.test.ts +++ b/test/core/shared/tool-detection.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; @@ -12,7 +12,10 @@ import { getToolVersionStatus, getConfiguredTools, getAllToolVersionStatus, + resolveSkillsDir, + resolveMarkerDir, } from '../../../src/core/shared/tool-detection.js'; +import { AI_TOOLS } from '../../../src/core/config.js'; describe('tool-detection', () => { let testDir: string; @@ -331,4 +334,183 @@ metadata: expect(cursorStatus?.needsUpdate).toBe(false); }); }); + + describe('resolveSkillsDir', () => { + it('should return project-local path for tools without installDir', () => { + const claude = AI_TOOLS.find(t => t.value === 'claude')!; + const result = resolveSkillsDir(claude, '/project'); + expect(result).toBe(path.join('/project', '.claude', 'skills')); + }); + + it('should expand ~ to home directory for global-install tools', () => { + const hermes = AI_TOOLS.find(t => t.value === 'hermes')!; + const result = resolveSkillsDir(hermes, '/project'); + expect(result).toBe(path.resolve(path.join(os.homedir(), '.hermes', 'skills'))); + expect(result).not.toContain('~'); + expect(path.isAbsolute(result)).toBe(true); + }); + + it('should ignore projectRoot for global-install tools', () => { + const hermes = AI_TOOLS.find(t => t.value === 'hermes')!; + const result = resolveSkillsDir(hermes, '/some/other/project'); + const expected = path.resolve(path.join(os.homedir(), '.hermes', 'skills')); + expect(result).toBe(expected); + }); + }); + + describe('resolveMarkerDir', () => { + it('should return project-local path for all tools', () => { + const hermes = AI_TOOLS.find(t => t.value === 'hermes')!; + const result = resolveMarkerDir(hermes, '/project'); + expect(result).toBe(path.join('/project', '.hermes', 'skills')); + }); + + it('should return project-local path for tools without installDir', () => { + const claude = AI_TOOLS.find(t => t.value === 'claude')!; + const result = resolveMarkerDir(claude, '/project'); + expect(result).toBe(path.join('/project', '.claude', 'skills')); + }); + }); + + describe('getToolSkillStatus (Hermes global-install)', () => { + let fakeHome: string; + let homedirSpy: ReturnType; + + beforeEach(async () => { + // Redirect os.homedir() to a temp dir so the global ~/.hermes/skills + // path is fully under our control (deterministic skill counts). + fakeHome = path.join(os.tmpdir(), `openspec-hermes-home-${randomUUID()}`); + await fs.mkdir(fakeHome, { recursive: true }); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + }); + + afterEach(async () => { + homedirSpy.mockRestore(); + await fs.rm(fakeHome, { recursive: true, force: true }); + }); + + /** + * Create a SKILL.md fixture with the given generatedBy version in the + * fake global ~/.hermes/skills directory. + */ + async function createGlobalSkill(skillName: string, generatedBy?: string): Promise { + const skillFile = path.join(fakeHome, '.hermes', 'skills', skillName, 'SKILL.md'); + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + const frontmatter = generatedBy + ? `---\nmetadata:\n generatedBy: "${generatedBy}"\n---\n` + : `---\n---\n`; + await fs.writeFile(skillFile, frontmatter, 'utf-8'); + } + + it('should return not configured when marker directory does not exist', () => { + const status = getToolSkillStatus(testDir, 'hermes'); + expect(status.configured).toBe(false); + expect(status.fullyConfigured).toBe(false); + expect(status.skillCount).toBe(0); + }); + + it('should return configured with exact skillCount when marker and global skills exist', async () => { + // Marker directory (project-local) + await fs.mkdir(path.join(testDir, '.hermes', 'skills'), { recursive: true }); + // Two global skills + await createGlobalSkill('openspec-explore', '1.6.0'); + await createGlobalSkill('openspec-new-change', '1.6.0'); + + const status = getToolSkillStatus(testDir, 'hermes'); + expect(status.configured).toBe(true); + expect(status.skillCount).toBe(2); + expect(status.fullyConfigured).toBe(false); + }); + + it('should return fullyConfigured when all skills exist globally', async () => { + await fs.mkdir(path.join(testDir, '.hermes', 'skills'), { recursive: true }); + for (const skillName of SKILL_NAMES) { + await createGlobalSkill(skillName, '1.6.0'); + } + + const status = getToolSkillStatus(testDir, 'hermes'); + expect(status.configured).toBe(true); + expect(status.skillCount).toBe(SKILL_NAMES.length); + expect(status.fullyConfigured).toBe(true); + }); + + it('should return configured with zero skills when marker exists but global is empty', async () => { + await fs.mkdir(path.join(testDir, '.hermes', 'skills'), { recursive: true }); + + const status = getToolSkillStatus(testDir, 'hermes'); + expect(status.configured).toBe(true); + expect(status.skillCount).toBe(0); + expect(status.fullyConfigured).toBe(false); + }); + }); + + describe('getToolVersionStatus (Hermes global-install)', () => { + let fakeHome: string; + let homedirSpy: ReturnType; + + beforeEach(async () => { + fakeHome = path.join(os.tmpdir(), `openspec-hermes-home-${randomUUID()}`); + await fs.mkdir(fakeHome, { recursive: true }); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + }); + + afterEach(async () => { + homedirSpy.mockRestore(); + await fs.rm(fakeHome, { recursive: true, force: true }); + }); + + async function createGlobalSkill(skillName: string, generatedBy?: string): Promise { + const skillFile = path.join(fakeHome, '.hermes', 'skills', skillName, 'SKILL.md'); + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + const frontmatter = generatedBy + ? `---\nmetadata:\n generatedBy: "${generatedBy}"\n---\n` + : `---\n---\n`; + await fs.writeFile(skillFile, frontmatter, 'utf-8'); + } + + it('should return not configured when marker directory does not exist', () => { + const status = getToolVersionStatus(testDir, 'hermes', '1.6.0'); + expect(status.configured).toBe(false); + expect(status.needsUpdate).toBe(false); + }); + + it('should report matching version without update needed', async () => { + await fs.mkdir(path.join(testDir, '.hermes', 'skills'), { recursive: true }); + await createGlobalSkill('openspec-explore', '1.6.0'); + + const status = getToolVersionStatus(testDir, 'hermes', '1.6.0'); + expect(status.toolId).toBe('hermes'); + expect(status.toolName).toBe('Hermes Agent'); + expect(status.configured).toBe(true); + expect(status.generatedByVersion).toBe('1.6.0'); + expect(status.needsUpdate).toBe(false); + }); + + it('should report needsUpdate when version differs', async () => { + await fs.mkdir(path.join(testDir, '.hermes', 'skills'), { recursive: true }); + await createGlobalSkill('openspec-explore', '1.5.0'); + + const status = getToolVersionStatus(testDir, 'hermes', '1.6.0'); + expect(status.configured).toBe(true); + expect(status.generatedByVersion).toBe('1.5.0'); + expect(status.needsUpdate).toBe(true); + }); + + it('should report needsUpdate when generatedBy is missing from skill file', async () => { + await fs.mkdir(path.join(testDir, '.hermes', 'skills'), { recursive: true }); + await createGlobalSkill('openspec-explore'); + + const status = getToolVersionStatus(testDir, 'hermes', '1.6.0'); + expect(status.configured).toBe(true); + expect(status.generatedByVersion).toBeNull(); + expect(status.needsUpdate).toBe(true); + }); + }); + + describe('getToolsWithSkillsDir', () => { + it('should include hermes in the list', () => { + const tools = getToolsWithSkillsDir(); + expect(tools).toContain('hermes'); + }); + }); }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index dfdadfb58f..351478a9db 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1731,6 +1731,94 @@ content }); }); + describe('global-install skill deletion safety', () => { + let fakeHome: string; + let homedirSpy: ReturnType; + + beforeEach(async () => { + // Redirect os.homedir() to a temp dir so the global ~/.hermes/skills + // path is fully under our control. + fakeHome = path.join(os.tmpdir(), `openspec-hermes-home-${randomUUID()}`); + await fs.mkdir(fakeHome, { recursive: true }); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome); + }); + + afterEach(async () => { + homedirSpy.mockRestore(); + await fs.rm(fakeHome, { recursive: true, force: true }); + }); + + /** Create a skill fixture in the fake global ~/.hermes/skills directory. */ + async function createGlobalSkill(skillName: string): Promise { + const skillFile = path.join(fakeHome, '.hermes', 'skills', skillName, 'SKILL.md'); + await fs.mkdir(path.dirname(skillFile), { recursive: true }); + await fs.writeFile(skillFile, '---\nmetadata:\n generatedBy: "1.6.0"\n---\nold content\n', 'utf-8'); + } + + it('should not remove non-profile skill directories from global path on profile change', async () => { + // Simulate Project A: installed with custom profile (explore, new, propose) + // Then Project B switches to core profile. + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'both', + }); + + // Create the project-local marker so Hermes is "configured" + await fs.mkdir(path.join(testDir, '.hermes', 'skills'), { recursive: true }); + + // Create global skills for both core and non-core workflows + const coreSkill = 'openspec-explore'; + const nonCoreSkill = 'openspec-propose'; // not in core profile + await createGlobalSkill(coreSkill); + await createGlobalSkill(nonCoreSkill); + + // Force update to trigger profile sync (core profile would normally + // remove openspec-propose, but for installDir tools it must be skipped) + const forceUpdate = new UpdateCommand({ force: true }); + await forceUpdate.execute(testDir); + + // Core skill should be overwritten with new content + const coreContent = await fs.readFile( + path.join(fakeHome, '.hermes', 'skills', coreSkill, 'SKILL.md'), + 'utf-8' + ); + expect(coreContent).not.toContain('old content'); + + // Non-core skill MUST still exist — global path is shared across projects + const nonCoreExists = await FileSystemUtils.fileExists( + path.join(fakeHome, '.hermes', 'skills', nonCoreSkill, 'SKILL.md') + ); + expect(nonCoreExists).toBe(true); + }); + + it('should not delete global skills when delivery is commands-only', async () => { + setMockConfig({ + featureFlags: {}, + profile: 'core', + delivery: 'commands', + }); + + // Create the project-local marker so Hermes is "configured" + await fs.mkdir(path.join(testDir, '.hermes', 'skills'), { recursive: true }); + + // Create global skill + const skillName = 'openspec-explore'; + await createGlobalSkill(skillName); + + // Force update — delivery=commands would normally removeSkillDirs, + // but for installDir tools it must be skipped + const forceUpdate = new UpdateCommand({ force: true }); + await forceUpdate.execute(testDir); + + // Skill MUST still exist — Hermes has no command adapter to substitute + const skillExists = await FileSystemUtils.fileExists( + path.join(fakeHome, '.hermes', 'skills', skillName, 'SKILL.md') + ); + expect(skillExists).toBe(true); + }); + }); + describe('scanInstalledWorkflows', () => { it('should detect installed workflows across tools', async () => { // Create skills for Claude