diff --git a/.github/workflows/sync-generated-output.yml b/.github/workflows/sync-generated-output.yml index 9e5acbdfa..b3c764f49 100644 --- a/.github/workflows/sync-generated-output.yml +++ b/.github/workflows/sync-generated-output.yml @@ -27,6 +27,7 @@ env: .gemini .github/skills .grok + .hermes .kiro .opencode .pi diff --git a/AGENTS.md b/AGENTS.md index 8029d45ed..39bcc730c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ Run `bun run build` after changing anything in `skill/`, transformer code, or us ## Generated Provider Output Policy -The root harness folders (`.agents/skills/`, `.claude/skills/`, `.cursor/skills/`, `.gemini/skills/`, `.github/skills/`, `.grok/skills/`, `.kiro/skills/`, `.opencode/skills/`, `.pi/skills/`, `.qoder/skills/`, `.rovodev/skills/`, `.trae*/skills/`, `.vibe/skills/`) and `plugin/` stay tracked so `main` remains installable for direct GitHub, `npx skills`, and submodule users. They are still generated artifacts. +The root harness folders (`.agents/skills/`, `.claude/skills/`, `.cursor/skills/`, `.gemini/skills/`, `.github/skills/`, `.grok/skills/`, `.hermes/skills/`, `.kiro/skills/`, `.opencode/skills/`, `.pi/skills/`, `.qoder/skills/`, `.rovodev/skills/`, `.trae*/skills/`, `.vibe/skills/`) and `plugin/` stay tracked so `main` remains installable for direct GitHub, `npx skills`, and submodule users. They are still generated artifacts. Normal development should be source-first: stage changes in `skill/`, `scripts/`, `cli/`, `site/`, `extension/`, `functions/`, and `tests/`; leave generated harness churn unstaged unless the user asked for it. After source changes land on `main`, `.github/workflows/sync-generated-output.yml` runs `bun run build:release` and commits generated provider output directly back to `main`. Treat generated harness diffs as release artifacts and keep them out of feature PRs unless they are the point of the PR. diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs index c57c4d0a6..c62a2558b 100644 --- a/cli/bin/commands/skills.mjs +++ b/cli/bin/commands/skills.mjs @@ -23,7 +23,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const API_BASE = 'https://impeccable.style'; // Provider folder names in project roots -const PROVIDER_DIRS = ['.claude', '.cursor', '.gemini', '.agents', '.agent', '.github', '.grok', '.kiro', '.opencode', '.pi', '.qoder', '.trae', '.trae-cn', '.rovodev', '.vibe']; +const PROVIDER_DIRS = ['.claude', '.cursor', '.gemini', '.agents', '.agent', '.github', '.grok', '.hermes', '.kiro', '.opencode', '.pi', '.qoder', '.trae', '.trae-cn', '.rovodev', '.vibe']; const PROVIDER_ALIASES = { agent: '.agent', agents: '.agents', @@ -37,6 +37,7 @@ const PROVIDER_ALIASES = { github: '.github', grok: '.grok', 'grok-build': '.grok', + hermes: '.hermes', xai: '.grok', kiro: '.kiro', opencode: '.opencode', @@ -57,6 +58,7 @@ const PROVIDER_DISPLAY = { '.gemini': { name: 'Gemini CLI', input: 'gemini' }, '.github': { name: 'GitHub Copilot', input: 'github' }, '.grok': { name: 'Grok Build', input: 'grok' }, + '.hermes': { name: 'Hermes Agent', input: 'hermes' }, '.kiro': { name: 'Kiro', input: 'kiro' }, '.opencode': { name: 'OpenCode', input: 'opencode' }, '.pi': { name: 'Pi Coding Agent', input: 'pi' }, @@ -66,7 +68,7 @@ const PROVIDER_DISPLAY = { '.trae-cn': { name: 'Trae CN', input: 'trae-cn' }, '.vibe': { name: 'Mistral Vibe', input: 'vibe' }, }; -const PROVIDER_INPUT_ORDER = ['antigravity', 'claude', 'codex', 'cursor', 'gemini', 'github', 'grok', 'kiro', 'opencode', 'pi', 'qoder', 'trae', 'trae-cn', 'rovo-dev', 'vibe']; +const PROVIDER_INPUT_ORDER = ['antigravity', 'claude', 'codex', 'cursor', 'gemini', 'github', 'grok', 'hermes', 'kiro', 'opencode', 'pi', 'qoder', 'trae', 'trae-cn', 'rovo-dev', 'vibe']; // OpenCode reads global skills from its config directory, not ~/.opencode: // $OPENCODE_CONFIG_DIR, else $XDG_CONFIG_HOME/opencode, else @@ -78,14 +80,52 @@ function opencodeGlobalConfigDir(home) { return join(home, '.config', 'opencode'); } +// Hermes reads skills from `$HERMES_HOME/skills/`, where $HERMES_HOME defaults +// to `~/.hermes` but is also set to a profile path (e.g. +// `~/.hermes/profiles/forge`) when a non-default profile is active. Reading +// the env var matters here at install time: writing to `~/.hermes/skills/` +// from a profile-scoped Hermes invocation would land in the wrong profile +// (the same cross-profile data-corruption class that the active_profile +// fallback warning in hermes_constants.py exists to detect). Used by +// HOME_SKILLS_DIR_OVERRIDES['.hermes'] only; GLOBAL_HARNESS_HINTS reads the +// fixed `~/.hermes` location so detection doesn't leak the developer's real +// HERMES_HOME into test output (test isolation). +// +// Ignore $HERMES_HOME when it doesn't sit under `home` (the caller-supplied +// home dir, which tests inject via HOME=/tmp/...). Without this guard, an +// inherited $HERMES_HOME=/home//.hermes from the developer's shell leaks +// into test output even when the test sets HOME=/tmp/imp-home-xxx: tests +// expect ~/.hermes to live under their tmp home, not under the dev's real +// home. The check uses `resolve()` on both sides so a symlinked test home +// (e.g. /tmp -> /private/tmp on macOS) still compares correctly. +function hermesGlobalHome(home) { + const envHome = process.env.HERMES_HOME; + if (envHome) { + try { + const resolvedEnv = resolve(envHome); + const resolvedHome = resolve(home); + // Honor HERMES_HOME only when it lives under the active home (real + // ~/.hermes or ~/.hermes/profiles/). Cross-home inheritance is + // treated as not-set, so a test running under HOME=/tmp/... doesn't + // pick up the developer's real ~/.hermes. + if (resolvedEnv === resolvedHome || resolvedEnv.startsWith(resolvedHome + sep)) { + return resolvedEnv; + } + } catch { + // fall through to default + } + } + return join(home, '.hermes'); +} + // Providers whose GLOBAL (home) skills dir is not `/skills`, // as a function of the home dir. Pi discovers global skills from // ~/.pi/agent/skills/ (issue #327); OpenCode from its config dir (issue -// #406). Antigravity's global skills dir is ~/.gemini/config/skills/ -// (shared Gemini config location); project scope stays `.agent/skills`. -// Project scope stays `/skills` for all of these. +// #406); Hermes from $HERMES_HOME. Project scope stays `/skills` +// for all three. const HOME_SKILLS_DIR_OVERRIDES = { '.agent': (home) => join(home, '.gemini', 'config', 'skills'), + '.hermes': (home) => join(hermesGlobalHome(home), 'skills'), '.pi': (home) => join(home, '.pi', 'agent', 'skills'), '.opencode': (home) => join(opencodeGlobalConfigDir(home), 'skills'), }; @@ -93,6 +133,14 @@ const HOME_SKILLS_DIR_OVERRIDES = { // When a project has no harness folder yet, infer the target from globally // installed harnesses (~/.claude, ~/.codex, ...). Codex reads skills from // .agents/skills, so ~/.codex maps to the .agents bundle variant. +// +// Hermes auto-detection uses the fixed `~/.hermes` location only. When a +// non-default Hermes profile is active (HERMES_HOME points to a profile path), +// the user is expected to be inside a Hermes invocation and can pass +// --providers=hermes explicitly. Auto-detection from a non-default HERMES_HOME +// would also defeat test isolation (tests inject HOME; HERMES_HOME leaks from +// the parent process and would surface the developer's real ~/.hermes in +// detection output). The install path honors $HERMES_HOME; detection does not. const GLOBAL_HARNESS_HINTS = [ { home: '.agent', provider: '.agent' }, // Antigravity nests under ~/.gemini/ too, so any of these also trips the @@ -105,6 +153,7 @@ const GLOBAL_HARNESS_HINTS = [ { home: '.cursor', provider: '.cursor' }, { home: '.gemini', provider: '.gemini' }, { home: '.grok', provider: '.grok' }, + { home: '.hermes', provider: '.hermes' }, { home: '.kiro', provider: '.kiro' }, { home: '.opencode', provider: '.opencode' }, // OpenCode's real global config dir (issue #406); the ~/.opencode entry @@ -610,7 +659,7 @@ async function copyOrExtractLocalBundle(sourceValue) { */ function normalizeForHash(content) { return content - .replace(/\.(claude|cursor|agents|agent|github|gemini|codex|grok|kiro|opencode|pi|qoder|trae|trae-cn|rovodev|vibe)\/skills\//g, '.PROVIDER/skills/'); + .replace(/\.(claude|cursor|agents|agent|github|gemini|codex|grok|hermes|kiro|opencode|pi|qoder|trae|trae-cn|rovodev|vibe)\/skills\//g, '.PROVIDER/skills/'); } function hashSkillFile(filePath) { @@ -2256,6 +2305,8 @@ export { expectedHookDests, extractZip, formatInstallDetectionLines, + hermesGlobalHome, + HOME_SKILLS_DIR_OVERRIDES, linkProviderSkills, mergeHookManifests, migrateUnprefixImpeccable, diff --git a/cli/lib/download-providers.js b/cli/lib/download-providers.js index afd2abee6..05a8fe9a1 100644 --- a/cli/lib/download-providers.js +++ b/cli/lib/download-providers.js @@ -12,6 +12,7 @@ export const FILE_DOWNLOAD_PROVIDER_CONFIG_DIRS = Object.freeze({ antigravity: '.agent', github: '.github', grok: '.grok', + hermes: '.hermes', kiro: '.kiro', opencode: '.opencode', pi: '.pi', diff --git a/docs/HARNESSES.md b/docs/HARNESSES.md index 68907e8aa..54c3b8a04 100644 --- a/docs/HARNESSES.md +++ b/docs/HARNESSES.md @@ -26,6 +26,7 @@ Last verified: 2026-04-28 (subagent landscape spot-checked 2026-06-28; Mistral V | Rovo Dev | https://support.atlassian.com/rovo/docs/extend-rovo-dev-cli-with-agent-skills | | Mistral Vibe | https://docs.mistral.ai/vibe/code/cli/skills | | Grok Build | https://docs.x.ai/build/features/skills-plugins-marketplaces | +| Hermes Agent | https://hermes-agent.nousresearch.com/docs/ | | Antigravity | https://antigravity.google/docs/skills | ## Spec Compliance @@ -38,28 +39,29 @@ Provider-specific extensions beyond the spec: `user-invocable`, `argument-hint`, Fields marked with * are spec-standard. Others are provider extensions. -| Field | Claude Code | Cursor | Gemini | Codex | Copilot | Grok | Kiro | OpenCode | Pi | Qoder | Rovo Dev | Mistral Vibe | Antigravity | -|-------|:-----------:|:------:|:------:|:-----:|:-------:|:----:|:----:|:--------:|:--:|:-----:|:--------:|:------------:|:-----------:| -| `name`* | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| `description`* | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| `license`* | Yes | Yes | Ignored | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| `compatibility`* | Yes | Yes | Ignored | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| `metadata`* | Yes | Yes | Ignored | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| `allowed-tools`* | Yes | No | Ignored | No | No | Yes | No | Yes | Yes | Yes | Yes | Yes | Yes | -| `user-invocable` | Yes | No | No | No | Yes | Yes | No | Yes | No | Yes | Yes | Yes | No | -| `argument-hint` | Yes | No | No | No | Yes | Yes | No | Yes | No | Yes | Yes | No | No | -| `disable-model-invocation` | Yes | Yes | No | No | Yes | Yes | No | Yes | Yes | TBD | TBD | No | No | -| `model` | Yes | No | No | No | No | Yes | No | Yes | No | No | No | No | No | -| `effort` | Yes | No | No | No | No | Yes | No | No | No | No | No | No | No | -| `context` | Yes | No | No | No | No | No | No | No | No | No | No | No | No | -| `agent` | Yes | No | No | No | No | No | No | Yes | No | No | No | No | No | -| `hooks` | Yes | No | No | Yes | No | Yes | No | No | No | No | No | No | No | +| Field | Claude Code | Cursor | Gemini | Codex | Copilot | Grok | Hermes | Kiro | OpenCode | Pi | Qoder | Rovo Dev | Mistral Vibe | Antigravity | +|-------|:-----------:|:------:|:------:|:-----:|:-------:|:----:|:------:|:----:|:--------:|:--:|:-----:|:--------:|:------------:|:-----------:| +| `name`* | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| `description`* | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| `license`* | Yes | Yes | Ignored | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| `compatibility`* | Yes | Yes | Ignored | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| `metadata`* | Yes | Yes | Ignored | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| `allowed-tools`* | Yes | No | Ignored | No | No | Yes | No | No | Yes | Yes | Yes | Yes | Yes | Yes | +| `user-invocable` | Yes | No | No | No | Yes | Yes | No | No | Yes | No | Yes | Yes | Yes | No | +| `argument-hint` | Yes | No | No | No | Yes | Yes | No | No | Yes | No | Yes | Yes | No | No | +| `disable-model-invocation` | Yes | Yes | No | No | Yes | Yes | No | No | Yes | Yes | TBD | TBD | No | No | +| `model` | Yes | No | No | No | No | Yes | No | No | Yes | No | No | No | No | No | +| `effort` | Yes | No | No | No | No | Yes | No | No | No | No | No | No | No | No | +| `context` | Yes | No | No | No | No | No | No | No | No | No | No | No | No | No | +| `agent` | Yes | No | No | No | No | No | No | No | Yes | No | No | No | No | No | +| `hooks` | Yes | No | No | Yes | No | Yes | No | No | No | No | No | No | No | No | Notes: - Gemini CLI validates only `name` and `description`; other spec fields are parsed but ignored. - Codex CLI uses a separate `agents/openai.yaml` sidecar for skill metadata (icons, branding, MCP tools, invocation control). Codex also auto-discovers subagents bundled inside an installed skill's `agents/` folder (TOML), which is how Impeccable ships its asset-producer. Standalone custom agents can still live under `.codex/agents/` or `~/.codex/agents/`, but Impeccable no longer installs anything there. - Codex CLI hooks ship under `[features].hooks = true` (still flagged), require `/hooks` trust ceremony per-update, and are disabled on Windows. - Grok Build is Claude Code compatible with zero config: it also reads `.claude/skills/`, `.claude/settings.json` hooks, and Claude plugin layouts. Native paths are `.grok/skills/`, `.grok/hooks/*.json`, and `.grok/agents/`. Skill frontmatter supports `when-to-use` in addition to the fields above. Project hooks require `/hooks-trust` (or `--trust`). See https://docs.x.ai/build/features/skills-plugins-marketplaces and https://docs.x.ai/build/features/hooks. +- Hermes Agent reads the Agent Skills spec as-is. Spec-defined fields (`name`, `description`, `license`, `compatibility`, `metadata`) are parsed and stored; harness-specific extensions (`user-invocable`, `argument-hint`, `allowed-tools`, `disable-model-invocation`, `model`, `effort`, `context`, `agent`, `hooks`) are unknown keys and silently ignored. Hermes has no hook surface, no per-skill tool ACL, and no slash-command equivalent of `user-invocable` (skills are loaded via `/skill ` or auto-loaded; sub-commands like `/impeccable polish` are routed from the skill body, not declared in frontmatter). Hermes adds two frontmatter fields not in the spec: `platforms:` (OS filter; default = all) and `environments:` (relevance gate over `kanban`, `docker`, `s6`). Unknown fields are silently ignored. - Kiro recognizes `user-invocable` and `disable-model-invocation` per community reports but does not formally document them. - Antigravity supports standard Agent Skills spec frontmatter fields (`name`, `description`, `license`, `compatibility`, `metadata`, `allowed-tools`). - Unknown fields are silently ignored by all harnesses. @@ -92,6 +94,7 @@ Notes: | Rovo Dev | `.rovodev/skills/` | `~/.rovodev/skills/` (user-level) | | Mistral Vibe | `.vibe/skills/` (project), `~/.vibe/skills/` (global) | `.agents/skills/` (project), `~/.agents/skills/` (global) | | Grok Build | `.grok/skills/` (project), `~/.grok/skills/` (global) | `.agents/skills/`, `.claude/skills/`, `.cursor/skills/` (Claude/Cursor compat, configurable) | +| Hermes Agent | `.hermes/skills/` (project), `~/.hermes/skills/` (global) | `skills.external_dirs` config (no automatic `.agents/skills/` fallback) | | Antigravity | `.agent/skills/` (project), `~/.gemini/config/skills/` (global) | `.agents/skills/` (project), `~/.agents/skills/` (global) | All harnesses support the `{skill-name}/SKILL.md` directory structure with optional `reference/`, `scripts/`, and `assets/` subdirectories. diff --git a/scripts/build.js b/scripts/build.js index 445b22752..94257eb85 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -491,6 +491,7 @@ This folder contains skills for all supported tools: .agent/ -> Antigravity .github/ -> GitHub Copilot .grok/ -> Grok Build + .hermes/ -> Hermes Agent .kiro/ -> Kiro .opencode/ -> OpenCode .pi/ -> Pi diff --git a/scripts/lib/transformers/index.js b/scripts/lib/transformers/index.js index 46603bc9d..449db6a90 100644 --- a/scripts/lib/transformers/index.js +++ b/scripts/lib/transformers/index.js @@ -18,5 +18,6 @@ export const transformRovoDev = createTransformer(PROVIDERS['rovo-dev']); export const transformVibe = createTransformer(PROVIDERS.vibe); export const transformGrok = createTransformer(PROVIDERS.grok); export const transformAntigravity = createTransformer(PROVIDERS.antigravity); +export const transformHermes = createTransformer(PROVIDERS.hermes); export { createTransformer, PROVIDERS }; diff --git a/scripts/lib/transformers/providers.js b/scripts/lib/transformers/providers.js index a001d738e..c33ce37ac 100644 --- a/scripts/lib/transformers/providers.js +++ b/scripts/lib/transformers/providers.js @@ -162,4 +162,18 @@ export const PROVIDERS = { displayName: 'Antigravity', frontmatterFields: ['license', 'compatibility', 'metadata', 'allowed-tools'], }, + hermes: { + provider: 'hermes', + providerTags: ['hermes'], + configDir: '.hermes', + displayName: 'Hermes Agent', + // Hermes ships the Agent Skills spec as-is. The optional fields below + // (license, compatibility, metadata) are spec-defined; harness-specific + // extensions (user-invocable, argument-hint, allowed-tools) are NOT + // recognized by the Hermes skill loader and would be silently ignored. + // Hermes also has no hook surface, no equivalent of Claude's slash + // commands, and no per-skill tool ACL -- so no emitHooks, no agentFormat, + // no writeOpenAIMetadata. See hermes-agent/SKILL.md "Skills" section. + frontmatterFields: ['license', 'compatibility', 'metadata'], + }, }; diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index 4e39249e7..f3fe77367 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -539,6 +539,15 @@ export const PROVIDER_PLACEHOLDERS = { config_file: 'AGENTS.md', ask_instruction: 'ask the user directly to clarify what you cannot infer.', command_prefix: '/' + }, + 'hermes': { + // Hermes is provider-agnostic and reads AGENTS.md / CLAUDE.md / .cursorrules + // for project context. "the model" matches the pi/opencode phrasing used + // for harnesses without a vendor-fixed assistant name. + model: 'the model', + config_file: 'AGENTS.md', + ask_instruction: 'ask the user directly to clarify what you cannot infer.', + command_prefix: '/' } }; @@ -552,6 +561,7 @@ export const PROVIDER_BLOCK_TAGS = new Set([ 'gemini', 'github', 'grok', + 'hermes', 'kiro', 'opencode', 'pi', diff --git a/skill/scripts/pin.mjs b/skill/scripts/pin.mjs index 27eb8be35..d80043df7 100644 --- a/skill/scripts/pin.mjs +++ b/skill/scripts/pin.mjs @@ -22,6 +22,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); // All known harness directories const HARNESS_DIRS = [ '.claude', '.cursor', '.gemini', '.codex', '.agents', '.agent', '.github', '.grok', + '.hermes', '.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder', ]; diff --git a/tests/skills-cli.test.js b/tests/skills-cli.test.js index 427f33e25..f4e9b799d 100644 --- a/tests/skills-cli.test.js +++ b/tests/skills-cli.test.js @@ -1940,3 +1940,153 @@ describeRemote('skills install: production universal bundle download', () => { expect(skills).not.toContain('i-impeccable'); }, 90000); }); + +describe('hermesGlobalHome resolver (PR #521)', () => { + // hermesGlobalHome was added in PR #521 to honor $HERMES_HOME for + // profile-scoped installs. The original PR had a P1 bug at lines + // 105-111 of cli/bin/commands/skills.mjs: it called `path.resolve` and + // `path.sep` but the file only named-imports `resolve` and `sep` from + // `node:path`. The ReferenceError was swallowed by the catch block, so + // $HERMES_HOME was silently ignored and installs always landed in + // ~/.hermes regardless of the active profile. + // + // These tests exercise the real implementation (via the export + // added to the skills.mjs test surface), not a reimplementation. + + // The resolver is internal to skills.mjs. It reads $HERMES_HOME and + // returns the home dir it should use for ~/.hermes/skills. We import + // it via the public test surface — see the export block at the bottom + // of skills.mjs. + let hermesGlobalHome; + + beforeAll(async () => { + // Dynamic import so the test can use the same surface as the + // production code without forcing a re-export gymnastics on the + // rest of the test file. + const mod = await import('../cli/bin/commands/skills.mjs'); + hermesGlobalHome = mod.hermesGlobalHome; + }); + + test('default (no HERMES_HOME) returns /.hermes', () => { + // Use a fresh tmp HOME so the test never depends on the dev's real + // ~/.hermes leaking through. The `delete env.HERMES_HOME` happens + // in the caller; here we just verify the function honors an + // explicitly-unset env (process.env is set per test below). + const home = mkdtempSync(join(tmpdir(), 'imp-home-hermes-default-')); + try { + expect(hermesGlobalHome(home)).toBe(join(home, '.hermes')); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test('HERMES_HOME=/.hermes is honored (default profile)', () => { + const home = mkdtempSync(join(tmpdir(), 'imp-home-hermes-real-')); + const prev = process.env.HERMES_HOME; + process.env.HERMES_HOME = join(home, '.hermes'); + try { + // The resolver returns $HERMES_HOME (resolved) when it lives + // under the active home. Callers append 'skills'. + expect(hermesGlobalHome(home)).toBe(join(home, '.hermes')); + } finally { + if (prev === undefined) delete process.env.HERMES_HOME; + else process.env.HERMES_HOME = prev; + rmSync(home, { recursive: true, force: true }); + } + }); + + test('HERMES_HOME=/.hermes/profiles/forge is honored (active profile)', () => { + // The whole point of the resolver: a Hermes invocation with + // HERMES_HOME pointing at an active profile should install into + // that profile's skills dir, not the default ~/.hermes. The + // original bug had install/update/check landing in ~/.hermes for + // every profile, which is the cross-profile data-corruption class + // that hermes_constants.py's active_profile fallback warning is + // designed to detect. + const home = mkdtempSync(join(tmpdir(), 'imp-home-hermes-profile-')); + const prev = process.env.HERMES_HOME; + process.env.HERMES_HOME = join(home, '.hermes', 'profiles', 'forge'); + try { + const resolved = hermesGlobalHome(home); + expect(resolved).toBe(join(home, '.hermes', 'profiles', 'forge')); + // And critically: it must NOT fall back to the default profile + // when an active profile is selected. + expect(resolved).not.toBe(join(home, '.hermes')); + } finally { + if (prev === undefined) delete process.env.HERMES_HOME; + else process.env.HERMES_HOME = prev; + rmSync(home, { recursive: true, force: true }); + } + }); + + test('HERMES_HOME outside the active home is ignored (cross-home leakage guard)', () => { + // If the developer's shell has HERMES_HOME=/home/dev/.hermes and a + // test runs under HOME=/tmp/imp-home-xxx, the resolver must NOT + // pick up the dev's real ~/.hermes. Otherwise test output (and + // potentially writes) leak into the developer's working state. + // The cross-home guard turns the inherited HERMES_HOME into a + // not-set, so the resolver falls back to /.hermes. + const home = mkdtempSync(join(tmpdir(), 'imp-home-hermes-xhome-')); + const otherHome = mkdtempSync(join(tmpdir(), 'imp-home-hermes-xhome-other-')); + const prev = process.env.HERMES_HOME; + process.env.HERMES_HOME = join(otherHome, '.hermes', 'profiles', 'main'); + try { + // HERMES_HOME is set but it doesn't sit under `home`, so the + // resolver should treat it as not-set and return /.hermes. + expect(hermesGlobalHome(home)).toBe(join(home, '.hermes')); + } finally { + if (prev === undefined) delete process.env.HERMES_HOME; + else process.env.HERMES_HOME = prev; + rmSync(home, { recursive: true, force: true }); + rmSync(otherHome, { recursive: true, force: true }); + } + }); + + test('HOME_SKILLS_DIR_OVERRIDES[".hermes"] returns /skills under an active profile', async () => { + // Integration check: the resolver is wired through the override + // map, so this is what the install path actually consumes. The + // import is cached across the suite (ESM module singleton), so + // the same `hermesGlobalHome` from the unit tests above applies + // here. We assert inside the async block so process.env is still + // set when the override function reads it (the unit-test version + // returns synchronously, but this one uses async import to share + // the module reference). + const home = mkdtempSync(join(tmpdir(), 'imp-home-hermes-override-')); + const prev = process.env.HERMES_HOME; + process.env.HERMES_HOME = join(home, '.hermes', 'profiles', 'savant'); + try { + const mod = await import('../cli/bin/commands/skills.mjs'); + const override = mod.HOME_SKILLS_DIR_OVERRIDES['.hermes']; + expect(override(home)).toBe(join(home, '.hermes', 'profiles', 'savant', 'skills')); + } finally { + if (prev === undefined) delete process.env.HERMES_HOME; + else process.env.HERMES_HOME = prev; + rmSync(home, { recursive: true, force: true }); + } + }); + + test('end-to-end: --scope=user --providers=hermes with HERMES_HOME=profile lands in the active profile', () => { + // The full pipeline: drive the real CLI under a controlled HOME and + // HERMES_HOME. This catches any regression that breaks the wiring + // between hermesGlobalHome and the install path (e.g. if a future + // refactor moves the override out of HOME_SKILLS_DIR_OVERRIDES, or + // if copyProviderSkills stops reading from it). + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-hermes-e2e-')); + const home = mkdtempSync(join(tmpdir(), 'imp-home-hermes-e2e-')); + execSync('git init', { cwd: tmp }); + const bundleRoot = createFakeUniversalBundle(tmp, ['.hermes']); + const baseEnv = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot }; + delete baseEnv.HERMES_HOME; + const profileDir = join(home, '.hermes', 'profiles', 'forge'); + const env = { ...baseEnv, HERMES_HOME: profileDir }; + + run('skills install -y --providers=hermes --scope=user --no-hooks', { cwd: tmp, env }); + + // Landed in the active profile, not the default ~/.hermes. + expect(existsSync(join(profileDir, 'skills', 'impeccable', 'SKILL.md'))).toBe(true); + expect(existsSync(join(home, '.hermes', 'skills', 'impeccable', 'SKILL.md'))).toBe(false); + + rmSync(tmp, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + }, 20000); +});