feat: add Hermes as a supported MCP client (#1403)

* feat: add Hermes as a supported MCP client

Hermes Agent reads stdio MCP servers from `mcp_servers.<name>` 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 <[email protected]>
This commit is contained in:
zayd / cold
2026-10-10 16:03:25 +08:00
committed by GitHub
co-authored by morluto
parent a0e5e9db2b
commit f970b886aa
19 changed files with 512 additions and 141 deletions
+12
View File
@@ -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
+2 -2
View File
@@ -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"
+3 -3
View File
@@ -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"
}
}
+10 -1
View File
@@ -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");
@@ -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<string, unknown> => {
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<string, unknown>,
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<string, unknown>)[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.<name>` 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,
+1 -17
View File
@@ -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)) &&
+2 -2
View File
@@ -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, {
+4 -1
View File
@@ -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";
+6 -8
View File
@@ -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),
};
};
+5 -12
View File
@@ -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"
+27 -44
View File
@@ -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<NodeJS.ProcessEnv> = {},
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<string | undefined> => {
try {
@@ -84,10 +60,11 @@ const readOptionalText = async (path: string): Promise<string | undefined> => {
const canonicalSkillFiles = async (
home: string,
clientIds: readonly string[],
claudeSkillsDirectory?: string,
environment: Readonly<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
): Promise<readonly CanonicalSkillFile[]> =>
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<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
): Promise<boolean> => {
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<NodeJS.ProcessEnv> = {},
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<NodeJS.ProcessEnv> = {},
platform: NodeJS.Platform = process.platform,
): Promise<InstalledSkillIdentity | undefined> => {
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),
+2 -1
View File
@@ -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<string | undefined>;
+102 -30
View File
@@ -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<NodeJS.ProcessEnv>;
}
interface ClientDefinition {
@@ -47,6 +34,7 @@ interface ClientDefinition {
readonly configPath: ClientPath;
readonly markerPath: ClientPath;
readonly format: NonNullable<SetupClient["format"]>;
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<NodeJS.ProcessEnv> = {},
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<string, { client: string; directory: string }>();
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),
);
};
+11 -12
View File
@@ -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<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
): Promise<UninstallItem> => {
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");
@@ -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" });
},
);
@@ -63,6 +63,7 @@ describe("client configuration filesystem lifecycle", () => {
"vscode",
"grok_build",
"omp",
"hermes",
"grok_bot",
]);
expect(
@@ -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<typeof supportedClients>[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 },
},
});
});
});
+11 -8
View File
@@ -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",
+3
View File
@@ -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",