Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/reference/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,11 @@ covered for both advertised scopes.
Rulesync honors Hermes profiles through `HERMES_HOME`. When it is set, its value
is the profile root itself: global configuration is read and written directly
under `$HERMES_HOME` (`config.yaml`, `skills/`, `plugins/`, and `rulesync/`),
without appending `.hermes`. When it is unset, the default remains
`~/.hermes`. Project-scoped paths remain rooted in the project.
without appending `.hermes`. When it is unset, Rulesync follows Hermes's own
platform default: `~/.hermes` everywhere except Windows, where it is
`%LOCALAPPDATA%\hermes`. Because `HERMES_HOME` names where Hermes itself reads
the profile, it also takes precedence over `--output-roots` in global scope.
Project-scoped paths remain rooted in the project.

Project plugins are registered by adding their names to
`$HERMES_HOME/config.yaml`, but Rulesync does not persist Hermes's global
Expand Down
7 changes: 5 additions & 2 deletions skills/rulesync/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,11 @@ covered for both advertised scopes.
Rulesync honors Hermes profiles through `HERMES_HOME`. When it is set, its value
is the profile root itself: global configuration is read and written directly
under `$HERMES_HOME` (`config.yaml`, `skills/`, `plugins/`, and `rulesync/`),
without appending `.hermes`. When it is unset, the default remains
`~/.hermes`. Project-scoped paths remain rooted in the project.
without appending `.hermes`. When it is unset, Rulesync follows Hermes's own
platform default: `~/.hermes` everywhere except Windows, where it is
`%LOCALAPPDATA%\hermes`. Because `HERMES_HOME` names where Hermes itself reads
the profile, it also takes precedence over `--output-roots` in global scope.
Project-scoped paths remain rooted in the project.

Project plugins are registered by adding their names to
`$HERMES_HOME/config.yaml`, but Rulesync does not persist Hermes's global
Expand Down
17 changes: 16 additions & 1 deletion src/constants/hermesagent-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,24 @@
/** Project-root instruction file auto-injected by Hermes Agent. */
export const HERMESAGENT_RULE_FILE_NAME = ".hermes.md";

/** Root directory for Hermes Agent global configuration (the HERMES_HOME dir). */
/**
* Root directory for Hermes Agent global configuration (the HERMES_HOME dir).
* Also the project-local plugin tree, which is `.hermes/` on every platform.
*/
export const HERMESAGENT_GLOBAL_DIR = ".hermes";

/**
* Home-relative global profile root on Windows: upstream defaults to
* `%LOCALAPPDATA%\hermes` there, not `~/.hermes`.
* Resolve it through `getHermesagentGlobalDir()` rather than reading it directly.
*
* Home-relative rather than read from `LOCALAPPDATA`, matching how every other
* Windows global path in rulesync is spelled (`ZED_GLOBAL_WIN32_DIR`,
* `WARP_WIN32_DIR`). A profile with `LOCALAPPDATA` redirected elsewhere is not
* followed; those users should set `HERMES_HOME` explicitly.
*/
export const HERMESAGENT_GLOBAL_WIN32_DIR = join("AppData", "Local", "hermes");

