From f970b886aace516383caaa6d974f9df63faff340 Mon Sep 17 00:00:00 2001 From: zayd / cold Date: Sat, 10 Oct 2026 01:03:25 -0700 Subject: [PATCH] feat: add Hermes as a supported MCP client (#1403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Hermes as a supported MCP client Hermes Agent reads stdio MCP servers from `mcp_servers.` in `config.yaml`. REA had no Hermes client, so setup could not register it and Hermes users had to wire the server up by hand. - SupportedClients: add the `hermes` format and client definition, resolving the config path from HERMES_HOME, else the platform default that Hermes's own resolver uses (LOCALAPPDATA/hermes on Windows, ~/.hermes elsewhere), including HERMES_DATA_DIR_SUFFIX. - ClientConfigurationDocument: parse and serialize Hermes YAML, upserting the rea entry into the parsed document so user comments and unrelated settings are preserved; emit `enabled: true` since Hermes connects entries unless disabled. - ClientRegistrationStatus: forward the Hermes location variables so status checks resolve the same path as setup. - Tests: cover path resolution on both platforms and the register/read/uninstall round trip, and clear the Hermes location variables between tests. - delilah 🌸 * docs: document the Hermes client and its config path - delilah 🌸 * refactor(setup): resolve skill destinations per client * fix(setup): install Hermes skills in the active profile * fix(setup): identify each client skill action uniquely --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/installation.md | 12 ++ package-lock.json | 4 +- package.json | 6 +- scripts/verify-package-install.mjs | 11 +- .../ClientConfigurationDocument.ts | 47 +++++ src/application/ClientRegistrationStatus.ts | 18 +- src/application/Doctor.ts | 4 +- src/application/Setup.fixture.ts | 5 +- src/application/SetupHost.ts | 14 +- src/application/SetupPlan.ts | 17 +- src/application/SetupSkill.ts | 71 +++----- src/application/SetupTypes.ts | 3 +- src/application/SupportedClients.ts | 132 ++++++++++---- src/application/Uninstall.ts | 23 ++- tests/boundary/cli/hermesClientSetup.test.ts | 98 +++++++++++ .../filesystem/clientLifecycle.test.ts | 1 + .../hermesClientConfiguration.test.ts | 165 ++++++++++++++++++ tests/boundary/filesystem/setupSkill.test.ts | 19 +- tests/fixtures/clientEnvironment.ts | 3 + 19 files changed, 512 insertions(+), 141 deletions(-) create mode 100644 tests/boundary/cli/hermesClientSetup.test.ts create mode 100644 tests/boundary/filesystem/hermesClientConfiguration.test.ts diff --git a/docs/installation.md b/docs/installation.md index acc285e69..7afefd07f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -139,6 +139,7 @@ listed after the table because its connector is not one of these files: | VS Code | `vscode` | | Grok Build | `grok_build` | | OMP | `omp` | +| Hermes | `hermes` | For Qwen Code, setup registers `rea` in the `mcpServers` table of the user-level `~/.qwen/settings.json`, or `$QWEN_HOME/settings.json` when configured. @@ -155,6 +156,17 @@ registration. Doctor treats an `enabled: false` entry as active when `enabledServers` lists `rea`, as OMP does, unless `disabledServers` also lists it. Run setup under each profile that should load REA. +For Hermes, setup writes a `mcp_servers.rea` entry to `config.yaml`, preserving +comments and unrelated settings. It resolves that file from `HERMES_HOME`, else +from the platform default Hermes itself uses — `%LOCALAPPDATA%\hermes` on +Windows, `~/.hermes` elsewhere — including any `HERMES_DATA_DIR_SUFFIX`. Hermes +connects an entry unless `enabled` is false, so setup states `enabled: true`. + +Hermes personal skills install under the active Hermes home's `skills` directory. +REA follows `HERMES_HOME` whitespace trimming, environment-variable expansion, +and home expansion before resolving that profile. Setup previews the resolved +configuration and skill destinations; doctor and uninstall use those same paths. + For OpenCode, setup writes the V1 `mcp.rea` entry, which OpenCode V1 and V2 both load. If the configuration already uses OpenCode V2's native `mcp.servers` table, setup registers REA there instead and replaces any earlier diff --git a/package-lock.json b/package-lock.json index e9bc42c5d..4b668f798 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "stream-json": "3.7.0", "write-file-atomic": "7.0.0", "ws": "8.21.0", + "yaml": "2.9.0", "zod": "4.4.3" }, "bin": { @@ -64,8 +65,7 @@ "turbo": "2.11.7", "typescript": "7.0.2", "vitepress": "1.6.4", - "vitest": "5.0.3", - "yaml": "2.9.0" + "vitest": "5.0.3" }, "engines": { "node": "^22.19.0 || ^24.11.0 || >=26.0.0" diff --git a/package.json b/package.json index 69cc9232b..439746c70 100644 --- a/package.json +++ b/package.json @@ -269,7 +269,8 @@ "write-file-atomic": "7.0.0", "ws": "8.21.0", "zod": "4.4.3", - "evmole": "0.9.3" + "evmole": "0.9.3", + "yaml": "2.9.0" }, "overrides": { "vitepress": { @@ -293,7 +294,6 @@ "turbo": "2.11.7", "typescript": "7.0.2", "vitepress": "1.6.4", - "vitest": "5.0.3", - "yaml": "2.9.0" + "vitest": "5.0.3" } } diff --git a/scripts/verify-package-install.mjs b/scripts/verify-package-install.mjs index 544ff9300..722549e5d 100644 --- a/scripts/verify-package-install.mjs +++ b/scripts/verify-package-install.mjs @@ -13,7 +13,16 @@ export async function verifyPackageInstall({ }) { await exec( "npm", - ["install", "--global", "--ignore-scripts", "--prefix", prefix, tarball], + [ + "install", + "--global", + "--ignore-scripts", + "--omit=dev", + "--install-strategy=nested", + "--prefix", + prefix, + tarball, + ], { env: environment }, ); const cli = join(prefix, "bin", "rea"); diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index 5ae6ad98c..34e701925 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -1,4 +1,9 @@ import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; +import { + parse as parseYaml, + parseDocument as parseYamlDocument, + stringify as stringifyYaml, +} from "yaml"; import { isDeepStrictEqual } from "node:util"; import { applyEdits, @@ -70,6 +75,7 @@ export const clientConfigurationServersKey = ( switch (format) { case "toml": case "grok": + case "hermes": return "mcp_servers"; case "opencode": return "mcp"; @@ -93,6 +99,8 @@ const parseDocument = ( ): Record => { if (format === "toml" || format === "grok") return objectSchema.parse(parseToml(text)); + // Hermes stores MCP servers in `mcp_servers` inside a YAML document. + if (format === "hermes") return objectSchema.parse(parseYaml(text)); // An empty file, which some clients create before any server is added, // holds no settings to preserve. if (isEmptyClientConfigurationText(text)) return {}; @@ -1265,6 +1273,8 @@ export const serializeClientConfiguration = ( return serializeCodexConfiguration(document, originalText); if (format === "grok") return serializeGrokConfiguration(document, originalText); + if (format === "hermes") + return serializeHermesConfiguration(document, originalText); // An empty original holds no comments or settings to preserve. Writing a // fresh document also keeps a lone BOM from shifting past the closing brace. if ( @@ -1303,6 +1313,34 @@ export const legacyClientServerPath = ( ? [parsed.serversPath[0] ?? "mcp", name] : undefined; +/** + * Keep an existing Hermes `config.yaml` intact aside from the REA server entry. + * Hermes config files carry user comments and unrelated settings, so the rea + * entry is upserted into the parsed YAML document (which retains formatting and + * comments) rather than re-serializing the whole object. + */ +const serializeHermesConfiguration = ( + document: Record, + originalText: string | undefined, +): string => { + if ( + originalText === undefined || + isEmptyClientConfigurationText(originalText) + ) + return stringifyYaml(document); + const serversKey = clientConfigurationServersKey("hermes"); + const servers = document[serversKey]; + const entry = + typeof servers === "object" && servers !== null + ? (servers as Record)[PRODUCT_IDENTITY.mcpServerKey] + : undefined; + const yamlDocument = parseYamlDocument(originalText); + if (entry === undefined) + yamlDocument.deleteIn([serversKey, PRODUCT_IDENTITY.mcpServerKey]); + else yamlDocument.setIn([serversKey, PRODUCT_IDENTITY.mcpServerKey], entry); + return yamlDocument.toString(); +}; + /** Build the stdio entry shape expected by one client's configuration dialect. */ export const clientRegistrationEntry = ( format: ClientRegistrationDialect, @@ -1353,6 +1391,15 @@ export const clientRegistrationEntry = ( args, ...(Object.keys(environment).length === 0 ? {} : { env: environment }), }; + case "hermes": + // Hermes reads stdio servers from `mcp_servers.` and connects them + // unless `enabled` is false, so the entry states it explicitly. + return { + command: executable, + args, + ...(Object.keys(environment).length === 0 ? {} : { env: environment }), + enabled: true, + }; default: return { command: executable, diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 1f5a12473..2151b00f3 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -78,23 +78,7 @@ export const readClientRegistrationStatuses = async ( for (const client of supportedClients( home, options.platform, - options.environment === undefined - ? undefined - : { - APPDATA: options.environment.APPDATA, - CLAUDE_CONFIG_DIR: options.environment.CLAUDE_CONFIG_DIR, - CODEX_HOME: options.environment.CODEX_HOME, - COPILOT_HOME: options.environment.COPILOT_HOME, - GROK_HOME: options.environment.GROK_HOME, - OMP_PROFILE: options.environment.OMP_PROFILE, - OPENCODE_CONFIG: options.environment.OPENCODE_CONFIG, - PI_CODING_AGENT_DIR: options.environment.PI_CODING_AGENT_DIR, - PI_CONFIG_DIR: options.environment.PI_CONFIG_DIR, - PI_PROFILE: options.environment.PI_PROFILE, - QWEN_HOME: options.environment.QWEN_HOME, - SAND_DATA_ROOT: options.environment.SAND_DATA_ROOT, - XDG_CONFIG_HOME: options.environment.XDG_CONFIG_HOME, - }, + options.environment, )) { if ( !(await exists(client.markerPath)) && diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 88dfda4c8..cf6fd2a26 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -16,7 +16,6 @@ import { type LinuxDistribution, } from "./LinuxHopper.js"; import { - claudeCodeSkillsDirectory, readInstalledSkillIdentity, type InstalledSkillIdentity, } from "./SetupSkill.js"; @@ -441,7 +440,8 @@ export const systemDoctorHost = ( readInstalledSkillIdentity( homeDirectory, clientIds, - claudeCodeSkillsDirectory(homeDirectory, environment), + environment, + platform, ), clientRegistrations: () => readClientRegistrationStatuses(homeDirectory, undefined, { diff --git a/src/application/Setup.fixture.ts b/src/application/Setup.fixture.ts index b15b7940e..bf9a97810 100644 --- a/src/application/Setup.fixture.ts +++ b/src/application/Setup.fixture.ts @@ -1,3 +1,4 @@ +import { skillDestinations } from "./SetupSkill.js"; import { type ClientConfigurationInspection, type ClientConfigurationResult, @@ -18,7 +19,9 @@ import { setupRegistrationCommand } from "./SetupHost.js"; export class FakeSetupHost implements SetupHost { readonly platform: NodeJS.Platform; readonly homeDirectory = "/fixture/home"; - readonly claudeCodeSkillsDirectory = "/fixture/home/.claude/skills"; + skillDestinations(clientIds: readonly string[]) { + return skillDestinations(this.homeDirectory, clientIds); + } readonly registrationCommand: readonly string[]; nodeVersion = "24.18.0"; version: string | undefined = "14.5"; diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 068b8c012..9941fcf0b 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -16,7 +16,7 @@ import { import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js"; import { supportedClients, type SetupClient } from "./SupportedClients.js"; import { - claudeCodeSkillsDirectory as resolveClaudeCodeSkillsDirectory, + skillDestinations, canonicalSkillNeedsInstall, installCanonicalSkill, } from "./SetupSkill.js"; @@ -90,14 +90,11 @@ export const systemSetupHost = ( const doctorHost = selectedDoctorHost ?? systemDoctorHost({ environment }); const platform = doctorHost.platform; const { homeDirectory } = doctorHost; - const claudeSkillsDirectory = resolveClaudeCodeSkillsDirectory( - homeDirectory, - environment, - ); return { platform, homeDirectory, - claudeCodeSkillsDirectory: claudeSkillsDirectory, + skillDestinations: (clientIds) => + skillDestinations(homeDirectory, clientIds, environment, platform), registrationCommand: setupRegistrationCommand( platform, environment.npm_command === "exec", @@ -153,10 +150,11 @@ export const systemSetupHost = ( canonicalSkillNeedsInstall( homeDirectory, clientIds, - claudeSkillsDirectory, + environment, + platform, ), installSkill: (clientIds) => - installCanonicalSkill(homeDirectory, clientIds, claudeSkillsDirectory), + installCanonicalSkill(homeDirectory, clientIds, environment, platform), doctor: (scope) => runDoctor(undefined, doctorHost, scope), }; }; diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index fc0d978a3..20f1e77a2 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -5,7 +5,7 @@ import { linuxHopperInstallDisclosure, type LinuxPackageFamily, } from "./LinuxHopper.js"; -import { skillDestinations } from "./SetupSkill.js"; +import type { SkillDestination } from "./SetupSkill.js"; import { macHopperInstallDisclosure } from "./MacHopper.js"; import type { SetupAction, @@ -188,7 +188,7 @@ export const planSetupActions = async (input: { installHopper: input.discovery.installHopper, installSkill: input.installSkill, skillClientIds: input.skillClientIds, - claudeCodeSkillsDirectory: input.host.claudeCodeSkillsDirectory, + skillDestinations: input.host.skillDestinations(input.skillClientIds), clients: clientPlans, providerEnvironment: input.providerEnvironment, ...(input.discovery.linuxPackageFamily === undefined @@ -210,7 +210,7 @@ const setupPlan = (input: { readonly installHopper: boolean; readonly installSkill: boolean; readonly skillClientIds: readonly string[]; - readonly claudeCodeSkillsDirectory: string; + readonly skillDestinations: readonly SkillDestination[]; readonly clients: readonly { readonly client: SetupClient; readonly operation: "create" | "update"; @@ -268,15 +268,8 @@ const setupPlan = (input: { ...(backupPath === undefined ? {} : { backupPath }), })), ...(input.installSkill - ? skillDestinations( - input.homeDirectory, - input.skillClientIds, - input.claudeCodeSkillsDirectory, - ).map(({ client, path }): SetupAction => ({ - id: - client === "claude_code" - ? "install_skill:claude_code" - : "install_skill", + ? input.skillDestinations.map(({ client, path }): SetupAction => ({ + id: client === "shared" ? "install_skill" : `install_skill:${client}`, kind: "install_skill", label: client === "claude_code" diff --git a/src/application/SetupSkill.ts b/src/application/SetupSkill.ts index 19a306f9a..70924d28f 100644 --- a/src/application/SetupSkill.ts +++ b/src/application/SetupSkill.ts @@ -3,6 +3,7 @@ import { dirname, join } from "node:path"; import writeFileAtomic from "write-file-atomic"; import { PRODUCT_IDENTITY } from "../identity.js"; +import { clientSkillDirectories } from "./SupportedClients.js"; const SKILL_FILES = [ "SKILL.md", @@ -21,7 +22,7 @@ interface CanonicalSkillFile { /** Managed skill location selected for a client family. */ export interface SkillDestination { - readonly client: "shared" | "claude_code"; + readonly client: string; readonly path: string; } @@ -32,44 +33,19 @@ export interface InstalledSkillIdentity { readonly canonical: boolean; } -/** Resolve Claude Code personal skills from the selected environment. */ -export const claudeCodeSkillsDirectory = ( - home: string, - environment: { readonly CLAUDE_CONFIG_DIR?: string } = {}, -): string => - join(environment.CLAUDE_CONFIG_DIR ?? join(home, ".claude"), "skills"); - /** Resolve the skill roots required by a client selection, or all owned roots. */ export const skillDestinations = ( home: string, clientIds: readonly string[] | undefined, - claudeSkillsDirectory = claudeCodeSkillsDirectory(home), -): readonly SkillDestination[] => { - const includeClaude = - clientIds === undefined || clientIds.includes("claude_code"); - const includeShared = - clientIds === undefined || - clientIds.length === 0 || - clientIds.some((clientId) => clientId !== "claude_code"); - return [ - ...(includeShared - ? [ - { - client: "shared" as const, - path: join(home, ".agents/skills", PRODUCT_IDENTITY.skillName), - }, - ] - : []), - ...(includeClaude - ? [ - { - client: "claude_code" as const, - path: join(claudeSkillsDirectory, PRODUCT_IDENTITY.skillName), - }, - ] - : []), - ]; -}; + environment: Readonly = {}, + platform: NodeJS.Platform = process.platform, +): readonly SkillDestination[] => + clientSkillDirectories(home, clientIds, environment, platform).map( + ({ client, directory }) => ({ + client, + path: join(directory, PRODUCT_IDENTITY.skillName), + }), + ); const readOptionalText = async (path: string): Promise => { try { @@ -84,10 +60,11 @@ const readOptionalText = async (path: string): Promise => { const canonicalSkillFiles = async ( home: string, clientIds: readonly string[], - claudeSkillsDirectory?: string, + environment: Readonly = {}, + platform: NodeJS.Platform = process.platform, ): Promise => Promise.all( - skillDestinations(home, clientIds, claudeSkillsDirectory).flatMap( + skillDestinations(home, clientIds, environment, platform).flatMap( ({ path: root }) => SKILL_FILES.map(async (relativePath) => { const destination = join(root, relativePath); @@ -110,11 +87,12 @@ const canonicalSkillFiles = async ( export const canonicalSkillNeedsInstall = async ( home: string, clientIds: readonly string[] = [], - claudeSkillsDirectory?: string, + environment: Readonly = {}, + platform: NodeJS.Platform = process.platform, ): Promise => { try { return ( - await canonicalSkillFiles(home, clientIds, claudeSkillsDirectory) + await canonicalSkillFiles(home, clientIds, environment, platform) ).some(({ content, original }) => original !== content); } catch (cause: unknown) { // Unreadable skill state fails open to install so setup can repair it. @@ -139,14 +117,16 @@ const restoreSkillFiles = async ( export const installCanonicalSkill = async ( home: string, clientIds: readonly string[] = [], - claudeSkillsDirectory?: string, + environment: Readonly = {}, + platform: NodeJS.Platform = process.platform, ): Promise<"installed" | "unchanged" | "failed"> => { let changed: readonly CanonicalSkillFile[] = []; try { const canonical = await canonicalSkillFiles( home, clientIds, - claudeSkillsDirectory, + environment, + platform, ); changed = canonical.filter(({ content, original }) => original !== content); if (changed.length === 0) return "unchanged"; @@ -179,12 +159,14 @@ export const installCanonicalSkill = async ( export const readInstalledSkillIdentity = async ( home: string, clientIds: readonly string[], - claudeSkillsDirectory?: string, + environment: Readonly = {}, + platform: NodeJS.Platform = process.platform, ): Promise => { const destinations = skillDestinations( home, clientIds, - claudeSkillsDirectory, + environment, + platform, ); let content: string | undefined; for (const destination of destinations) { @@ -202,7 +184,8 @@ export const readInstalledSkillIdentity = async ( canonical: !(await canonicalSkillNeedsInstall( home, clientIds, - claudeSkillsDirectory, + environment, + platform, )), version: /^\s{2}version:\s*"([^"]+)"\s*$/mu.exec(content)?.[1] ?? null, toolCount: toolCount === undefined ? null : Number.parseInt(toolCount, 10), diff --git a/src/application/SetupTypes.ts b/src/application/SetupTypes.ts index 08eb0df99..8a1b99961 100644 --- a/src/application/SetupTypes.ts +++ b/src/application/SetupTypes.ts @@ -1,5 +1,6 @@ import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js"; import type { LinuxDistribution } from "./LinuxHopper.js"; +import type { SkillDestination } from "./SetupSkill.js"; import type { SetupClient } from "./SupportedClients.js"; import type { SetupFailureCode, @@ -42,7 +43,7 @@ export type ClientConfigurationInspection = export interface SetupHost { readonly platform: NodeJS.Platform; readonly homeDirectory: string; - readonly claudeCodeSkillsDirectory: string; + skillDestinations(clientIds: readonly string[]): readonly SkillDestination[]; readonly registrationCommand: readonly string[]; readonly nodeVersion: string; macosVersion(): Promise; diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 52f5577ca..a6bd9914a 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -16,6 +16,7 @@ export interface SetupClient { | "commandcode" | "grok" | "omp" + | "hermes" | "unsupported"; } @@ -24,21 +25,7 @@ type ClientPath = readonly string[] | ((paths: ClientPathContext) => string); interface ClientPathContext { readonly home: string; readonly platform: NodeJS.Platform; - readonly env: { - readonly APPDATA?: string | undefined; - readonly CLAUDE_CONFIG_DIR?: string | undefined; - readonly CODEX_HOME?: string | undefined; - readonly COPILOT_HOME?: string | undefined; - readonly GROK_HOME?: string | undefined; - readonly OMP_PROFILE?: string | undefined; - readonly OPENCODE_CONFIG?: string | undefined; - readonly PI_CODING_AGENT_DIR?: string | undefined; - readonly PI_CONFIG_DIR?: string | undefined; - readonly PI_PROFILE?: string | undefined; - readonly QWEN_HOME?: string | undefined; - readonly SAND_DATA_ROOT?: string | undefined; - readonly XDG_CONFIG_HOME?: string | undefined; - }; + readonly env: Readonly; } interface ClientDefinition { @@ -47,6 +34,7 @@ interface ClientDefinition { readonly configPath: ClientPath; readonly markerPath: ClientPath; readonly format: NonNullable; + readonly skillPath?: ClientPath; } const vscodeUserDirectory = ({ @@ -108,6 +96,50 @@ const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => { const grokDirectory = ({ home, env }: ClientPathContext): string => env.GROK_HOME ?? join(home, ".grok"); +/** + * Hermes Agent home. `HERMES_HOME` wins; otherwise the platform default mirrors + * Hermes's own resolver (`%LOCALAPPDATA%\\hermes` on Windows, `~/.hermes` + * elsewhere), including the optional `HERMES_DATA_DIR_SUFFIX` that is appended + * to the literal `hermes`/`.hermes` directory name. + */ +const hermesDirectory = ({ + home, + platform, + env, +}: ClientPathContext): string => { + const override = env.HERMES_HOME?.trim(); + if (override) { + const expandedVariables = override.replace( + /\$(\w+|\{[^}]*\})/gu, + (match, variable: string) => { + const key = variable.startsWith("{") ? variable.slice(1, -1) : variable; + return env[key] ?? match; + }, + ); + const expanded = + platform === "win32" + ? expandedVariables.replace( + /%([^%]+)%/gu, + (match, variable: string) => env[variable] ?? match, + ) + : expandedVariables; + if (expanded === "~") return home; + if ( + expanded.startsWith("~/") || + (platform === "win32" && expanded.startsWith("~\\")) + ) + return join(home, expanded.slice(2)); + return resolve(expanded); + } + const suffix = env.HERMES_DATA_DIR_SUFFIX ?? ""; + return platform === "win32" + ? join( + env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local"), + `hermes${suffix}`, + ) + : join(home, `.hermes${suffix}`); +}; + /** Grok Bot uses an absolute SAND_DATA_ROOT; anything else stays ~/.grokbot. */ const grokBotDirectory = ({ home, env }: ClientPathContext): string => { const root = env.SAND_DATA_ROOT; @@ -212,6 +244,8 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ { name: "claude_code", displayName: "Claude Code", + skillPath: ({ home, env }: ClientPathContext) => + join(env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"), "skills"), configPath: (context: ClientPathContext) => join(claudeCodeConfigDirectory(context), ".claude.json"), markerPath: claudeCodeMarkerDirectory, @@ -324,6 +358,16 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ markerPath: ompAgentDirectory, format: "omp", }, + { + name: "hermes", + displayName: "Hermes", + skillPath: (context: ClientPathContext) => + join(hermesDirectory(context), "skills"), + configPath: (context: ClientPathContext) => + join(hermesDirectory(context), "config.yaml"), + markerPath: hermesDirectory, + format: "hermes", + }, { name: "grok_bot", displayName: "Grok Bot", @@ -340,21 +384,7 @@ const resolvePath = (path: ClientPath, context: ClientPathContext): string => export const supportedClients = ( home: string, platform: NodeJS.Platform = process.platform, - env: ClientPathContext["env"] = { - APPDATA: process.env.APPDATA, - CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, - CODEX_HOME: process.env.CODEX_HOME, - COPILOT_HOME: process.env.COPILOT_HOME, - GROK_HOME: process.env.GROK_HOME, - OMP_PROFILE: process.env.OMP_PROFILE, - OPENCODE_CONFIG: process.env.OPENCODE_CONFIG, - PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR, - PI_CONFIG_DIR: process.env.PI_CONFIG_DIR, - PI_PROFILE: process.env.PI_PROFILE, - QWEN_HOME: process.env.QWEN_HOME, - SAND_DATA_ROOT: process.env.SAND_DATA_ROOT, - XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, - }, + env: ClientPathContext["env"] = process.env, ): readonly SetupClient[] => { const context = { home, platform, env }; return SUPPORTED_CLIENT_DEFINITIONS.map((definition) => ({ @@ -365,3 +395,45 @@ export const supportedClients = ( format: definition.format, })); }; + +/** Resolve personal skill roots using the same client environment as setup. */ +export const clientSkillDirectories = ( + home: string, + clientIds: readonly string[] | undefined, + environment: Readonly = {}, + platform: NodeJS.Platform = process.platform, +): readonly { readonly client: string; readonly directory: string }[] => { + const definitions: readonly ClientDefinition[] = SUPPORTED_CLIENT_DEFINITIONS; + const selected = + clientIds === undefined + ? [ + "shared", + ...definitions + .filter(({ skillPath }) => skillPath !== undefined) + .map(({ name }) => name), + ] + : clientIds.length === 0 + ? ["shared"] + : clientIds; + const destinations = new Map(); + for (const client of selected) { + const definition = definitions.find(({ name }) => name === client); + const directory = + definition?.skillPath === undefined + ? join(home, ".agents", "skills") + : resolvePath(definition.skillPath, { + home, + platform, + env: environment, + }); + if (!destinations.has(directory)) + destinations.set(directory, { + client: + directory === join(home, ".agents", "skills") ? "shared" : client, + directory, + }); + } + return [...destinations.values()].sort((left, right) => + left.directory.localeCompare(right.directory), + ); +}; diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index 9848e91da..433678ef0 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -15,7 +15,7 @@ import writeFileAtomic from "write-file-atomic"; import { z } from "zod"; import { PRODUCT_IDENTITY } from "../identity.js"; -import { claudeCodeSkillsDirectory, skillDestinations } from "./SetupSkill.js"; +import { skillDestinations } from "./SetupSkill.js"; import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; import { @@ -179,7 +179,8 @@ export const systemUninstallHost = ( : undefined; }, removeClient: (client) => removeClient(client, fileSystem), - removeSkill: () => removeManagedSkills(home, fileSystem, environment), + removeSkill: () => + removeManagedSkills(home, fileSystem, environment, platform), purgeData: async () => [ await removeManagedPath(join(home, ".rea/cache"), "cache", fileSystem), await removeManagedPath(join(home, ".rea/state"), "state", fileSystem), @@ -404,18 +405,16 @@ const removeManagedSkills = async ( home: string, fileSystem: UninstallFileSystem, environment: Readonly, + platform: NodeJS.Platform, ): Promise => { const results = await Promise.all( - skillDestinations( - home, - undefined, - claudeCodeSkillsDirectory(home, environment), - ).map(({ client, path }) => - removeManagedPath( - path, - client === "claude_code" ? "Claude Code skill" : "skill", - fileSystem, - ), + skillDestinations(home, undefined, environment, platform).map( + ({ client, path }) => + removeManagedPath( + path, + client === "claude_code" ? "Claude Code skill" : "skill", + fileSystem, + ), ), ); const failed = results.find(({ status }) => status === "failed"); diff --git a/tests/boundary/cli/hermesClientSetup.test.ts b/tests/boundary/cli/hermesClientSetup.test.ts new file mode 100644 index 000000000..bd65d735b --- /dev/null +++ b/tests/boundary/cli/hermesClientSetup.test.ts @@ -0,0 +1,98 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { parse } from "yaml"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "Hermes setup, doctor and uninstall use the expanded profile and its personal skill", + async ({ cli }) => { + const home = await createTestTempDirectory("rea-hermes-cli-"); + const profile = join(home, "profile"); + const config = join(profile, "config.yaml"); + const skill = join(profile, "skills/reverse-engineer-anything/SKILL.md"); + const original = + "# user settings\nmodel: custom\nmcp_servers:\n other:\n command: other-server\n"; + await mkdir(profile); + await writeFile(config, original); + const run = (arguments_: readonly string[]) => + cli.run({ + arguments: arguments_, + cwd: home, + environment: { + HOME: home, + USERPROFILE: home, + HERMES_HOME: " ${HOME}/profile ", + }, + timeoutMs: 20_000, + }); + const planned = await run([ + "setup", + "--client", + "hermes", + "--dry-run", + "--json", + ]); + expect(planned.exitCode).toBe(0); + expect(planned.json).toMatchObject({ + status: "planned", + plannedActions: expect.arrayContaining([ + expect.objectContaining({ target: config }), + expect.objectContaining({ + target: join(profile, "skills/reverse-engineer-anything"), + }), + ]), + }); + expect(await readFile(config, "utf8")).toBe(original); + await expect(access(skill)).rejects.toMatchObject({ code: "ENOENT" }); + const applied = await run([ + "setup", + "--client", + "hermes", + "--yes", + "--json", + ]); + expect(applied.exitCode).toBe(0); + expect(applied.json).toMatchObject({ status: "ready" }); + expect(parse(await readFile(config, "utf8"))).toMatchObject({ + model: "custom", + mcp_servers: { + other: { command: "other-server" }, + rea: { enabled: true }, + }, + }); + expect(await readFile(config + ".rea.backup", "utf8")).toBe(original); + expect(await readFile(skill, "utf8")).toContain( + "reverse-engineer-anything", + ); + await expect( + access(join(home, ".agents/skills/reverse-engineer-anything/SKILL.md")), + ).rejects.toMatchObject({ code: "ENOENT" }); + const doctor = await run([ + "doctor", + "--client", + "hermes", + "--skill", + "--json", + ]); + expect(doctor.exitCode).toBe(0); + expect(doctor.json).toMatchObject({ + healthy: true, + identity: { skill: { state: "aligned" } }, + }); + const repeat = await run([ + "setup", + "--client", + "hermes", + "--yes", + "--json", + ]); + expect(repeat.json).toMatchObject({ status: "ready", appliedActions: [] }); + const uninstall = await run(["uninstall", "--json"]); + expect(uninstall.exitCode).toBe(0); + expect(parse(await readFile(config, "utf8"))).toEqual(parse(original)); + await expect(access(skill)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 297787a10..72f1a7a13 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -63,6 +63,7 @@ describe("client configuration filesystem lifecycle", () => { "vscode", "grok_build", "omp", + "hermes", "grok_bot", ]); expect( diff --git a/tests/boundary/filesystem/hermesClientConfiguration.test.ts b/tests/boundary/filesystem/hermesClientConfiguration.test.ts new file mode 100644 index 000000000..e2f2cf74c --- /dev/null +++ b/tests/boundary/filesystem/hermesClientConfiguration.test.ts @@ -0,0 +1,165 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { parse as parseYaml } from "yaml"; + +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; + +/** + * The registration command setup writes on the host running the tests. Windows + * wraps it in `cmd.exe /d /c`, so the expected entry follows the host too. + */ +const command = npxRegistrationCommand(process.platform); + +beforeEach(clearClientLocationEnvironment); +afterEach(() => vi.unstubAllEnvs()); + +const hermesPath = ( + env: Parameters[2], + platform: NodeJS.Platform = "linux", +) => + supportedClients(join("/home/a"), platform, env).find( + ({ name }) => name === "hermes", + ); + +describe("Hermes configuration paths", () => { + it("resolves config.yaml under the platform Hermes home", () => { + expect(hermesPath({})).toEqual({ + name: "hermes", + displayName: "Hermes", + configPath: join("/home/a", ".hermes", "config.yaml"), + markerPath: join("/home/a", ".hermes"), + format: "hermes", + }); + // Windows uses the native data directory, matching Hermes's own resolver. + expect( + hermesPath({ LOCALAPPDATA: "C:\\Users\\a\\AppData\\Local" }, "win32") + ?.configPath, + ).toBe(join("C:\\Users\\a\\AppData\\Local", "hermes", "config.yaml")); + }); + + it("expands and trims Hermes overrides without changing the suffix", () => { + expect(hermesPath({ HERMES_HOME: " ~/profile " })?.markerPath).toBe( + join("/home/a", "profile"), + ); + expect( + hermesPath({ + HERMES_HOME: "${PROFILE_ROOT}/agent", + PROFILE_ROOT: "/custom", + })?.markerPath, + ).toBe(join("/custom", "agent")); + expect( + hermesPath({ + HERMES_HOME: "$PROFILE_ROOT/agent", + PROFILE_ROOT: "/custom", + })?.markerPath, + ).toBe(join("/custom", "agent")); + expect(hermesPath({ LOCALAPPDATA: " " }, "win32")?.markerPath).toBe( + join("/home/a", "AppData", "Local", "hermes"), + ); + }); + + it("honours HERMES_HOME and Hermes's data-directory suffix", () => { + expect( + hermesPath({ HERMES_HOME: join("/srv", "hermes") })?.configPath, + ).toBe(join("/srv", "hermes", "config.yaml")); + // A blank override falls through to the platform default. + expect(hermesPath({ HERMES_HOME: " " })?.configPath).toBe( + join("/home/a", ".hermes", "config.yaml"), + ); + // Hermes appends the suffix to the literal directory name. + expect(hermesPath({ HERMES_DATA_DIR_SUFFIX: "-dev" })?.configPath).toBe( + join("/home/a", ".hermes-dev", "config.yaml"), + ); + expect( + hermesPath( + { + HERMES_DATA_DIR_SUFFIX: "-dev", + LOCALAPPDATA: "C:\\Users\\a\\AppData\\Local", + }, + "win32", + )?.configPath, + ).toBe(join("C:\\Users\\a\\AppData\\Local", "hermes-dev", "config.yaml")); + }); +}); + +describe("Hermes YAML registration", () => { + const hermesClient = async () => { + const home = await createTestTempDirectory("rea-hermes-"); + const client = supportedClients(home, process.platform, {}).find( + ({ name }) => name === "hermes", + ); + if (client?.markerPath === undefined) + throw new Error("missing Hermes client"); + await mkdir(client.markerPath, { recursive: true }); + return { home, client }; + }; + + it("writes mcp_servers, preserves comments, reads back, and uninstalls", async () => { + const { home, client } = await hermesClient(); + await writeFile( + client.configPath, + `# Hermes configuration +display: + language: en +mcp_servers: + # An independently managed server. + other: + command: other-server +`, + ); + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + const text = await readFile(client.configPath, "utf8"); + expect(text).toContain("# Hermes configuration"); + expect(text).toContain("# An independently managed server."); + expect(parseYaml(text)).toEqual({ + display: { language: "en" }, + mcp_servers: { + other: { command: "other-server" }, + rea: { command: command[0], args: command.slice(1), enabled: true }, + }, + }); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect( + ( + await readClientRegistrationStatuses(home, undefined, { + platform: process.platform, + environment: {}, + }) + ).find(({ client: name }) => name === "hermes"), + ).toMatchObject({ state: "aligned", command }); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + expect(parseYaml(await readFile(client.configPath, "utf8"))).toEqual({ + display: { language: "en" }, + mcp_servers: { other: { command: "other-server" } }, + }); + }); + + it("creates config.yaml when Hermes has no configuration yet", async () => { + const { client } = await hermesClient(); + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + expect(parseYaml(await readFile(client.configPath, "utf8"))).toEqual({ + mcp_servers: { + rea: { command: command[0], args: command.slice(1), enabled: true }, + }, + }); + }); +}); diff --git a/tests/boundary/filesystem/setupSkill.test.ts b/tests/boundary/filesystem/setupSkill.test.ts index 0d1caa93a..40bbcfd82 100644 --- a/tests/boundary/filesystem/setupSkill.test.ts +++ b/tests/boundary/filesystem/setupSkill.test.ts @@ -13,7 +13,6 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { canonicalSkillNeedsInstall, - claudeCodeSkillsDirectory, installCanonicalSkill, } from "../../../src/application/SetupSkill.js"; import { @@ -56,9 +55,7 @@ describe("canonical skill transaction", () => { it("installs only the selected Claude Code personal skill", async () => { const home = await createTestTempDirectory("rea-claude-skill-"); const configDirectory = join(home, "claude-config"); - const skillsDirectory = claudeCodeSkillsDirectory(home, { - CLAUDE_CONFIG_DIR: configDirectory, - }); + const skillsDirectory = join(configDirectory, "skills"); const claudeSkill = join( skillsDirectory, "reverse-engineer-anything/SKILL.md", @@ -84,13 +81,19 @@ describe("canonical skill transaction", () => { }); expect( - await canonicalSkillNeedsInstall(home, ["claude_code"], skillsDirectory), + await canonicalSkillNeedsInstall(home, ["claude_code"], { + CLAUDE_CONFIG_DIR: dirname(skillsDirectory), + }), ).toBe(true); expect( - await installCanonicalSkill(home, ["claude_code"], skillsDirectory), + await installCanonicalSkill(home, ["claude_code"], { + CLAUDE_CONFIG_DIR: dirname(skillsDirectory), + }), ).toBe("installed"); expect( - await canonicalSkillNeedsInstall(home, ["claude_code"], skillsDirectory), + await canonicalSkillNeedsInstall(home, ["claude_code"], { + CLAUDE_CONFIG_DIR: dirname(skillsDirectory), + }), ).toBe(false); await expect(access(claudeSkill)).resolves.toBeUndefined(); await expect( @@ -112,7 +115,7 @@ describe("canonical skill transaction", () => { home, ".agents/skills/reverse-engineer-anything/SKILL.md", ); - const claudeSkills = claudeCodeSkillsDirectory(home); + const claudeSkills = join(home, ".claude", "skills"); const claudeSkill = join( claudeSkills, "reverse-engineer-anything/SKILL.md", diff --git a/tests/fixtures/clientEnvironment.ts b/tests/fixtures/clientEnvironment.ts index 0db5a69ec..bdc6d0102 100644 --- a/tests/fixtures/clientEnvironment.ts +++ b/tests/fixtures/clientEnvironment.ts @@ -17,6 +17,9 @@ export const CLIENT_LOCATION_ENVIRONMENT = [ "CODEX_HOME", "COPILOT_HOME", "GROK_HOME", + "HERMES_DATA_DIR_SUFFIX", + "HERMES_HOME", + "LOCALAPPDATA", "OMP_PROFILE", "OPENCODE_CONFIG", "PI_CODING_AGENT_DIR",