From 29fe5b7dafcf5942f367f7ac656f26fc26a91bee Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 19 May 2026 11:37:23 +0200 Subject: [PATCH] feat(skills): add hermes-agent as a third skill-profile target Extends PR #35 (Specialize authmux launches by skill profile) so a Soul skill profile can target the local hermes-agent skills tree in addition to codex and claude. - SkillAgent gains "hermes"; isSkillAgent narrows third-party strings. - resolveDefaultSkillTarget("hermes") maps to AUTHMUX_HERMES_HOME or HERMES_AGENT_HOME or ~/Documents/hermes-agent, plus "/skills". - activateSkillProfile auto-fills the target when agent is hermes so the shell activator only needs --target. Soul activate-profile.sh keeps its existing case for codex|claude; agent=hermes works because we always pass --target and the activator only branches on agent for the default target. - authmux skills --agent gains "hermes". - Tests cover isSkillAgent, resolveDefaultSkillTarget, and an end-to-end hermes activation against a tmp target. Out of scope (deferred): bundling MCP servers alongside skills in the same profile. Soul's install-codex-mcps.py / install-claude-mcps.py write a full placeholder block without profile filtering, so the MCP side needs a soul-side design pass before authmux can wire it in. --- src/commands/skills.ts | 2 +- src/lib/skills/profile.ts | 25 +++++++++++++---- src/tests/skills-profile.test.ts | 48 ++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/src/commands/skills.ts b/src/commands/skills.ts index 1dfcfd7..6aac406 100644 --- a/src/commands/skills.ts +++ b/src/commands/skills.ts @@ -32,7 +32,7 @@ export default class SkillsCommand extends BaseCommand { }), agent: Flags.string({ description: "Agent skill target", - options: ["codex", "claude"], + options: ["codex", "claude", "hermes"], default: "codex", }), target: Flags.string({ diff --git a/src/lib/skills/profile.ts b/src/lib/skills/profile.ts index b972644..682ee9c 100644 --- a/src/lib/skills/profile.ts +++ b/src/lib/skills/profile.ts @@ -3,9 +3,23 @@ import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; -export type SkillAgent = "codex" | "claude"; +export type SkillAgent = "codex" | "claude" | "hermes"; export type SkillProfileSource = "env" | "account" | "default"; +const SKILL_AGENTS: readonly SkillAgent[] = ["codex", "claude", "hermes"]; + +export function isSkillAgent(value: string): value is SkillAgent { + return (SKILL_AGENTS as readonly string[]).includes(value); +} + +export function resolveDefaultSkillTarget(agent: SkillAgent): string | undefined { + if (agent === "hermes") { + const root = process.env.AUTHMUX_HERMES_HOME || process.env.HERMES_AGENT_HOME || "~/Documents/hermes-agent"; + return path.join(resolvePath(root), "skills"); + } + return undefined; +} + export interface ResolvedSkillProfile { profile: string; source: SkillProfileSource; @@ -77,13 +91,14 @@ export function activateSkillProfile(input: { }): SkillProfileActivation { const profile = normalizeSkillProfileName(input.profile); const agent = input.agent ?? "codex"; + const target = input.target ?? resolveDefaultSkillTarget(agent); const activator = resolveSoulSkillActivator(); if (!fs.existsSync(activator)) { return { activated: false, profile, agent, - target: input.target, + target, reason: `missing activator: ${activator}`, stdout: "", stderr: "", @@ -91,8 +106,8 @@ export function activateSkillProfile(input: { } const args = ["--profile", profile, "--agent", agent]; - if (input.target) { - args.push("--target", input.target); + if (target) { + args.push("--target", target); } const result = spawnSync(activator, args, { @@ -114,7 +129,7 @@ export function activateSkillProfile(input: { activated: true, profile, agent, - target: input.target ?? targetMatch?.[1], + target: target ?? targetMatch?.[1], skillCount: countMatch ? Number.parseInt(countMatch[1], 10) : undefined, stdout, stderr, diff --git a/src/tests/skills-profile.test.ts b/src/tests/skills-profile.test.ts index e2e71a3..238da32 100644 --- a/src/tests/skills-profile.test.ts +++ b/src/tests/skills-profile.test.ts @@ -6,8 +6,10 @@ import path from "node:path"; import { activateSkillProfile, + isSkillAgent, listAvailableSkillProfiles, normalizeSkillProfileName, + resolveDefaultSkillTarget, } from "../lib/skills/profile"; test("normalizeSkillProfileName accepts simple profile names", () => { @@ -41,3 +43,49 @@ test("activateSkillProfile delegates to the Soul activator", async (t) => { assert.equal(result.profile, "base"); assert.equal(result.skillCount, 10); }); + +test("isSkillAgent narrows to known agents", () => { + assert.equal(isSkillAgent("codex"), true); + assert.equal(isSkillAgent("claude"), true); + assert.equal(isSkillAgent("hermes"), true); + assert.equal(isSkillAgent("kiro"), false); + assert.equal(isSkillAgent(""), false); +}); + +test("resolveDefaultSkillTarget points hermes at hermes-agent/skills", () => { + const previous = process.env.AUTHMUX_HERMES_HOME; + process.env.AUTHMUX_HERMES_HOME = "/tmp/authmux-hermes-fixture"; + try { + assert.equal( + resolveDefaultSkillTarget("hermes"), + path.join("/tmp/authmux-hermes-fixture", "skills"), + ); + } finally { + if (previous === undefined) delete process.env.AUTHMUX_HERMES_HOME; + else process.env.AUTHMUX_HERMES_HOME = previous; + } +}); + +test("resolveDefaultSkillTarget returns undefined for codex and claude", () => { + assert.equal(resolveDefaultSkillTarget("codex"), undefined); + assert.equal(resolveDefaultSkillTarget("claude"), undefined); +}); + +test("activateSkillProfile fills hermes target from env when not given", async (t) => { + const targetRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "authmux-skills-hermes-")); + t.after(async () => { + await fsp.rm(targetRoot, { recursive: true, force: true }); + }); + const previous = process.env.AUTHMUX_HERMES_HOME; + process.env.AUTHMUX_HERMES_HOME = targetRoot; + try { + const result = activateSkillProfile({ profile: "base", agent: "hermes" }); + assert.equal(result.activated, true); + assert.equal(result.agent, "hermes"); + assert.equal(result.target, path.join(targetRoot, "skills")); + assert.equal(result.skillCount, 10); + } finally { + if (previous === undefined) delete process.env.AUTHMUX_HERMES_HOME; + else process.env.AUTHMUX_HERMES_HOME = previous; + } +});