diff --git a/.gitignore b/.gitignore index 9bffed082..8696031f3 100644 --- a/.gitignore +++ b/.gitignore @@ -333,6 +333,7 @@ rulesync.local.jsonc **/.factory/droids/ **/.gemini/agents/ **/.goose/recipes/subagents/ +**/.hermes/rulesync/subagents/ **/.grok/agents/ **/.junie/agents/ **/.kiro/agents/ diff --git a/README.md b/README.md index c01c85104..da111ca76 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,8 @@ The tables below show whether each tool supports a given feature (✅ = supporte | GitHub Copilot | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | | | GitHub Copilot CLI | ✅ | | ✅ | | ✅ | ✅ | ✅ | | | Goose | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Hermes Agent | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Grok CLI | ✅ | | ✅ | | ✅ | ✅ | | ✅ | -| Hermes Agent | ✅ | | ✅ | | | | | | | Cursor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | deepagents-cli | ✅ | | ✅ | | ✅ | ✅ | ✅ | | | Factory Droid | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/cspell.json b/cspell.json index 15c60a2ae..27ac1766f 100644 --- a/cspell.json +++ b/cspell.json @@ -30,6 +30,12 @@ "agentsignore", "agentsmd", "agentsskills", + "isinstance", + "kwargs", + "pathlib", + "subagent", + "subagents", + "toolsets", "ampcode", "aiexclude", "aiglobal", diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index bc1204ebc..9206c3f28 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -15,8 +15,8 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ | | | GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | Goose | goose | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | 🌏 | +| Hermes Agent | hermesagent | ✅ | | 🌏 | 🌏 | ✅ 🌏 | 🌏 | 🌏 | 🌏 | | Grok CLI | grokcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | | 🌏 | -| Hermes Agent | hermesagent | ✅ | | 🌏 | | | | | | | Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | deepagents-cli | deepagents | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | 🌏 | | | Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index bc1204ebc..9206c3f28 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -15,8 +15,8 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ 🌏 | ✅ | ✅ | | | GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | | Goose | goose | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | 🌏 | +| Hermes Agent | hermesagent | ✅ | | 🌏 | 🌏 | ✅ 🌏 | 🌏 | 🌏 | 🌏 | | Grok CLI | grokcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | | 🌏 | -| Hermes Agent | hermesagent | ✅ | | 🌏 | | | | | | | Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | deepagents-cli | deepagents | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | 🌏 | | | Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | diff --git a/src/constants/hermesagent-paths.ts b/src/constants/hermesagent-paths.ts index 895c7448b..a1f9a451f 100644 --- a/src/constants/hermesagent-paths.ts +++ b/src/constants/hermesagent-paths.ts @@ -21,3 +21,30 @@ 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_PATH = join(HERMESAGENT_GLOBAL_DIR, HERMESAGENT_SOUL_FILE_NAME); +export const HERMESAGENT_CONFIG_FILE_PATH = join( + HERMESAGENT_GLOBAL_DIR, + HERMESAGENT_CONFIG_FILE_NAME, +); +export const HERMESAGENT_SKILLS_DIR_PATH = join(HERMESAGENT_GLOBAL_DIR, "skills"); +export const HERMESAGENT_RULESYNC_DIR_PATH = join(HERMESAGENT_GLOBAL_DIR, "rulesync"); +export const HERMESAGENT_RULESYNC_SUBAGENTS_DIR_PATH = join( + HERMESAGENT_RULESYNC_DIR_PATH, + "subagents", +); +export const HERMESAGENT_RULESYNC_SUBAGENTS_PLUGIN_DIR_PATH = join( + HERMESAGENT_GLOBAL_DIR, + "plugins", + "rulesync-subagents", +); +export const HERMESAGENT_RULESYNC_SUBAGENTS_PLUGIN_MANIFEST_PATH = join( + HERMESAGENT_RULESYNC_SUBAGENTS_PLUGIN_DIR_PATH, + "plugin.yaml", +); +export const HERMESAGENT_RULESYNC_SUBAGENTS_PLUGIN_INIT_PATH = join( + HERMESAGENT_RULESYNC_SUBAGENTS_PLUGIN_DIR_PATH, + "__init__.py", +); +import { join } from "node:path"; diff --git a/src/features/commands/commands-processor.test.ts b/src/features/commands/commands-processor.test.ts index 8049cbe06..9c8a7d079 100644 --- a/src/features/commands/commands-processor.test.ts +++ b/src/features/commands/commands-processor.test.ts @@ -1202,12 +1202,13 @@ describe("CommandsProcessor", () => { "claudecode", "claudecode-legacy", "cline", + "codexcli", "cursor", "factorydroid", "geminicli", "goose", + "hermesagent", "junie", - "codexcli", "kilo", "opencode", "pi", diff --git a/src/features/commands/commands-processor.ts b/src/features/commands/commands-processor.ts index fd0b9f140..4baa566b7 100644 --- a/src/features/commands/commands-processor.ts +++ b/src/features/commands/commands-processor.ts @@ -24,6 +24,7 @@ import { DevinCommand } from "./devin-command.js"; import { FactorydroidCommand } from "./factorydroid-command.js"; import { GeminiCliCommand } from "./geminicli-command.js"; import { GooseCommand } from "./goose-command.js"; +import { HermesagentCommand } from "./hermesagent-command.js"; import { JunieCommand } from "./junie-command.js"; import { KiloCommand } from "./kilo-command.js"; import { KiroCliCommand } from "./kiro-cli-command.js"; @@ -269,6 +270,19 @@ export const toolCommandFactories = new Map { + it("uses rulesync command description and body when generating SKILL.md", () => { + const rulesyncCommand = new RulesyncCommand({ + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: `${RULESYNC_COMMANDS_RELATIVE_DIR_PATH}/review.md`, + frontmatter: { + description: "Review the current changes", + }, + body: "Review the diff carefully.", + fileContent: "", + }); + + const command = HermesagentCommand.fromRulesyncCommand({ + outputRoot: ".", + rulesyncCommand, + }); + + expect(command.getFileContent()).toContain("description: Review the current changes"); + expect(command.getFileContent()).toContain("Review the diff carefully."); + expect(command.getFileContent()).not.toContain("targets:"); + }); + + it("strips Hermes skill frontmatter when importing back to rulesync command", () => { + const command = new HermesagentCommand({ + relativeDirPath: ".hermes/skills/review", + relativeFilePath: "SKILL.md", + fileContent: [ + "---", + "name: review", + "description: Review the current changes", + "---", + "", + "Review the diff carefully.", + "", + ].join("\n"), + }); + + const rulesyncCommand = command.toRulesyncCommand(); + + expect(rulesyncCommand.getFrontmatter().description).toBe("Review the current changes"); + expect(rulesyncCommand.getBody()).toBe("Review the diff carefully.\n"); + expect(rulesyncCommand.getBody()).not.toContain("---"); + }); +}); diff --git a/src/features/commands/hermesagent-command.ts b/src/features/commands/hermesagent-command.ts new file mode 100644 index 000000000..6974315ba --- /dev/null +++ b/src/features/commands/hermesagent-command.ts @@ -0,0 +1,86 @@ +import { basename, dirname, join } from "node:path"; + +import { HERMESAGENT_SKILLS_DIR_PATH } from "../../constants/hermesagent-paths.js"; +import { RULESYNC_COMMANDS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { type AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { parseFrontmatter, stringifyFrontmatter } from "../../utils/frontmatter.js"; +import { RulesyncCommand } from "./rulesync-command.js"; +import { ToolCommand, type ToolCommandFromRulesyncCommandParams } from "./tool-command.js"; + +const SKILL_FILE_NAME = "SKILL.md"; + +type HermesagentCommandParams = AiFileParams & { + slug?: string; +}; + +function commandSlug(relativeFilePath: string): string { + return basename(relativeFilePath, ".md").replace(/[^a-zA-Z0-9_-]/g, "-"); +} + +function commandSkillContent(rulesyncCommand: RulesyncCommand): string { + const slug = commandSlug(rulesyncCommand.getRelativeFilePath()); + const description = rulesyncCommand.getFrontmatter().description ?? `${slug} command`; + + return stringifyFrontmatter(rulesyncCommand.getBody().trim(), { + name: slug, + description, + }); +} + +export class HermesagentCommand extends ToolCommand { + static override isTargetedByRulesyncCommand(rulesyncCommand: RulesyncCommand): boolean { + const targets = rulesyncCommand.getFrontmatter().targets; + + return !targets || targets.includes("*") || targets.includes("hermesagent"); + } + + static getSettablePaths({ slug = "command" }: { slug?: string; global?: boolean } = {}) { + return { + relativeDirPath: join(HERMESAGENT_SKILLS_DIR_PATH, slug), + relativeFilePath: SKILL_FILE_NAME, + }; + } + + constructor({ slug, ...params }: HermesagentCommandParams) { + const resolvedSlug = + slug ?? basename(dirname(params.relativeDirPath)) ?? commandSlug(params.relativeFilePath); + super({ + ...params, + ...HermesagentCommand.getSettablePaths({ slug: resolvedSlug }), + }); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + toRulesyncCommand(): RulesyncCommand { + const slug = basename(dirname(this.getRelativePathFromCwd())); + const { frontmatter, body } = parseFrontmatter(this.getFileContent(), this.getFilePath()); + const description = + typeof frontmatter.description === "string" ? frontmatter.description : undefined; + + return new RulesyncCommand({ + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: `${slug}.md`, + frontmatter: { description }, + body: body.trimStart(), + } as ConstructorParameters[0]); + } + + static override fromRulesyncCommand({ + outputRoot, + rulesyncCommand, + }: ToolCommandFromRulesyncCommandParams): HermesagentCommand { + return new HermesagentCommand({ + outputRoot, + relativeDirPath: "", + relativeFilePath: SKILL_FILE_NAME, + slug: commandSlug(rulesyncCommand.getRelativeFilePath()), + fileContent: commandSkillContent(rulesyncCommand), + }); + } + getFileContent(): string { + return this.fileContent; + } +} diff --git a/src/features/hermes-config.test.ts b/src/features/hermes-config.test.ts new file mode 100644 index 000000000..4e7cef4e5 --- /dev/null +++ b/src/features/hermes-config.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { parseHermesConfig } from "./hermes-config.js"; + +describe("parseHermesConfig", () => { + it("returns empty config for non-object YAML roots", () => { + expect(parseHermesConfig("- item")).toEqual({}); + }); + + it("drops prototype-pollution keys recursively", () => { + const config = parseHermesConfig(` +model: hermes-3 +__proto__: + polluted: true +mcp_servers: + docs: + url: https://example.com/mcp + constructor: + polluted: true +plugins: + enabled: + - rulesync-subagents + prototype: + polluted: true +`); + + expect(config).toEqual({ + model: "hermes-3", + mcp_servers: { + docs: { + url: "https://example.com/mcp", + }, + }, + plugins: { + enabled: ["rulesync-subagents"], + }, + }); + expect(({} as Record).polluted).toBeUndefined(); + }); +}); diff --git a/src/features/hermes-config.ts b/src/features/hermes-config.ts new file mode 100644 index 000000000..0989f8200 --- /dev/null +++ b/src/features/hermes-config.ts @@ -0,0 +1,54 @@ +import { dump, load } from "js-yaml"; + +import { + omitPrototypePollutionKeys, + PROTOTYPE_POLLUTION_KEYS, +} from "../utils/prototype-pollution.js"; +import { isPlainObject } from "../utils/type-guards.js"; + +type HermesConfig = Record; + +function sanitizeHermesConfigValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sanitizeHermesConfigValue); + } + + if (!isPlainObject(value)) { + return value; + } + + const sanitized = omitPrototypePollutionKeys(value); + const result: Record = {}; + + for (const [key, nestedValue] of Object.entries(sanitized)) { + if (PROTOTYPE_POLLUTION_KEYS.has(key)) continue; + result[key] = sanitizeHermesConfigValue(nestedValue); + } + + return result; +} + +export function parseHermesConfig(fileContent: string): HermesConfig { + if (!fileContent.trim()) { + return {}; + } + + const parsed = load(fileContent); + + if (isPlainObject(parsed)) { + return sanitizeHermesConfigValue(parsed) as HermesConfig; + } + + return {}; +} + +export function stringifyHermesConfig(config: HermesConfig): string { + return dump(config, { noRefs: true, sortKeys: false }).trimEnd() + "\n"; +} + +export function mergeHermesConfig(fileContent: string, patch: HermesConfig): string { + return stringifyHermesConfig({ + ...parseHermesConfig(fileContent), + ...patch, + }); +} diff --git a/src/features/hooks/hermesagent-hooks.test.ts b/src/features/hooks/hermesagent-hooks.test.ts new file mode 100644 index 000000000..c66f45c03 --- /dev/null +++ b/src/features/hooks/hermesagent-hooks.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { parseHermesConfig } from "../hermes-config.js"; +import { HermesagentHooks } from "./hermesagent-hooks.js"; +import { RulesyncHooks } from "./rulesync-hooks.js"; + +describe("HermesagentHooks", () => { + it("preserves existing Hermes config when writing hooks", async () => { + const rulesyncHooks = new RulesyncHooks({ + relativeDirPath: ".rulesync", + relativeFilePath: "hooks.json", + fileContent: JSON.stringify({ + hooks: { + preToolUse: { + command: "pnpm lint", + }, + }, + }), + }); + + const hooks = await HermesagentHooks.fromRulesyncHooks({ + outputRoot: ".", + rulesyncHooks, + }); + + hooks.setFileContent(`model: hermes-3 +mcp_servers: + docs: + url: https://example.com/mcp +hooks: + rulesync: + stale: true +`); + + const config = parseHermesConfig(hooks.getFileContent()); + expect(config.model).toBe("hermes-3"); + expect(config.mcp_servers).toEqual({ + docs: { url: "https://example.com/mcp" }, + }); + expect(config.hooks).toEqual({ + rulesync: { + hooks: { + preToolUse: { + command: "pnpm lint", + }, + }, + }, + }); + }); +}); diff --git a/src/features/hooks/hermesagent-hooks.ts b/src/features/hooks/hermesagent-hooks.ts new file mode 100644 index 000000000..9dcdb4fcc --- /dev/null +++ b/src/features/hooks/hermesagent-hooks.ts @@ -0,0 +1,65 @@ +import { + HERMESAGENT_CONFIG_FILE_NAME, + HERMESAGENT_GLOBAL_DIR, +} from "../../constants/hermesagent-paths.js"; +import { type AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { mergeHermesConfig, parseHermesConfig, stringifyHermesConfig } from "../hermes-config.js"; +import { RulesyncHooks } from "./rulesync-hooks.js"; +import { ToolHooks, type ToolHooksFromRulesyncHooksParams } from "./tool-hooks.js"; + +type HermesagentHooksParams = Omit; + +export class HermesagentHooks extends ToolHooks { + static getSettablePaths() { + return { + relativeDirPath: HERMESAGENT_GLOBAL_DIR, + relativeFilePath: HERMESAGENT_CONFIG_FILE_NAME, + }; + } + + constructor(params: HermesagentHooksParams) { + super({ + ...params, + ...HermesagentHooks.getSettablePaths(), + }); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + shouldMergeExistingFileContent(): boolean { + return true; + } + + setFileContent(fileContent: string): void { + this.fileContent = mergeHermesConfig(fileContent, parseHermesConfig(this.fileContent)); + } + + toRulesyncHooks(): RulesyncHooks { + const config = parseHermesConfig(this.getFileContent()); + const hooks = + config.hooks && typeof config.hooks === "object" + ? (config.hooks as Record).rulesync + : {}; + return new RulesyncHooks({ + relativeDirPath: "", + relativeFilePath: ".rulesync/hooks.json", + fileContent: JSON.stringify(hooks ?? {}, null, 2), + }); + } + + static fromRulesyncHooks({ + outputRoot, + rulesyncHooks, + }: ToolHooksFromRulesyncHooksParams): HermesagentHooks { + return new HermesagentHooks({ + outputRoot, + fileContent: stringifyHermesConfig({ + hooks: { + rulesync: rulesyncHooks.getJson(), + }, + }), + }); + } +} diff --git a/src/features/hooks/hooks-processor.test.ts b/src/features/hooks/hooks-processor.test.ts index 30eed47b6..05dc8cdb4 100644 --- a/src/features/hooks/hooks-processor.test.ts +++ b/src/features/hooks/hooks-processor.test.ts @@ -519,6 +519,7 @@ describe("HooksProcessor", () => { "factorydroid", "geminicli", "goose", + "hermesagent", "deepagents", "devin", "augmentcode", @@ -562,6 +563,7 @@ describe("HooksProcessor", () => { "factorydroid", "geminicli", "goose", + "hermesagent", "deepagents", "devin", "augmentcode", diff --git a/src/features/hooks/hooks-processor.ts b/src/features/hooks/hooks-processor.ts index 902f0f52a..760e69432 100644 --- a/src/features/hooks/hooks-processor.ts +++ b/src/features/hooks/hooks-processor.ts @@ -41,6 +41,7 @@ import { DEVIN_HOOK_EVENTS, DevinHooks } from "./devin-hooks.js"; import { FactorydroidHooks } from "./factorydroid-hooks.js"; import { GeminicliHooks } from "./geminicli-hooks.js"; import { GooseHooks } from "./goose-hooks.js"; +import { HermesagentHooks } from "./hermesagent-hooks.js"; import { JunieHooks } from "./junie-hooks.js"; import { KiloHooks } from "./kilo-hooks.js"; import { KiroCliHooks } from "./kiro-cli-hooks.js"; @@ -259,6 +260,16 @@ export const toolHooksFactories = new Map = new Set(factory.supportedHookTypes); const unsupportedTypeToEvents = new Map>(); for (const [event, defs] of Object.entries(effectiveHooks)) { - for (const def of defs) { - const hookType = def.type ?? "command"; + for (const def of defs as unknown[]) { + const hookDef = def as { type?: string }; + const hookType = hookDef.type ?? "command"; if (!supportedHookTypes.has(hookType)) { const events = unsupportedTypeToEvents.get(hookType) ?? new Set(); events.add(event); @@ -549,8 +561,9 @@ export class HooksProcessor extends FeatureProcessor { if (!factory.supportsMatcher) { const eventsWithMatcher = new Set(); for (const [event, defs] of Object.entries(effectiveHooks)) { - for (const def of defs) { - if (def.matcher) { + for (const def of defs as unknown[]) { + const hookDef = def as { matcher?: unknown }; + if (hookDef.matcher) { eventsWithMatcher.add(event); } } diff --git a/src/features/mcp/hermesagent-mcp.test.ts b/src/features/mcp/hermesagent-mcp.test.ts index 29acc2e40..ff75f38e3 100644 --- a/src/features/mcp/hermesagent-mcp.test.ts +++ b/src/features/mcp/hermesagent-mcp.test.ts @@ -285,7 +285,7 @@ describe("HermesagentMcp", () => { expect(servers.fetch).toMatchObject({ command: "uvx", args: ["mcp-server-fetch"], - timeout: 120, + networkTimeout: 120, }); expect(servers.remote).toMatchObject({ url: "https://example.com/mcp" }); // `enabled: false` maps back to the canonical `disabled: true`. @@ -332,4 +332,61 @@ describe("HermesagentMcp", () => { expect(mcp.isDeletable()).toBe(false); }); }); + + it("converts Hermes timeout back to canonical networkTimeout", () => { + const mcp = new HermesagentMcp({ + outputRoot: testDir, + relativeDirPath: ".hermes", + relativeFilePath: HERMES_FILE, + fileContent: `mcp_servers: + fetch: + command: uvx + timeout: 120 +`, + global: true, + }); + + const rulesync = mcp.toRulesyncMcp(); + const servers = JSON.parse(rulesync.getFileContent()).mcpServers; + + expect(servers.fetch.networkTimeout).toBe(120); + expect(servers.fetch.timeout).toBeUndefined(); + }); + + it("merges generated MCP servers when existing Hermes config is loaded later", async () => { + const rulesyncMcp = new RulesyncMcp({ + outputRoot: testDir, + relativeDirPath: ".", + relativeFilePath: ".mcp.json", + fileContent: JSON.stringify({ + mcpServers: { + "test-server": { + command: "echo", + args: ["hello"], + env: {}, + }, + }, + }), + }); + + const mcp = await HermesagentMcp.fromRulesyncMcp({ + outputRoot: testDir, + rulesyncMcp, + global: true, + }); + + mcp.setFileContent(`model: hermes-3 +mcp_servers: + existing-server: + command: existing + test-server: + command: stale +`); + + const config = mcp.getConfig(); + expect(config.model).toBe("hermes-3"); + expect(getMcpServers(mcp.getFileContent())["existing-server"]?.command).toBe("existing"); + expect(getMcpServers(mcp.getFileContent())["test-server"]?.command).toBe("echo"); + expect(getMcpServers(mcp.getFileContent())["test-server"]?.args).toEqual(["hello"]); + }); }); diff --git a/src/features/mcp/hermesagent-mcp.ts b/src/features/mcp/hermesagent-mcp.ts index 39411265f..02c26b3f3 100644 --- a/src/features/mcp/hermesagent-mcp.ts +++ b/src/features/mcp/hermesagent-mcp.ts @@ -128,6 +128,21 @@ function convertToHermesFormat(mcpServers: McpServers): Record, + mcpServers: Record>, +): Record { + const existingMcpServers = isRecord(config.mcp_servers) ? config.mcp_servers : {}; + + return { + ...config, + mcp_servers: { + ...existingMcpServers, + ...mcpServers, + }, + }; +} + /** * Converts Hermes `mcp_servers:` entries back into rulesync canonical MCP servers. * @@ -147,7 +162,7 @@ function convertFromHermesFormat(mcpServers: Record): McpServer if (typeof config.url === "string") server.url = config.url; if (isPlainObject(config.headers)) server.headers = omitPrototypePollutionKeys(config.headers); if (config.enabled === false) server.disabled = true; - if (typeof config.timeout === "number") server.timeout = config.timeout; + if (typeof config.timeout === "number") server.networkTimeout = config.timeout; result[name] = server; } @@ -166,7 +181,7 @@ function convertFromHermesFormat(mcpServers: Record): McpServer * the file is never deleted. */ export class HermesagentMcp extends ToolMcp { - private readonly config: Record; + private config: Record; constructor(params: ToolMcpParams) { super(params); @@ -185,6 +200,22 @@ export class HermesagentMcp extends ToolMcp { return this.config; } + override shouldMergeExistingFileContent(): boolean { + return true; + } + + override setFileContent(fileContent: string): void { + const config = parseHermesConfig(fileContent, this.relativeDirPath, this.relativeFilePath); + const mcpServers = isRecord(this.config.mcp_servers) ? this.config.mcp_servers : {}; + const merged = mergeHermesMcpServers( + config, + mcpServers as Record>, + ); + + this.config = merged; + super.setFileContent(dump(merged)); + } + override isDeletable(): boolean { // config.yaml holds other Hermes settings, so it must never be removed // wholesale; clearing MCP happens via an in-place merge instead. @@ -239,10 +270,10 @@ export class HermesagentMcp extends ToolMcp { // Merge the `mcp_servers:` block into the shared config, preserving other // keys (model, terminal, ...). - const merged = { - ...config, - mcp_servers: convertToHermesFormat(rulesyncMcp.getMcpServers()), - }; + const merged = mergeHermesMcpServers( + config, + convertToHermesFormat(rulesyncMcp.getMcpServers()), + ); return new HermesagentMcp({ outputRoot, diff --git a/src/features/permissions/hermesagent-permissions.test.ts b/src/features/permissions/hermesagent-permissions.test.ts new file mode 100644 index 000000000..38b4de856 --- /dev/null +++ b/src/features/permissions/hermesagent-permissions.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { parseHermesConfig } from "../hermes-config.js"; +import { HermesagentPermissions } from "./hermesagent-permissions.js"; +import { RulesyncPermissions } from "./rulesync-permissions.js"; + +describe("HermesagentPermissions", () => { + it("keeps allowed command patterns in command_allowlist", async () => { + const rulesyncPermissions = new RulesyncPermissions({ + relativeDirPath: ".rulesync", + relativeFilePath: "permissions.json", + fileContent: JSON.stringify({ + permission: { + bash: { + "git *": "allow", + "pnpm *": "allow", + "rm *": "deny", + "*": "ask", + }, + }, + }), + }); + + const permissions = await HermesagentPermissions.fromRulesyncPermissions({ + outputRoot: ".", + rulesyncPermissions, + }); + + const config = parseHermesConfig(permissions.getFileContent()); + expect(config.command_allowlist).toEqual(["git *", "pnpm *"]); + }); + + it("preserves existing Hermes config when writing permissions", async () => { + const rulesyncPermissions = new RulesyncPermissions({ + relativeDirPath: ".rulesync", + relativeFilePath: "permissions.json", + fileContent: JSON.stringify({ + permission: { + bash: { + "git *": "allow", + }, + }, + }), + }); + + const permissions = await HermesagentPermissions.fromRulesyncPermissions({ + outputRoot: ".", + rulesyncPermissions, + }); + + permissions.setFileContent(`model: hermes-3 +mcp_servers: + docs: + url: https://example.com/mcp +`); + + const config = parseHermesConfig(permissions.getFileContent()); + expect(config.model).toBe("hermes-3"); + expect(config.mcp_servers).toEqual({ + docs: { url: "https://example.com/mcp" }, + }); + expect(config.command_allowlist).toEqual(["git *"]); + expect(config.permissions).toEqual({ + rulesync: { + permission: { + bash: { + "git *": "allow", + }, + }, + }, + }); + }); +}); diff --git a/src/features/permissions/hermesagent-permissions.ts b/src/features/permissions/hermesagent-permissions.ts new file mode 100644 index 000000000..692a922a7 --- /dev/null +++ b/src/features/permissions/hermesagent-permissions.ts @@ -0,0 +1,76 @@ +import { + HERMESAGENT_CONFIG_FILE_NAME, + HERMESAGENT_GLOBAL_DIR, +} from "../../constants/hermesagent-paths.js"; +import { type AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { mergeHermesConfig, parseHermesConfig, stringifyHermesConfig } from "../hermes-config.js"; +import { RulesyncPermissions } from "./rulesync-permissions.js"; +import { + ToolPermissions, + type ToolPermissionsFromRulesyncPermissionsParams, +} from "./tool-permissions.js"; + +type HermesagentPermissionsParams = Omit; + +export class HermesagentPermissions extends ToolPermissions { + static getSettablePaths() { + return { + relativeDirPath: HERMESAGENT_GLOBAL_DIR, + relativeFilePath: HERMESAGENT_CONFIG_FILE_NAME, + }; + } + + constructor(params: HermesagentPermissionsParams) { + super({ + ...params, + ...HermesagentPermissions.getSettablePaths(), + }); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + shouldMergeExistingFileContent(): boolean { + return true; + } + + setFileContent(fileContent: string): void { + this.fileContent = mergeHermesConfig(fileContent, parseHermesConfig(this.fileContent)); + } + + toRulesyncPermissions(): RulesyncPermissions { + const config = parseHermesConfig(this.getFileContent()); + const permissions = + config.permissions && typeof config.permissions === "object" + ? (config.permissions as Record).rulesync + : {}; + return new RulesyncPermissions({ + relativeDirPath: "", + relativeFilePath: ".rulesync/permissions.json", + fileContent: JSON.stringify(permissions ?? {}, null, 2), + }); + } + + static fromRulesyncPermissions({ + outputRoot, + rulesyncPermissions, + }: ToolPermissionsFromRulesyncPermissionsParams): HermesagentPermissions { + const permissions = rulesyncPermissions.getJson(); + const commandAllowlist = Object.entries(permissions.permission ?? {}).flatMap(([, patterns]) => + Object.entries(patterns) + .filter(([, action]) => action === "allow") + .map(([command]) => command), + ); + + return new HermesagentPermissions({ + outputRoot, + fileContent: stringifyHermesConfig({ + command_allowlist: commandAllowlist, + permissions: { + rulesync: permissions, + }, + }), + }); + } +} diff --git a/src/features/permissions/permissions-processor.test.ts b/src/features/permissions/permissions-processor.test.ts index 3ed638e66..8edf82027 100644 --- a/src/features/permissions/permissions-processor.test.ts +++ b/src/features/permissions/permissions-processor.test.ts @@ -113,6 +113,7 @@ describe("PermissionsProcessor", () => { "geminicli", "goose", "grokcli", + "hermesagent", "kilo", "opencode", "qwencode", diff --git a/src/features/permissions/permissions-processor.ts b/src/features/permissions/permissions-processor.ts index 188d2292d..183872423 100644 --- a/src/features/permissions/permissions-processor.ts +++ b/src/features/permissions/permissions-processor.ts @@ -20,6 +20,7 @@ import { FactorydroidPermissions } from "./factorydroid-permissions.js"; import { GeminicliPermissions } from "./geminicli-permissions.js"; import { GoosePermissions } from "./goose-permissions.js"; import { GrokcliPermissions } from "./grokcli-permissions.js"; +import { HermesagentPermissions } from "./hermesagent-permissions.js"; import { KiloPermissions } from "./kilo-permissions.js"; import { KiroPermissions } from "./kiro-permissions.js"; import { OpencodePermissions } from "./opencode-permissions.js"; @@ -214,6 +215,17 @@ export const toolPermissionsFactories = new Map< }, }, ], + [ + "hermesagent", + { + class: HermesagentPermissions, + meta: { + supportsProject: false, + supportsGlobal: true, + supportsImport: true, + }, + }, + ], [ "kilo", { diff --git a/src/features/rules/rules-processor.test.ts b/src/features/rules/rules-processor.test.ts index 26fe81d66..ee099fdc3 100644 --- a/src/features/rules/rules-processor.test.ts +++ b/src/features/rules/rules-processor.test.ts @@ -1019,6 +1019,7 @@ Content that would fail parsing`; // These targets should NOT be in global mode expect(globalTargets).not.toContain("cursor"); + expect(globalTargets).not.toContain("hermesagent"); expect(globalTargets).not.toContain("warp"); }); }); diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index ae621de5f..1b7c99ab8 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -489,33 +489,28 @@ export const toolRuleFactories = new Map { + test("generates native Hermes delegation plugin files", () => { + const rulesyncSubagent = new RulesyncSubagent({ + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: `${RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH}/reviewer.md`, + frontmatter: { + name: "Reviewer", + description: "Review code changes", + }, + body: "Review the code carefully.", + }); + + const files = HermesagentSubagent.fromRulesyncSubagents({ + rulesyncSubagents: [rulesyncSubagent], + }); + + expect(files.map((file) => file.getRelativeFilePath()).toSorted()).toEqual( + [`reviewer.json`, `plugin.yaml`, `__init__.py`, `config.yaml`].toSorted(), + ); + + const subagentSpec = files.find((file) => file.getRelativeFilePath() === `reviewer.json`); + expect(JSON.parse(subagentSpec?.getFileContent() ?? "{}")).toMatchObject({ + slug: "reviewer", + name: "Reviewer", + description: "Review code changes", + prompt: "Review the code carefully.", + hermes: { + command: "rulesync_subagent_reviewer", + dispatch: "delegate_task", + }, + }); + + const plugin = files.find((file) => file.getRelativeFilePath() === `__init__.py`); + expect(plugin?.getFileContent()).toContain("ctx.dispatch_tool("); + expect(plugin?.getFileContent()).toContain('"delegate_task"'); + expect(plugin?.getFileContent()).toContain("ctx.register_command"); + + const manifest = files.find((file) => file.getRelativeFilePath() === `plugin.yaml`); + expect(manifest?.getFileContent()).toContain("name: rulesync-subagents"); + + const config = files.find((file) => file.getRelativeFilePath() === `config.yaml`); + expect(config?.getFileContent()).toContain("rulesync-subagents"); + }); + + test("declares the Hermes subagent directory as settable", () => { + expect(HermesagentSubagent.getSettablePaths()).toEqual({ + relativeDirPath: HERMESAGENT_RULESYNC_SUBAGENTS_DIR_PATH, + }); + }); + + test("declares per-subagent generated paths", () => { + const rulesyncSubagent = new RulesyncSubagent({ + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: `${RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH}/reviewer.md`, + frontmatter: { + name: "Reviewer", + }, + body: "Review the code carefully.", + }); + + expect(HermesagentSubagent.getSettablePathsForRulesyncSubagent(rulesyncSubagent)).toEqual([ + `${HERMESAGENT_RULESYNC_SUBAGENTS_DIR_PATH}/reviewer.json`, + ]); + }); + + test("uses Hermes plugin directories", () => { + expect(HERMESAGENT_RULESYNC_SUBAGENTS_PLUGIN_DIR_PATH).toBe( + `${HERMESAGENT_GLOBAL_DIR}/plugins/rulesync-subagents`, + ); + }); + + test("preserves existing Hermes config when enabling subagents plugin", () => { + const files = HermesagentSubagent.fromRulesyncSubagents({ + rulesyncSubagents: [], + }); + const config = files.find((file) => file.getRelativeFilePath() === "config.yaml"); + + config?.setFileContent(`model: hermes-3 +mcp_servers: + docs: + url: https://example.com/mcp +plugins: + enabled: + - existing-plugin +`); + + const parsed = parseHermesConfig(config?.getFileContent() ?? ""); + expect(parsed.model).toBe("hermes-3"); + expect(parsed.mcp_servers).toEqual({ + docs: { url: "https://example.com/mcp" }, + }); + expect(parsed.plugins).toEqual({ + enabled: ["existing-plugin", "rulesync-subagents"], + }); + }); +}); diff --git a/src/features/subagents/hermesagent-subagent.ts b/src/features/subagents/hermesagent-subagent.ts new file mode 100644 index 000000000..b82a12a5d --- /dev/null +++ b/src/features/subagents/hermesagent-subagent.ts @@ -0,0 +1,305 @@ +import { readFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; + +import { + HERMESAGENT_CONFIG_FILE_PATH, + HERMESAGENT_GLOBAL_DIR, + HERMESAGENT_RULESYNC_SUBAGENTS_DIR_PATH, + HERMESAGENT_RULESYNC_SUBAGENTS_PLUGIN_DIR_PATH, + HERMESAGENT_RULESYNC_SUBAGENTS_PLUGIN_INIT_PATH, + HERMESAGENT_RULESYNC_SUBAGENTS_PLUGIN_MANIFEST_PATH, +} from "../../constants/hermesagent-paths.js"; +import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { type ValidationResult } from "../../types/ai-file.js"; +import { parseHermesConfig, stringifyHermesConfig } from "../hermes-config.js"; +import { RulesyncSubagent } from "./rulesync-subagent.js"; +import { + ToolSubagent, + type ToolSubagentForDeletionParams, + type ToolSubagentFromFileParams, + type ToolSubagentFromRulesyncSubagentParams, +} from "./tool-subagent.js"; + +type ToolSubagentsFromRulesyncSubagentsParams = { + rulesyncSubagents: RulesyncSubagent[]; + outputRoot?: string; +}; + +function subagentSlug(relativeFilePath: string): string { + return basename(relativeFilePath, ".md").replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +function hermesCommandName(slug: string): string { + return `rulesync_subagent_${slug}`; +} + +function getPluginManifestContent(): string { + return [ + "name: rulesync-subagents", + 'version: "1.0.0"', + "description: Exposes RuleSync subagents as Hermes native delegation commands.", + "", + ].join("\n"); +} + +function getPluginInitContent(): string { + return `"""RuleSync-generated Hermes subagent commands.""" + +import json +from pathlib import Path + + +SUBAGENTS_DIR = Path.home() / ".hermes" / "rulesync" / "subagents" + + +def _load_subagents(): + if not SUBAGENTS_DIR.exists(): + return [] + + subagents = [] + for path in sorted(SUBAGENTS_DIR.glob("*.json")): + try: + subagent = json.loads(path.read_text(encoding="utf-8")) + except Exception: + continue + if isinstance(subagent, dict): + subagent["_path"] = str(path) + subagents.append(subagent) + return subagents + + +def _register_subagent(ctx, subagent): + slug = subagent.get("slug") + if not slug: + return + + command_name = f"rulesync_subagent_{slug}" + name = subagent.get("name") or slug + description = subagent.get("description") or f"Delegate work to the {name} RuleSync subagent." + system_prompt = subagent.get("prompt") or "" + toolsets = subagent.get("toolsets") or ["terminal", "file", "web"] + + def handler(args=None, **kwargs): + del kwargs + user_context = "" + if isinstance(args, dict): + user_context = args.get("context") or args.get("task") or args.get("prompt") or "" + elif args is not None: + user_context = str(args) + + context_parts = [] + if system_prompt: + context_parts.append(system_prompt) + if user_context: + context_parts.append(user_context) + + return ctx.dispatch_tool( + "delegate_task", + { + "goal": description, + "context": "\\n\\n".join(context_parts), + "toolsets": toolsets, + }, + ) + + ctx.register_command(command_name, handler, description) + + +def register(ctx): + for subagent in _load_subagents(): + _register_subagent(ctx, subagent) +`; +} + +function getEnabledPluginConfigContent(currentContent: string): string { + const config = parseHermesConfig(currentContent); + const plugins = + config.plugins && typeof config.plugins === "object" + ? (config.plugins as Record) + : {}; + const enabled = Array.isArray(plugins.enabled) ? plugins.enabled : []; + + config.plugins = { + ...plugins, + enabled: Array.from(new Set([...enabled, "rulesync-subagents"])), + }; + + return stringifyHermesConfig(config); +} + +function getSubagentSpec(rulesyncSubagent: RulesyncSubagent): Record { + const json = rulesyncSubagent.getFrontmatter(); + const slug = subagentSlug(rulesyncSubagent.getRelativePathFromCwd()); + const name = typeof json.name === "string" && json.name.length > 0 ? json.name : slug; + const description = + typeof json.description === "string" && json.description.length > 0 + ? json.description + : `Delegate work to the ${name} RuleSync subagent.`; + + return { + slug, + name, + description, + prompt: rulesyncSubagent.getBody(), + toolsets: ["terminal", "file", "web"], + hermes: { + command: hermesCommandName(slug), + dispatch: "delegate_task", + }, + }; +} + +export class HermesagentSubagent extends ToolSubagent { + static forDeletion({ + global = false, + outputRoot, + relativeDirPath, + relativeFilePath, + }: ToolSubagentForDeletionParams): HermesagentSubagent { + return new HermesagentSubagent({ + fileContent: "", + global, + outputRoot, + relativeDirPath, + relativeFilePath, + validate: false, + }); + } + + static async fromFile({ + global = false, + outputRoot = process.cwd(), + relativeFilePath, + validate = true, + }: ToolSubagentFromFileParams): Promise { + return new HermesagentSubagent({ + fileContent: await readFile(join(outputRoot, relativeFilePath), "utf8"), + global, + outputRoot, + relativeDirPath: dirname(relativeFilePath), + relativeFilePath: basename(relativeFilePath), + validate, + }); + } + + static isTargetedByRulesyncSubagent(rulesyncSubagent: RulesyncSubagent): boolean { + const targets = rulesyncSubagent.getFrontmatter().targets; + + return !targets || targets.includes("*") || targets.includes("hermesagent"); + } + + static fromRulesyncSubagents({ + rulesyncSubagents, + outputRoot, + }: ToolSubagentsFromRulesyncSubagentsParams): HermesagentSubagent[] { + return [ + ...rulesyncSubagents.map((rulesyncSubagent) => + HermesagentSubagent.fromRulesyncSubagent({ + relativeDirPath: HERMESAGENT_RULESYNC_SUBAGENTS_DIR_PATH, + rulesyncSubagent, + outputRoot, + }), + ), + new HermesagentSubagent({ + relativeDirPath: HERMESAGENT_RULESYNC_SUBAGENTS_PLUGIN_DIR_PATH, + relativeFilePath: basename(HERMESAGENT_RULESYNC_SUBAGENTS_PLUGIN_MANIFEST_PATH), + fileContent: "", + outputRoot, + }), + new HermesagentSubagent({ + relativeDirPath: HERMESAGENT_RULESYNC_SUBAGENTS_PLUGIN_DIR_PATH, + relativeFilePath: basename(HERMESAGENT_RULESYNC_SUBAGENTS_PLUGIN_INIT_PATH), + fileContent: "", + outputRoot, + }), + new HermesagentSubagent({ + relativeDirPath: HERMESAGENT_GLOBAL_DIR, + relativeFilePath: basename(HERMESAGENT_CONFIG_FILE_PATH), + fileContent: "", + outputRoot, + }), + ]; + } + + static fromRulesyncSubagent({ + rulesyncSubagent, + outputRoot, + }: ToolSubagentFromRulesyncSubagentParams): HermesagentSubagent { + const spec = getSubagentSpec(rulesyncSubagent); + const slug = String(spec.slug); + + return new HermesagentSubagent({ + relativeDirPath: HERMESAGENT_RULESYNC_SUBAGENTS_DIR_PATH, + relativeFilePath: `${slug}.json`, + fileContent: `${JSON.stringify(spec, null, 2)}\n`, + outputRoot, + }); + } + + static getSettablePaths(): { relativeDirPath: string } { + return { + relativeDirPath: HERMESAGENT_RULESYNC_SUBAGENTS_DIR_PATH, + }; + } + + static getSettablePathsForRulesyncSubagent(rulesyncSubagent: RulesyncSubagent): string[] { + const slug = subagentSlug(rulesyncSubagent.getRelativePathFromCwd()); + + return [join(HERMESAGENT_RULESYNC_SUBAGENTS_DIR_PATH, `${slug}.json`)]; + } + + toRulesyncSubagent(): RulesyncSubagent { + const slug = basename(this.getRelativeFilePath(), ".json"); + const json = JSON.parse(this.getFileContent()) as { + name?: string; + description?: string; + prompt?: string; + }; + + return new RulesyncSubagent({ + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: join(RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, `${slug}.md`), + body: json.prompt ?? "", + frontmatter: { + name: json.name ?? slug, + description: json.description, + }, + outputRoot: this.outputRoot, + }); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + shouldMergeExistingFileContent(): boolean { + return this.getRelativeFilePath() === basename(HERMESAGENT_CONFIG_FILE_PATH); + } + + setFileContent(newFileContent: string): void { + if (this.getRelativeFilePath() === basename(HERMESAGENT_CONFIG_FILE_PATH)) { + super.setFileContent(getEnabledPluginConfigContent(newFileContent)); + return; + } + + super.setFileContent(newFileContent); + } + + getFileContent(): string { + if ( + this.getRelativeFilePath() === basename(HERMESAGENT_RULESYNC_SUBAGENTS_PLUGIN_MANIFEST_PATH) + ) { + return getPluginManifestContent(); + } + + if (this.getRelativeFilePath() === basename(HERMESAGENT_RULESYNC_SUBAGENTS_PLUGIN_INIT_PATH)) { + return getPluginInitContent(); + } + + if (this.getRelativeFilePath() === basename(HERMESAGENT_CONFIG_FILE_PATH)) { + return getEnabledPluginConfigContent(super.getFileContent()); + } + + return super.getFileContent(); + } +} diff --git a/src/features/subagents/subagents-processor.test.ts b/src/features/subagents/subagents-processor.test.ts index cb0f4275e..598112616 100644 --- a/src/features/subagents/subagents-processor.test.ts +++ b/src/features/subagents/subagents-processor.test.ts @@ -1037,6 +1037,7 @@ Second global content`; "factorydroid", "geminicli", "goose", + "hermesagent", "grokcli", "junie", "kilo", @@ -1089,6 +1090,7 @@ Second global content`; "factorydroid", "geminicli", "goose", + "hermesagent", "grokcli", "junie", "kilo", diff --git a/src/features/subagents/subagents-processor.ts b/src/features/subagents/subagents-processor.ts index fe27040d6..a8668c154 100644 --- a/src/features/subagents/subagents-processor.ts +++ b/src/features/subagents/subagents-processor.ts @@ -25,6 +25,7 @@ import { FactorydroidSubagent } from "./factorydroid-subagent.js"; import { GeminiCliSubagent } from "./geminicli-subagent.js"; import { GooseSubagent } from "./goose-subagent.js"; import { GrokcliSubagent } from "./grokcli-subagent.js"; +import { HermesagentSubagent } from "./hermesagent-subagent.js"; import { JunieSubagent } from "./junie-subagent.js"; import { KiloSubagent } from "./kilo-subagent.js"; import { KiroCliSubagent } from "./kiro-cli-subagent.js"; @@ -64,7 +65,7 @@ type ToolSubagentFactory = { outputRoot?: string; rulesyncSubagents: RulesyncSubagent[]; global?: boolean; - }): ToolSubagent; + }): ToolSubagent | ToolSubagent[]; fromFile(params: ToolSubagentFromFileParams): Promise; forDeletion(params: ToolSubagentForDeletionParams): ToolSubagent; getSettablePaths(options?: { global?: boolean }): ToolSubagentSettablePaths; @@ -237,6 +238,17 @@ export const toolSubagentFactories = new Map diff --git a/src/lib/generate.ts b/src/lib/generate.ts index 49ae59633..5aceab479 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 }); } - const toolFiles = await processor.convertRulesyncFilesToToolFiles(rulesyncFiles); + let toolFiles = await processor.convertRulesyncFilesToToolFiles(rulesyncFiles); return processFeatureGeneration({ config, processor, toolFiles, skipFilePaths }); } diff --git a/src/types/ai-file.ts b/src/types/ai-file.ts index e2b3be77c..85dd3c517 100644 --- a/src/types/ai-file.ts +++ b/src/types/ai-file.ts @@ -118,6 +118,10 @@ export abstract class AiFile { this.fileContent = newFileContent; } + shouldMergeExistingFileContent(): boolean { + return false; + } + /** * Returns whether this file can be deleted by rulesync. * Override in subclasses that should not be deleted (e.g., user-managed config files). diff --git a/src/types/feature-processor.ts b/src/types/feature-processor.ts index 907a5ce60..2d3b0fbdc 100644 --- a/src/types/feature-processor.ts +++ b/src/types/feature-processor.ts @@ -61,8 +61,19 @@ export abstract class FeatureProcessor { const changedPaths: string[] = []; for (const aiFile of aiFiles) { const filePath = aiFile.getFilePath(); + const existingFileContent = await readFileContentOrNull(filePath); + + if ( + existingFileContent !== null && + "shouldMergeExistingFileContent" in aiFile && + typeof aiFile.shouldMergeExistingFileContent === "function" && + aiFile.shouldMergeExistingFileContent() + ) { + aiFile.setFileContent(existingFileContent); + } + const contentWithNewline = addTrailingNewline(aiFile.getFileContent()); - const existingContent = await readFileContentOrNull(filePath); + const existingContent = existingFileContent; if ( fileContentsEquivalent({ diff --git a/src/types/tool-display.ts b/src/types/tool-display.ts index f252c4d89..bdc3a2f83 100644 --- a/src/types/tool-display.ts +++ b/src/types/tool-display.ts @@ -24,8 +24,8 @@ export const TOOL_DISPLAY: ReadonlyArray = [ { key: "copilot", label: "GitHub Copilot", group: "ai" }, { key: "copilotcli", label: "GitHub Copilot CLI", group: "ai" }, { key: "goose", label: "Goose", group: "ai" }, - { key: "grokcli", label: "Grok CLI", group: "ai" }, { key: "hermesagent", label: "Hermes Agent", group: "ai" }, + { key: "grokcli", label: "Grok CLI", group: "ai" }, { key: "cursor", label: "Cursor", group: "ai" }, { key: "deepagents", label: "deepagents-cli", group: "ai" }, { key: "factorydroid", label: "Factory Droid", group: "ai" }, diff --git a/src/types/tool-target-tuples.ts b/src/types/tool-target-tuples.ts index d54551283..ae7e94663 100644 --- a/src/types/tool-target-tuples.ts +++ b/src/types/tool-target-tuples.ts @@ -113,6 +113,7 @@ export const commandsProcessorToolTargetTuple = [ "factorydroid", "geminicli", "goose", + "hermesagent", "junie", "kilo", "kiro", @@ -143,6 +144,7 @@ export const subagentsProcessorToolTargetTuple = [ "geminicli", "goose", "grokcli", + "hermesagent", "junie", "kiro", "kiro-cli", @@ -175,6 +177,7 @@ export const skillsProcessorToolTargetTuple = [ "geminicli", "goose", "grokcli", + "hermesagent", "junie", "kilo", "kiro", @@ -206,6 +209,7 @@ export const hooksProcessorToolTargetTuple = [ "factorydroid", "geminicli", "goose", + "hermesagent", "deepagents", "kiro", "kiro-cli", @@ -229,6 +233,7 @@ export const permissionsProcessorToolTargetTuple = [ "geminicli", "goose", "grokcli", + "hermesagent", "kilo", "kiro", "kiro-cli",