diff --git a/src/constants/hermesagent-paths.ts b/src/constants/hermesagent-paths.ts index a1f9a451f..b7297b2f8 100644 --- a/src/constants/hermesagent-paths.ts +++ b/src/constants/hermesagent-paths.ts @@ -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, diff --git a/src/e2e/e2e-commands.spec.ts b/src/e2e/e2e-commands.spec.ts index 1cbdc0475..f430d76ab 100644 --- a/src/e2e/e2e-commands.spec.ts +++ b/src/e2e/e2e-commands.spec.ts @@ -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//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(); diff --git a/src/e2e/e2e-hooks.spec.ts b/src/e2e/e2e-hooks.spec.ts index 78da67ae9..e420cce01 100644 --- a/src/e2e/e2e-hooks.spec.ts +++ b/src/e2e/e2e-hooks.spec.ts @@ -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"); + }); }); diff --git a/src/e2e/e2e-permissions.spec.ts b/src/e2e/e2e-permissions.spec.ts index 1870ff497..8397aac8d 100644 --- a/src/e2e/e2e-permissions.spec.ts +++ b/src/e2e/e2e-permissions.spec.ts @@ -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 = { diff --git a/src/e2e/e2e-skills.spec.ts b/src/e2e/e2e-skills.spec.ts index e1918d312..ed3bfabf3 100644 --- a/src/e2e/e2e-skills.spec.ts +++ b/src/e2e/e2e-skills.spec.ts @@ -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(); diff --git a/src/e2e/e2e-subagents.spec.ts b/src/e2e/e2e-subagents.spec.ts index 7f7fad1fb..e8c9791f7 100644 --- a/src/e2e/e2e-subagents.spec.ts +++ b/src/e2e/e2e-subagents.spec.ts @@ -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/.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(); diff --git a/src/features/commands/hermesagent-command.ts b/src/features/commands/hermesagent-command.ts index 6974315ba..7bde1d853 100644 --- a/src/features/commands/hermesagent-command.ts +++ b/src/features/commands/hermesagent-command.ts @@ -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 }), diff --git a/src/features/mcp/hermesagent-mcp.ts b/src/features/mcp/hermesagent-mcp.ts index 02c26b3f3..968404a96 100644 --- a/src/features/mcp/hermesagent-mcp.ts +++ b/src/features/mcp/hermesagent-mcp.ts @@ -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, @@ -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 { - 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). */ @@ -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 { @@ -205,7 +169,7 @@ 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, @@ -213,7 +177,7 @@ export class HermesagentMcp extends ToolMcp { ); this.config = merged; - super.setFileContent(dump(merged)); + super.setFileContent(stringifyHermesConfig(merged)); } override isDeletable(): boolean { @@ -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, }; } @@ -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, ...). @@ -279,7 +243,7 @@ export class HermesagentMcp extends ToolMcp { outputRoot, relativeDirPath: paths.relativeDirPath, relativeFilePath: paths.relativeFilePath, - fileContent: dump(merged), + fileContent: stringifyHermesConfig(merged), validate, global, }); diff --git a/src/features/skills/hermesagent-skill.test.ts b/src/features/skills/hermesagent-skill.test.ts new file mode 100644 index 000000000..ea237e9e0 --- /dev/null +++ b/src/features/skills/hermesagent-skill.test.ts @@ -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; + + 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"); + }); + }); +}); diff --git a/src/lib/generate.ts b/src/lib/generate.ts index 5aceab479..49ae59633 100644 --- a/src/lib/generate.ts +++ b/src/lib/generate.ts @@ -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 }); } diff --git a/src/types/feature-processor.test.ts b/src/types/feature-processor.test.ts index 89cc2cd04..7b7fc811d 100644 --- a/src/types/feature-processor.test.ts +++ b/src/types/feature-processor.test.ts @@ -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; } diff --git a/src/types/feature-processor.ts b/src/types/feature-processor.ts index 2d3b0fbdc..4dc459d29 100644 --- a/src/types/feature-processor.ts +++ b/src/types/feature-processor.ts @@ -63,12 +63,7 @@ export abstract class FeatureProcessor { const filePath = aiFile.getFilePath(); const existingFileContent = await readFileContentOrNull(filePath); - if ( - existingFileContent !== null && - "shouldMergeExistingFileContent" in aiFile && - typeof aiFile.shouldMergeExistingFileContent === "function" && - aiFile.shouldMergeExistingFileContent() - ) { + if (existingFileContent !== null && aiFile.shouldMergeExistingFileContent()) { aiFile.setFileContent(existingFileContent); }