/** MCP servers and other settings live in `config.yaml` under `~/.hermes/`. */
export const HERMESAGENT_CONFIG_FILE_NAME = "config.yaml";
export const HERMESAGENT_CONFIG_FILE_PATH = join(
Expand Down
9 changes: 5 additions & 4 deletions src/e2e/e2e-checks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
import { RULESYNC_CHECKS_RELATIVE_DIR_PATH } from "../constants/rulesync-paths.js";
import { ChecksProcessor } from "../features/checks/checks-processor.js";
import { fileExists, readFileContent, writeFileContent } from "../utils/file.js";
import { getHermesagentGlobalDir } from "../utils/hermesagent.js";
import {
assertGenerateMatrixCoversTargets,
runGenerate,
Expand Down Expand Up @@ -116,10 +117,10 @@ Look for injection vulnerabilities.
join(testDir, ".hermes", "plugins", "rulesync-checks", "__init__.py"),
);
expect(plugin).toContain('ctx.register_hook("pre_verify", require_rulesync_checks)');
expect(await readFileContent(join(homeDir, ".hermes", "config.yaml"))).toContain(
"rulesync-checks",
);
expect(await fileExists(join(homeDir, ".hermes", ".env"))).toBe(false);
expect(
await readFileContent(join(homeDir, getHermesagentGlobalDir(), "config.yaml")),
).toContain("rulesync-checks");
expect(await fileExists(join(homeDir, getHermesagentGlobalDir(), ".env"))).toBe(false);
}
expect(generatedContent).toContain("Look for injection vulnerabilities.");
},
Expand Down
15 changes: 9 additions & 6 deletions src/e2e/e2e-commands.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
import { RULESYNC_COMMANDS_RELATIVE_DIR_PATH } from "../constants/rulesync-paths.js";
import { CommandsProcessor } from "../features/commands/commands-processor.js";
import { fileExists, readFileContent, removeFile, writeFileContent } from "../utils/file.js";
import { getHermesagentGlobalDir } from "../utils/hermesagent.js";
import {
assertGenerateMatrixCoversTargets,
runGenerate,
Expand Down Expand Up @@ -79,7 +80,7 @@ const commandsGlobalTargets = [
// Hermes commands are global plugin-backed slash commands, separate from skills.
{
target: "hermesagent",
outputPath: join(".hermes", "rulesync", "commands", "review-pr.json"),
outputPath: join(getHermesagentGlobalDir(), "rulesync", "commands", "review-pr.json"),
},
{ target: "reasonix", outputPath: join(".reasonix", "commands", "review-pr.md") },
{ target: "rovodev", outputPath: join(".rovodev", "prompts", "review-pr.md") },
Expand Down Expand Up @@ -349,9 +350,9 @@ describe("E2E: commands (global mode)", () => {
});

const plugin = await readFileContent(
join(homeDir, ".hermes", "plugins", "rulesync-commands", "__init__.py"),
join(homeDir, getHermesagentGlobalDir(), "plugins", "rulesync-commands", "__init__.py"),
);
const config = await readFileContent(join(homeDir, ".hermes", "config.yaml"));
const config = await readFileContent(join(homeDir, getHermesagentGlobalDir(), "config.yaml"));
expect(plugin).toContain("ctx.register_command(slug, handler, description)");
expect(plugin).toContain('"delegate_task"');
expect(plugin).toContain('Path(__file__).resolve().parents[2] / "rulesync" / "commands"');
Expand All @@ -368,7 +369,7 @@ describe("E2E: commands (global mode)", () => {
'---\ndescription: "Review a pull request"\ntargets: ["hermesagent"]\n---\nReview it.\n',
);
await writeFileContent(
join(homeDir, ".hermes", "config.yaml"),
join(homeDir, getHermesagentGlobalDir(), "config.yaml"),
"plugins:\n enabled:\n - existing-plugin\n",
);

Expand All @@ -388,9 +389,11 @@ describe("E2E: commands (global mode)", () => {
});

expect(
await fileExists(join(homeDir, ".hermes", "plugins", "rulesync-commands", "__init__.py")),
await fileExists(
join(homeDir, getHermesagentGlobalDir(), "plugins", "rulesync-commands", "__init__.py"),
),
).toBe(false);
const config = await readFileContent(join(homeDir, ".hermes", "config.yaml"));
const config = await readFileContent(join(homeDir, getHermesagentGlobalDir(), "config.yaml"));
expect(config).toContain("- existing-plugin");
expect(config).not.toContain("rulesync-commands");
});
Expand Down
7 changes: 4 additions & 3 deletions src/e2e/e2e-hermesagent-home.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH,
} from "../constants/rulesync-paths.js";
import { fileExists, readFileContent, removeFile, writeFileContent } from "../utils/file.js";
import { getHermesagentGlobalDir } from "../utils/hermesagent.js";
import {
execFileAsync,
rulesyncArgs,
Expand Down Expand Up @@ -137,7 +138,7 @@ describe("E2E: HERMES_HOME", () => {
expect(config).toContain("rm -rf *");
expect(config).toContain("rulesync-commands");
expect(config).toContain("rulesync-subagents");
expect(await fileExists(join(homeDir, ".hermes", "config.yaml"))).toBe(false);
expect(await fileExists(join(homeDir, getHermesagentGlobalDir(), "config.yaml"))).toBe(false);

await runGenerate({
target: "hermesagent",
Expand Down Expand Up @@ -205,7 +206,7 @@ describe("E2E: HERMES_HOME", () => {
expect(await fileExists(join(homeDir, "plugins", "rulesync-commands", "plugin.yaml"))).toBe(
false,
);
expect(await fileExists(join(homeDir, ".hermes", "config.yaml"))).toBe(false);
expect(await fileExists(join(homeDir, getHermesagentGlobalDir(), "config.yaml"))).toBe(false);
});

it("imports every global feature from the custom profile into the global RuleSync root", async () => {
Expand All @@ -229,7 +230,7 @@ describe("E2E: HERMES_HOME", () => {
].join("\n"),
);
await writeFileContent(
join(homeDir, ".hermes", "config.yaml"),
join(homeDir, getHermesagentGlobalDir(), "config.yaml"),
["mcp_servers:", " wrong-profile:", " command: false"].join("\n"),
);
await writeFileContent(
Expand Down
7 changes: 5 additions & 2 deletions src/e2e/e2e-hooks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
import { RULESYNC_HOOKS_RELATIVE_FILE_PATH } from "../constants/rulesync-paths.js";
import { HooksProcessor } from "../features/hooks/hooks-processor.js";
import { fileExists, readFileContent, writeFileContent } from "../utils/file.js";
import { getHermesagentGlobalDir } from "../utils/hermesagent.js";
import {
assertGenerateMatrixCoversTargets,
runGenerate,
Expand Down Expand Up @@ -980,7 +981,9 @@ describe("E2E: hooks (global mode)", () => {

// The config is YAML; assert the canonical hooks survive generation under
// Hermes's real, functioning event keys.
const generatedContent = await readFileContent(join(homeDir, ".hermes", "config.yaml"));
const generatedContent = await readFileContent(
join(homeDir, getHermesagentGlobalDir(), "config.yaml"),
);
expect(generatedContent).not.toContain("rulesync:");
expect(generatedContent).toContain("on_session_start");
expect(generatedContent).toContain("pre_tool_call");
Expand All @@ -993,7 +996,7 @@ describe("E2E: hooks (global mode)", () => {
it("should import Hermes native-only hooks without dropping them", async () => {
const homeDir = getHomeDir();
await writeFileContent(
join(homeDir, ".hermes", "config.yaml"),
join(homeDir, getHermesagentGlobalDir(), "config.yaml"),
[
"hooks:",
" pre_tool_call:",
Expand Down
9 changes: 6 additions & 3 deletions src/e2e/e2e-ignore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { RULESYNC_AIIGNORE_RELATIVE_FILE_PATH } from "../constants/rulesync-path
import { getZedGlobalDir, ZED_SETTINGS_FILE_NAME } from "../constants/zed-paths.js";
import { IgnoreProcessor } from "../features/ignore/ignore-processor.js";
import { fileExists, readFileContent, writeFileContent } from "../utils/file.js";
import { getHermesagentGlobalDir } from "../utils/hermesagent.js";
import {
assertGenerateMatrixCoversTargets,
runGenerate,
Expand Down Expand Up @@ -126,7 +127,9 @@ credentials/
if (target === "hermesagent") {
expect(await readFileContent(join(hermesHome, "config.yaml"))).toContain("rulesync-ignore");
expect(await fileExists(join(hermesHome, ".env"))).toBe(false);
expect(await fileExists(join(homeDir, ".hermes", "config.yaml"))).toBe(false);
expect(await fileExists(join(homeDir, getHermesagentGlobalDir(), "config.yaml"))).toBe(
false,
);
}
},
);
Expand All @@ -148,8 +151,8 @@ credentials/
expect(
await fileExists(join(testDir, ".hermes", "plugins", "rulesync-ignore", "__init__.py")),
).toBe(false);
expect(await fileExists(join(homeDir, ".hermes", "config.yaml"))).toBe(false);
expect(await fileExists(join(homeDir, ".hermes", ".env"))).toBe(false);
expect(await fileExists(join(homeDir, getHermesagentGlobalDir(), "config.yaml"))).toBe(false);
expect(await fileExists(join(homeDir, getHermesagentGlobalDir(), ".env"))).toBe(false);

await runGenerate({
target: "hermesagent",
Expand Down
5 changes: 3 additions & 2 deletions src/e2e/e2e-mcp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { getZedGlobalDir } from "../constants/zed-paths.js";
import { McpProcessor } from "../features/mcp/mcp-processor.js";
import { fileExists, readFileContent, writeFileContent } from "../utils/file.js";
import { getHermesagentGlobalDir } from "../utils/hermesagent.js";
import {
assertGenerateMatrixCoversTargets,
runGenerate,
Expand Down Expand Up @@ -750,7 +751,7 @@ const mcpGlobalTargets = [
{ target: "cursor", outputPath: join(".cursor", "mcp.json") },
{ target: "qwencode", outputPath: join(".qwen", "settings.json") },
{ target: "goose", outputPath: join(".config", "goose", "config.yaml") },
{ target: "hermesagent", outputPath: join(".hermes", "config.yaml") },
{ target: "hermesagent", outputPath: join(getHermesagentGlobalDir(), "config.yaml") },
{ target: "opencode", outputPath: join(".config", "opencode", "opencode.jsonc") },
{ target: "codexcli", outputPath: join(".codex", "config.toml") },
{ target: "grokcli", outputPath: join(".grok", "config.toml") },
Expand Down Expand Up @@ -849,7 +850,7 @@ describe("E2E: mcp (global mode)", () => {
it("should import Hermes OAuth and lifecycle settings into a target override", async () => {
const homeDir = getHomeDir();
await writeFileContent(
join(homeDir, ".hermes", "config.yaml"),
join(homeDir, getHermesagentGlobalDir(), "config.yaml"),
[
"mcp_servers:",
" remote:",
Expand Down
9 changes: 6 additions & 3 deletions src/e2e/e2e-permissions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { getZedGlobalDir } from "../constants/zed-paths.js";
import { PermissionsProcessor } from "../features/permissions/permissions-processor.js";
import { fileExists, readFileContent, writeFileContent } from "../utils/file.js";
import { getHermesagentGlobalDir } from "../utils/hermesagent.js";
import {
assertGenerateMatrixCoversTargets,
runGenerate,
Expand Down Expand Up @@ -2421,7 +2422,7 @@ describe("E2E: permissions (global mode)", () => {
// Pre-seed config.yaml with unrelated user settings to verify the
// non-destructive merge into ~/.hermes/config.yaml.
await writeFileContent(
join(homeDir, ".hermes", "config.yaml"),
join(homeDir, getHermesagentGlobalDir(), "config.yaml"),
["model: hermes-large", "terminal: tmux"].join("\n"),
);

Expand All @@ -2436,7 +2437,9 @@ describe("E2E: permissions (global mode)", () => {
// merged into the shared global ~/.hermes/config.yaml. Allow rules are also
// surfaced as a flat `command_allowlist`, and the canonical map is preserved
// under `permissions.rulesync` for round-tripping.
const parsed = toTable(load(await readFileContent(join(homeDir, ".hermes", "config.yaml"))));
const parsed = toTable(
load(await readFileContent(join(homeDir, getHermesagentGlobalDir(), "config.yaml"))),
);
expect(parsed.command_allowlist).toEqual(["git status *"]);
// The bash deny reaches Hermes's hard denylist (previously silently dropped).
expect(toTable(parsed.approvals).deny).toEqual(["rm -rf *"]);
Expand All @@ -2454,7 +2457,7 @@ describe("E2E: permissions (global mode)", () => {
it("should import native Hermes permission settings without private provenance", async () => {
const homeDir = getHomeDir();
await writeFileContent(
join(homeDir, ".hermes", "config.yaml"),
join(homeDir, getHermesagentGlobalDir(), "config.yaml"),
[
"model: hermes-large",
'command_allowlist: ["git *", "pnpm *"]',
Expand Down
5 changes: 3 additions & 2 deletions src/e2e/e2e-skills.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../constants/rulesync-paths.js";
import { SkillsProcessor } from "../features/skills/skills-processor.js";
import { ensureDir, fileExists, readFileContent, writeFileContent } from "../utils/file.js";
import { getHermesagentGlobalDir } from "../utils/hermesagent.js";
import {
assertGenerateMatrixCoversTargets,
runGenerate,
Expand Down Expand Up @@ -665,7 +666,7 @@ const skillsGlobalTargets = [
{
// Hermes Agent reads skills from ~/.hermes/skills/ (global only).
target: "hermesagent",
outputPath: join(".hermes", "skills", "test-skill", "SKILL.md"),
outputPath: join(getHermesagentGlobalDir(), "skills", "test-skill", "SKILL.md"),
},
{
// Kiro reads global skills from ~/.kiro/skills/.
Expand Down Expand Up @@ -697,7 +698,7 @@ describe("E2E: skills (global mode)", () => {
it("should import Hermes skill metadata into a target override", async () => {
const homeDir = getHomeDir();
await writeFileContent(
join(homeDir, ".hermes", "skills", "test-skill", "SKILL.md"),
join(homeDir, getHermesagentGlobalDir(), "skills", "test-skill", "SKILL.md"),
[
"---",
"name: test-skill",
Expand Down
13 changes: 7 additions & 6 deletions src/e2e/e2e-subagents.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../constants/rulesync-paths.js";
import { SubagentsProcessor } from "../features/subagents/subagents-processor.js";
import { ensureDir, fileExists, readFileContent, writeFileContent } from "../utils/file.js";
import { getHermesagentGlobalDir } from "../utils/hermesagent.js";
import {
assertGenerateMatrixCoversTargets,
runGenerate,
Expand Down Expand Up @@ -177,7 +178,7 @@ const subagentsGlobalTargets = [
},
{
target: "hermesagent",
outputPath: join(".hermes", "rulesync", "subagents", "planner.json"),
outputPath: join(getHermesagentGlobalDir(), "rulesync", "subagents", "planner.json"),
},
] as const;

Expand Down Expand Up @@ -220,10 +221,10 @@ You are the planner. Analyze files and create a plan.
expect(generatedContent).toContain("Analyze files and create a plan.");

if (target === "hermesagent") {
expect(await readFileContent(join(homeDir, ".hermes", "config.yaml"))).toContain(
"rulesync-subagents",
);
expect(await fileExists(join(homeDir, ".hermes", ".env"))).toBe(false);
expect(
await readFileContent(join(homeDir, getHermesagentGlobalDir(), "config.yaml")),
).toContain("rulesync-subagents");
expect(await fileExists(join(homeDir, getHermesagentGlobalDir(), ".env"))).toBe(false);
expect(
await readFileContent(
join(testDir, ".hermes", "plugins", "rulesync-subagents", "__init__.py"),
Expand Down Expand Up @@ -726,7 +727,7 @@ describe("E2E: subagents (global mode)", () => {
it("should import Hermes global subagents into the global RuleSync source directory", async () => {
const homeDir = getHomeDir();
await writeFileContent(
join(homeDir, ".hermes", "rulesync", "subagents", "planner.json"),
join(homeDir, getHermesagentGlobalDir(), "rulesync", "subagents", "planner.json"),
JSON.stringify({
slug: "planner",
name: "Planner",
Expand Down
5 changes: 4 additions & 1 deletion src/features/commands/commands-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -914,7 +914,10 @@ export class CommandsProcessor extends FeatureProcessor {
);
const currentContent = await readFileContentOrNull(configPath);
if (currentContent === null) return changedCount;
const nextContent = getDisabledHermesCommandsPluginConfigContent(currentContent);
const nextContent = getDisabledHermesCommandsPluginConfigContent({
currentContent,
global: this.global,
});
if (nextContent === currentContent) return changedCount;

if (this.dryRun) {
Expand Down
Loading
Loading