diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index 20c04359dc4..822fd2a7a73 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -53,6 +53,7 @@ beforeEach(() => { afterEach(() => { unavailableRootPath = null + vi.unstubAllEnvs() vi.restoreAllMocks() }) @@ -509,6 +510,38 @@ describe('skill discovery', () => { }) }) + it('discovers Hermes Skills under a relocated HERMES_HOME profile', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const hermesHome = join(root, 'profiles', 'coder') + const skillDir = join(hermesHome, 'skills', 'research') + await mkdir(skillDir, { recursive: true }) + await writeFile( + join(skillDir, 'SKILL.md'), + ['---', 'name: profile-research', 'description: Research sources.', '---', ''].join('\n') + ) + vi.stubEnv('HERMES_HOME', hermesHome) + + const result = await discoverSkills({ + homeDir: home, + cwd: join(root, 'missing-cwd') + }) + + expect(result.skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'profile-research', + sourceLabel: 'Hermes home', + directoryPath: skillDir + }) + ]) + ) + expect(result.sources.find((source) => source.id === 'home-hermes')).toMatchObject({ + path: join(hermesHome, 'skills'), + exists: true + }) + }) + it('discovers worktree .agents skill symlinks from the requested cwd', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index 929d4cdeace..638c7c46b5b 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -11,7 +11,10 @@ import type { AgentType } from '../../shared/agent-status-types' import type { Repo } from '../../shared/repo-types' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { SkillProviderRootOverrides } from './skill-provider-destinations' -import { resolveEnvironmentSkillProviderRoots } from './skill-provider-runtime-roots' +import { + resolveEnvironmentHermesSkillsRoot, + resolveEnvironmentSkillProviderRoots +} from './skill-provider-runtime-roots' export type SkillScanRoot = Omit type SkillDiscoveryPathApi = Pick @@ -77,6 +80,12 @@ export function buildSkillDiscoverySources( const cwd = args.cwd ?? process.cwd() const providerRootOverrides = args.providerRootOverrides ?? (args.pathApi ? {} : resolveEnvironmentSkillProviderRoots()) + // Why: HERMES_HOME moves the whole profile tree, so the default home path + // finds nothing for `hermes -p `. Only this process's own host can + // read it — a custom pathApi means the home belongs to another host. + const hermesSkillsRoot = + (args.pathApi ? null : resolveEnvironmentHermesSkillsRoot()) ?? + pathApi.join(home, '.hermes', 'skills') const roots: SkillScanRoot[] = [ source( 'home-codex', @@ -144,14 +153,7 @@ export function buildSkillDiscoverySources( ['agent-skills'], 'omp' ), - source( - 'home-hermes', - 'Hermes home', - pathApi.join(home, '.hermes', 'skills'), - 'home', - ['agent-skills'], - 'hermes' - ), + source('home-hermes', 'Hermes home', hermesSkillsRoot, 'home', ['agent-skills'], 'hermes'), source( 'home-prime-agent', 'Prime Agent home', diff --git a/src/main/skills/skill-provider-runtime-roots.test.ts b/src/main/skills/skill-provider-runtime-roots.test.ts index 07d9d422dac..38a1f2e047e 100644 --- a/src/main/skills/skill-provider-runtime-roots.test.ts +++ b/src/main/skills/skill-provider-runtime-roots.test.ts @@ -1,6 +1,7 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { + resolveEnvironmentHermesSkillsRoot, resolveEnvironmentSkillProviderRoots, resolveWslGrokSkillProviderRoot, withClaudeSkillProviderRoot @@ -30,6 +31,14 @@ describe('skill provider runtime roots', () => { }) }) + it('maps a relocated HERMES_HOME to its skill root and ignores relative ones', () => { + expect(resolveEnvironmentHermesSkillsRoot({ HERMES_HOME: join('/srv', 'hermes') })).toBe( + join('/srv', 'hermes', 'skills') + ) + expect(resolveEnvironmentHermesSkillsRoot({ HERMES_HOME: '../hermes' })).toBeNull() + expect(resolveEnvironmentHermesSkillsRoot({})).toBeNull() + }) + it('maps the WSL login shell GROK_HOME to a host-readable skill root', async () => { await expect( resolveWslGrokSkillProviderRoot('Ubuntu-24.04', async () => '/srv/grok\n') diff --git a/src/main/skills/skill-provider-runtime-roots.ts b/src/main/skills/skill-provider-runtime-roots.ts index 8f2a4db2196..16c0bab941e 100644 --- a/src/main/skills/skill-provider-runtime-roots.ts +++ b/src/main/skills/skill-provider-runtime-roots.ts @@ -37,6 +37,16 @@ export function resolveEnvironmentSkillProviderRoots( } } +// Why: `HERMES_HOME` relocates a whole Hermes profile tree (`hermes -p coder`), +// and the rest of Orca already treats it as authoritative. Hermes is not an +// install provider, so it stays out of `SkillProviderRootOverrides`. +export function resolveEnvironmentHermesSkillsRoot( + env: NodeJS.ProcessEnv = process.env +): string | null { + const hermesHome = normalizedRoot(env.HERMES_HOME) + return hermesHome ? join(hermesHome, 'skills') : null +} + export function withClaudeSkillProviderRoot( roots: SkillProviderRootOverrides, claudeConfigDirectory: string | null | undefined