mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
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:
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) &&
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Vendored
+3
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user