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
3 changes: 1 addition & 2 deletions src/constants/hermesagent-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@ export const HERMESAGENT_RULE_FILE_NAME = ".hermes.md";
export const HERMESAGENT_GLOBAL_DIR = ".hermes";

/** MCP servers and other settings live in `config.yaml` under `~/.hermes/`. */
export const HERMESAGENT_MCP_FILE_NAME = "config.yaml";
export const HERMESAGENT_SOUL_FILE_NAME = "SOUL.md";
export const HERMESAGENT_CONFIG_FILE_NAME = "config.yaml";
export const HERMESAGENT_SOUL_FILE_NAME = "SOUL.md";
export const HERMESAGENT_SOUL_FILE_PATH = join(HERMESAGENT_GLOBAL_DIR, HERMESAGENT_SOUL_FILE_NAME);
export const HERMESAGENT_CONFIG_FILE_PATH = join(
HERMESAGENT_GLOBAL_DIR,
Expand Down
3 changes: 3 additions & 0 deletions src/e2e/e2e-commands.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ describe("E2E: commands (global mode)", () => {
{ target: "factorydroid", outputPath: join(".factory", "commands", "review-pr.md") },
{ target: "goose", outputPath: join(".config", "goose", "recipes", "review-pr.yaml") },
{ target: "qwencode", outputPath: join(".qwen", "commands", "review-pr.md") },
// Hermes Agent has no project-scoped command location; commands are emitted
// as Hermes skills under ~/.hermes/skills/<slug>/SKILL.md (global only).
{ target: "hermesagent", outputPath: join(".hermes", "skills", "review-pr", "SKILL.md") },
])("should generate $target commands in home directory", async ({ target, outputPath }) => {
const projectDir = getProjectDir();
const homeDir = getHomeDir();
Expand Down
36 changes: 36 additions & 0 deletions src/e2e/e2e-hooks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,4 +633,40 @@ describe("E2E: hooks (global mode)", () => {
const configContent = await readFileContent(join(homeDir, ".vibe", "config.toml"));
expect(configContent).toContain("enable_experimental_hooks = true");
});

it("should generate hermesagent hooks in home directory", async () => {
const projectDir = getProjectDir();
const homeDir = getHomeDir();

// Hermes Agent has no project-scoped hooks location; hooks are merged under
// the `hooks.rulesync` key of the shared global ~/.hermes/config.yaml (YAML,
// global only).
const hooksContent = JSON.stringify(
{
version: 1,
root: true,
hooks: {
sessionStart: [{ type: "command", command: ".rulesync/hooks/session-start.sh" }],
stop: [{ command: ".rulesync/hooks/audit.sh" }],
},
},
null,
2,
);
await writeFileContent(join(projectDir, RULESYNC_HOOKS_RELATIVE_FILE_PATH), hooksContent);

await runGenerate({
target: "hermesagent",
features: "hooks",
global: true,
env: { HOME_DIR: homeDir },
});

// The config is YAML; assert the canonical hook command paths survive
// generation under the nested `hooks.rulesync` block.
const generatedContent = await readFileContent(join(homeDir, ".hermes", "config.yaml"));
expect(generatedContent).toContain("rulesync");
expect(generatedContent).toContain(".rulesync/hooks/session-start.sh");
expect(generatedContent).toContain(".rulesync/hooks/audit.sh");
});
});
48 changes: 48 additions & 0 deletions src/e2e/e2e-permissions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1603,6 +1603,54 @@ describe("E2E: permissions (global mode)", () => {
// Unrelated user settings preserved by the non-destructive merge.
expect(parsed.model).toBe("claude-sonnet");
});

it("should generate hermesagent permissions in home directory with --global", async () => {
const projectDir = getProjectDir();
const homeDir = getHomeDir();

await writeFileContent(
join(projectDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH),
JSON.stringify(
{
permission: {
bash: { "git status *": "allow", "rm -rf *": "deny" },
},
},
null,
2,
),
);

// 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"),
["model: hermes-large", "terminal: tmux"].join("\n"),
);

await runGenerate({
target: "hermesagent",
features: "permissions",
global: true,
env: { HOME_DIR: homeDir },
});

// Hermes Agent has no project-scoped permissions location; permissions are
// 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"))));
expect(parsed.command_allowlist).toEqual(["git status *"]);
const permissions = toTable(parsed.permissions);
const rulesyncProfile = toTable(permissions.rulesync);
const permissionMap = toTable(rulesyncProfile.permission);
const bash = toTable(permissionMap.bash);
expect(bash["git status *"]).toBe("allow");
expect(bash["rm -rf *"]).toBe("deny");
// Unrelated user settings preserved by the non-destructive merge.
expect(parsed.model).toBe("hermes-large");
expect(parsed.terminal).toBe("tmux");
});
});

type AugmentEntry = {
Expand Down
5 changes: 5 additions & 0 deletions src/e2e/e2e-skills.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,11 @@ describe("E2E: skills (global mode)", () => {
target: "vibe",
outputPath: join(".vibe", "skills", "test-skill", "SKILL.md"),
},
{
// Hermes Agent reads skills from ~/.hermes/skills/ (global only).
target: "hermesagent",
outputPath: join(".hermes", "skills", "test-skill", "SKILL.md"),
},
])("should generate $target skills in home directory", async ({ target, outputPath }) => {
const projectDir = getProjectDir();
const homeDir = getHomeDir();
Expand Down
7 changes: 7 additions & 0 deletions src/e2e/e2e-subagents.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,13 @@ describe("E2E: subagents (global mode)", () => {
target: "goose",
outputPath: join(".config", "goose", "recipes", "subagents", "planner.yaml"),
},
{
// Hermes Agent has no project-scoped subagent location; subagents are
// emitted as JSON specs under ~/.hermes/rulesync/subagents/<slug>.json,
// discovered by the generated rulesync-subagents plugin (global only).
target: "hermesagent",
outputPath: join(".hermes", "rulesync", "subagents", "planner.json"),
},
])("should generate $target subagents in home directory", async ({ target, outputPath }) => {
const projectDir = getProjectDir();
const homeDir = getHomeDir();
Expand Down
3 changes: 1 addition & 2 deletions src/features/commands/hermesagent-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ export class HermesagentCommand extends ToolCommand {
}

constructor({ slug, ...params }: HermesagentCommandParams) {
const resolvedSlug =
slug ?? basename(dirname(params.relativeDirPath)) ?? commandSlug(params.relativeFilePath);
const resolvedSlug = slug ?? basename(dirname(params.relativeDirPath));
super({
...params,
...HermesagentCommand.getSettablePaths({ slug: resolvedSlug }),
Expand Down
52 changes: 8 additions & 44 deletions src/features/mcp/hermesagent-mcp.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import { join } from "node:path";

import { dump, load } from "js-yaml";

import {
HERMESAGENT_CONFIG_FILE_NAME,
HERMESAGENT_GLOBAL_DIR,
HERMESAGENT_MCP_FILE_NAME,
} from "../../constants/hermesagent-paths.js";
import { ValidationResult } from "../../types/ai-file.js";
import { McpServers } from "../../types/mcp.js";
import { formatError } from "../../utils/error.js";
import { readFileContentOrNull, readOrInitializeFileContent } from "../../utils/file.js";
import {
omitPrototypePollutionKeys,
PROTOTYPE_POLLUTION_KEYS,
} from "../../utils/prototype-pollution.js";
import { isPlainObject, isRecord, isStringArray } from "../../utils/type-guards.js";
import { parseHermesConfig, stringifyHermesConfig } from "../hermes-config.js";
import { RulesyncMcp } from "./rulesync-mcp.js";
import {
ToolMcp,
Expand All @@ -28,32 +26,6 @@ import {
const HERMESAGENT_GLOBAL_ONLY_MESSAGE =
"Hermes Agent MCP is global-only; use --global to sync ~/.hermes/config.yaml";

function parseHermesConfig(
fileContent: string,
relativeDirPath: string,
relativeFilePath: string,
): Record<string, unknown> {
const configPath = join(relativeDirPath, relativeFilePath);
let parsed: unknown;
try {
parsed = load(fileContent);
} catch (error) {
throw new Error(`Failed to parse Hermes config at ${configPath}: ${formatError(error)}`, {
cause: error,
});
}
// An empty config.yaml parses to undefined/null; treat it as an empty object.
if (parsed === undefined || parsed === null) {
return {};
}
// `isPlainObject` (not `isRecord`) rejects class instances for
// prototype-pollution hardening; a YAML mapping always parses to a plain object.
if (!isPlainObject(parsed)) {
throw new Error(`Failed to parse Hermes config at ${configPath}: expected a YAML mapping`);
}
return parsed;
}

/**
* Resolves the canonical remote URL for a server (`url` or the `httpUrl` alias).
*/
Expand Down Expand Up @@ -185,15 +157,7 @@ export class HermesagentMcp extends ToolMcp {

constructor(params: ToolMcpParams) {
super(params);
if (this.fileContent !== undefined) {
this.config = parseHermesConfig(
this.fileContent,
this.relativeDirPath,
this.relativeFilePath,
);
} else {
this.config = {};
}
this.config = this.fileContent !== undefined ? parseHermesConfig(this.fileContent) : {};
}

getConfig(): Record<string, unknown> {
Expand All @@ -205,15 +169,15 @@ export class HermesagentMcp extends ToolMcp {
}

override setFileContent(fileContent: string): void {
const config = parseHermesConfig(fileContent, this.relativeDirPath, this.relativeFilePath);
const config = parseHermesConfig(fileContent);
const mcpServers = isRecord(this.config.mcp_servers) ? this.config.mcp_servers : {};
const merged = mergeHermesMcpServers(
config,
mcpServers as Record<string, Record<string, unknown>>,
);

this.config = merged;
super.setFileContent(dump(merged));
super.setFileContent(stringifyHermesConfig(merged));
}

override isDeletable(): boolean {
Expand All @@ -225,7 +189,7 @@ export class HermesagentMcp extends ToolMcp {
static getSettablePaths(_options?: { global?: boolean }): ToolMcpSettablePaths {
return {
relativeDirPath: HERMESAGENT_GLOBAL_DIR,
relativeFilePath: HERMESAGENT_MCP_FILE_NAME,
relativeFilePath: HERMESAGENT_CONFIG_FILE_NAME,
};
}

Expand Down Expand Up @@ -266,7 +230,7 @@ export class HermesagentMcp extends ToolMcp {
join(outputRoot, paths.relativeDirPath, paths.relativeFilePath),
"",
);
const config = parseHermesConfig(fileContent, paths.relativeDirPath, paths.relativeFilePath);
const config = parseHermesConfig(fileContent);

// Merge the `mcp_servers:` block into the shared config, preserving other
// keys (model, terminal, ...).
Expand All @@ -279,7 +243,7 @@ export class HermesagentMcp extends ToolMcp {
outputRoot,
relativeDirPath: paths.relativeDirPath,
relativeFilePath: paths.relativeFilePath,
fileContent: dump(merged),
fileContent: stringifyHermesConfig(merged),
validate,
global,
});
Expand Down
104 changes: 104 additions & 0 deletions src/features/skills/hermesagent-skill.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { HERMESAGENT_SKILLS_DIR_PATH } from "../../constants/hermesagent-paths.js";
import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js";
import { setupTestDirectory } from "../../test-utils/test-directories.js";
import { HermesagentSkill } from "./hermesagent-skill.js";
import { RulesyncSkill } from "./rulesync-skill.js";

describe("HermesagentSkill", () => {
let testDir: string;
let cleanup: () => Promise<void>;

beforeEach(async () => {
const testSetup = await setupTestDirectory();
testDir = testSetup.testDir;
cleanup = testSetup.cleanup;
vi.spyOn(process, "cwd").mockReturnValue(testDir);
});

afterEach(async () => {
await cleanup();
vi.restoreAllMocks();
});

describe("getSettablePaths", () => {
it("should return the Hermes skills directory as relativeDirPath", () => {
const paths = HermesagentSkill.getSettablePaths();
expect(paths.relativeDirPath).toBe(HERMESAGENT_SKILLS_DIR_PATH);
});
});

describe("constructor", () => {
it("should force the Hermes skills directory even when another relativeDirPath is passed", () => {
const skill = new HermesagentSkill({
outputRoot: testDir,
relativeDirPath: "ignored",
dirName: "test-skill",
frontmatter: {
name: "Test Skill",
description: "Test skill description",
},
body: "This is the body of the Hermes skill.",
validate: true,
});

expect(skill).toBeInstanceOf(HermesagentSkill);
expect(skill.getRelativeDirPath()).toBe(HERMESAGENT_SKILLS_DIR_PATH);
expect(skill.getBody()).toBe("This is the body of the Hermes skill.");
expect(skill.getFrontmatter()).toEqual({
name: "Test Skill",
description: "Test skill description",
});
});
});

describe("fromRulesyncSkill", () => {
it("should create an instance routed to the Hermes skills directory", () => {
const rulesyncSkill = new RulesyncSkill({
outputRoot: testDir,
relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH,
dirName: "test-skill",
frontmatter: {
name: "Test Skill",
description: "Test skill description",
},
body: "Test body content",
validate: true,
});

const skill = HermesagentSkill.fromRulesyncSkill({ rulesyncSkill, global: true });

expect(skill).toBeInstanceOf(HermesagentSkill);
expect(skill.getRelativeDirPath()).toBe(HERMESAGENT_SKILLS_DIR_PATH);
expect(skill.getBody()).toBe("Test body content");
expect(skill.getFrontmatter()).toEqual({
name: "Test Skill",
description: "Test skill description",
});
});
});

describe("toRulesyncSkill", () => {
it("should convert back to a RulesyncSkill", () => {
const skill = new HermesagentSkill({
outputRoot: testDir,
dirName: "test-skill",
frontmatter: {
name: "Test Skill",
description: "Test description",
},
body: "Test body",
validate: true,
});

const rulesyncSkill = skill.toRulesyncSkill();

expect(rulesyncSkill.getFrontmatter()).toMatchObject({
name: "Test Skill",
description: "Test description",
});
expect(rulesyncSkill.getBody()).toBe("Test body");
});
});
});
2 changes: 1 addition & 1 deletion src/lib/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ async function processFeatureWithRulesyncFiles(params: {
if (rulesyncFiles.length === 0) {
return processEmptyFeatureGeneration({ config, processor, skipFilePaths });
}
let toolFiles = await processor.convertRulesyncFilesToToolFiles(rulesyncFiles);
const toolFiles = await processor.convertRulesyncFilesToToolFiles(rulesyncFiles);
return processFeatureGeneration({ config, processor, toolFiles, skipFilePaths });
}

Expand Down
2 changes: 2 additions & 0 deletions src/types/feature-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ function createMockFile(filePath: string): AiFile {
getFilePath: () => filePath,
getFileContent: () => "content",
getRelativePathFromCwd: () => filePath,
// Declared on the AiFile base class; defaults to false for non-merging files.
shouldMergeExistingFileContent: () => false,
} as AiFile;
}

Expand Down
Loading
Loading