diff --git a/src/features/hooks/claudecode-hooks.ts b/src/features/hooks/claudecode-hooks.ts index 4ecfe549b..14b1ca5af 100644 --- a/src/features/hooks/claudecode-hooks.ts +++ b/src/features/hooks/claudecode-hooks.ts @@ -7,8 +7,8 @@ import type { RulesyncHooks } from "./rulesync-hooks.js"; import { CLAUDE_HOOK_EVENTS, - CLAUDE_TO_CURSOR_EVENT_NAMES, - CURSOR_TO_CLAUDE_EVENT_NAMES, + CLAUDE_TO_CANONICAL_EVENT_NAMES, + CANONICAL_TO_CLAUDE_EVENT_NAMES, } from "../../types/hooks.js"; import { formatError } from "../../utils/error.js"; import { readFileContentOrNull, readOrInitializeFileContent } from "../../utils/file.js"; @@ -21,7 +21,7 @@ import { } from "./tool-hooks.js"; /** - * Convert canonical (Cursor-style) hooks config to Claude format. + * Convert canonical hooks config to Claude format. * Filters shared hooks to CLAUDE_HOOK_EVENTS, merges config.claudecode?.hooks, * then converts to PascalCase and Claude matcher/hooks structure. */ @@ -39,7 +39,7 @@ function canonicalToClaudeHooks(config: HooksConfig): Record }; const claude: Record = {}; for (const [eventName, definitions] of Object.entries(effectiveHooks)) { - const claudeEventName = CURSOR_TO_CLAUDE_EVENT_NAMES[eventName] ?? eventName; + const claudeEventName = CANONICAL_TO_CLAUDE_EVENT_NAMES[eventName] ?? eventName; const byMatcher = new Map(); for (const def of definitions) { const key = def.matcher ?? ""; @@ -97,7 +97,7 @@ function claudeHooksToCanonical(claudeHooks: unknown): HooksConfig["hooks"] { } const canonical: HooksConfig["hooks"] = {}; for (const [claudeEventName, matcherEntries] of Object.entries(claudeHooks)) { - const eventName = CLAUDE_TO_CURSOR_EVENT_NAMES[claudeEventName] ?? claudeEventName; + const eventName = CLAUDE_TO_CANONICAL_EVENT_NAMES[claudeEventName] ?? claudeEventName; if (!Array.isArray(matcherEntries)) continue; const defs: HooksConfig["hooks"][string] = []; for (const rawEntry of matcherEntries) { diff --git a/src/features/hooks/cursor-hooks.ts b/src/features/hooks/cursor-hooks.ts index 24bec5d8f..25404e9cd 100644 --- a/src/features/hooks/cursor-hooks.ts +++ b/src/features/hooks/cursor-hooks.ts @@ -5,7 +5,11 @@ import type { ValidationResult } from "../../types/ai-file.js"; import type { HooksConfig } from "../../types/hooks.js"; import type { RulesyncHooks } from "./rulesync-hooks.js"; -import { CURSOR_HOOK_EVENTS } from "../../types/hooks.js"; +import { + CURSOR_HOOK_EVENTS, + CURSOR_TO_CANONICAL_EVENT_NAMES, + CANONICAL_TO_CURSOR_EVENT_NAMES, +} from "../../types/hooks.js"; import { readFileContent } from "../../utils/file.js"; import { ToolHooks, @@ -69,9 +73,14 @@ export class CursorHooks extends ToolHooks { ...sharedHooks, ...config.cursor?.hooks, }; + const mappedHooks: HooksConfig["hooks"] = {}; + for (const [eventName, defs] of Object.entries(mergedHooks)) { + const cursorEventName = CANONICAL_TO_CURSOR_EVENT_NAMES[eventName] ?? eventName; + mappedHooks[cursorEventName] = defs; + } const cursorConfig = { version: config.version ?? 1, - hooks: mergedHooks, + hooks: mappedHooks, }; const fileContent = JSON.stringify(cursorConfig, null, 2); const paths = CursorHooks.getSettablePaths(); @@ -88,10 +97,15 @@ export class CursorHooks extends ToolHooks { toRulesyncHooks(): RulesyncHooks { const content = this.getFileContent(); const parsed: { version?: number; hooks?: HooksConfig["hooks"] } = JSON.parse(content); - const hooks = parsed.hooks ?? {}; + const cursorHooks = parsed.hooks ?? {}; + const canonicalHooks: HooksConfig["hooks"] = {}; + for (const [cursorEventName, defs] of Object.entries(cursorHooks)) { + const eventName = CURSOR_TO_CANONICAL_EVENT_NAMES[cursorEventName] ?? cursorEventName; + canonicalHooks[eventName] = defs; + } const version = parsed.version ?? 1; return this.toRulesyncHooksDefault({ - fileContent: JSON.stringify({ version, hooks }, null, 2), + fileContent: JSON.stringify({ version, hooks: canonicalHooks }, null, 2), }); } diff --git a/src/features/hooks/factorydroid-hooks.test.ts b/src/features/hooks/factorydroid-hooks.test.ts new file mode 100644 index 000000000..703ee2d81 --- /dev/null +++ b/src/features/hooks/factorydroid-hooks.test.ts @@ -0,0 +1,593 @@ +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { RULESYNC_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { FactorydroidHooks } from "./factorydroid-hooks.js"; +import { RulesyncHooks } from "./rulesync-hooks.js"; + +describe("FactorydroidHooks", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("should return .factory and settings.json for project mode", () => { + const paths = FactorydroidHooks.getSettablePaths({ global: false }); + expect(paths).toEqual({ relativeDirPath: ".factory", relativeFilePath: "settings.json" }); + }); + + it("should return .factory and settings.json for global mode", () => { + const paths = FactorydroidHooks.getSettablePaths({ global: true }); + expect(paths).toEqual({ relativeDirPath: ".factory", relativeFilePath: "settings.json" }); + }); + }); + + describe("fromRulesyncHooks", () => { + it("should filter shared hooks to Factory Droid-supported events and convert to PascalCase", async () => { + await ensureDir(join(testDir, ".factory")); + await writeFileContent(join(testDir, ".factory", "settings.json"), JSON.stringify({})); + + const config = { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: ".rulesync/hooks/session-start.sh" }], + stop: [{ command: ".rulesync/hooks/audit.sh" }], + afterFileEdit: [{ command: "format.sh" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const factorydroidHooks = await FactorydroidHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = factorydroidHooks.getFileContent(); + const parsed = JSON.parse(content); + expect(parsed.hooks.SessionStart).toBeDefined(); + expect(parsed.hooks.Stop).toBeDefined(); + expect(parsed.hooks.afterFileEdit).toBeUndefined(); + }); + + it("should prefix non-absolute commands with $FACTORY_PROJECT_DIR", async () => { + await ensureDir(join(testDir, ".factory")); + await writeFileContent(join(testDir, ".factory", "settings.json"), JSON.stringify({})); + + const config = { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: ".rulesync/hooks/session-start.sh" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const factorydroidHooks = await FactorydroidHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = factorydroidHooks.getFileContent(); + const parsed = JSON.parse(content); + const sessionStartEntry = parsed.hooks.SessionStart[0]; + expect(sessionStartEntry).toBeDefined(); + expect(sessionStartEntry.matcher).toBeUndefined(); + expect(sessionStartEntry.hooks[0].command).toContain("$FACTORY_PROJECT_DIR"); + expect(sessionStartEntry.hooks[0].command).toContain(".rulesync/hooks/session-start.sh"); + }); + + it("should not prefix commands that already start with $", async () => { + await ensureDir(join(testDir, ".factory")); + await writeFileContent(join(testDir, ".factory", "settings.json"), JSON.stringify({})); + + const config = { + version: 1, + hooks: { + sessionStart: [ + { type: "command", command: "$FACTORY_PROJECT_DIR/.factory/hooks/start.sh" }, + ], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const factorydroidHooks = await FactorydroidHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = factorydroidHooks.getFileContent(); + const parsed = JSON.parse(content); + expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe( + "$FACTORY_PROJECT_DIR/.factory/hooks/start.sh", + ); + }); + + it("should merge config.factorydroid.hooks on top of shared hooks", async () => { + await ensureDir(join(testDir, ".factory")); + await writeFileContent(join(testDir, ".factory", "settings.json"), JSON.stringify({})); + + const config = { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: "shared.sh" }], + }, + factorydroid: { + hooks: { + notification: [ + { + matcher: "permission_prompt", + command: "$FACTORY_PROJECT_DIR/.factory/hooks/notify.sh", + }, + ], + sessionStart: [{ type: "command", command: "factory-override.sh" }], + }, + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const factorydroidHooks = await FactorydroidHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = factorydroidHooks.getFileContent(); + const parsed = JSON.parse(content); + expect(parsed.hooks.SessionStart[0].hooks[0].command).toContain("factory-override.sh"); + expect(parsed.hooks.Notification).toBeDefined(); + expect(parsed.hooks.Notification[0].matcher).toBe("permission_prompt"); + }); + + it("should throw error with descriptive message when existing settings.json contains invalid JSON", async () => { + await ensureDir(join(testDir, ".factory")); + await writeFileContent(join(testDir, ".factory", "settings.json"), "invalid json {"); + + const config = { version: 1, hooks: {} }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + await expect( + FactorydroidHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }), + ).rejects.toThrow(/Failed to parse existing Factory Droid settings/); + }); + + it("should merge rulesync hooks into existing .factory/settings.json content", async () => { + await ensureDir(join(testDir, ".factory")); + await writeFileContent( + join(testDir, ".factory", "settings.json"), + JSON.stringify({ otherKey: "preserved" }), + ); + + const config = { + version: 1, + hooks: { sessionStart: [{ command: "echo" }] }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const factorydroidHooks = await FactorydroidHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = factorydroidHooks.getFileContent(); + const parsed = JSON.parse(content); + expect(parsed.otherKey).toBe("preserved"); + expect(parsed.hooks).toBeDefined(); + expect(parsed.hooks.SessionStart).toBeDefined(); + }); + + it("should handle hooks with matcher grouping", async () => { + await ensureDir(join(testDir, ".factory")); + await writeFileContent(join(testDir, ".factory", "settings.json"), JSON.stringify({})); + + const config = { + version: 1, + hooks: { + preToolUse: [ + { matcher: "Write", command: "lint.sh" }, + { matcher: "Write", command: "format.sh" }, + { matcher: "Edit", command: "validate.sh" }, + ], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const factorydroidHooks = await FactorydroidHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = factorydroidHooks.getFileContent(); + const parsed = JSON.parse(content); + expect(parsed.hooks.PreToolUse).toHaveLength(2); + + const writeEntry = parsed.hooks.PreToolUse.find( + (e: Record) => e.matcher === "Write", + ); + expect(writeEntry).toBeDefined(); + expect(writeEntry.hooks).toHaveLength(2); + + const editEntry = parsed.hooks.PreToolUse.find( + (e: Record) => e.matcher === "Edit", + ); + expect(editEntry).toBeDefined(); + expect(editEntry.hooks).toHaveLength(1); + }); + + it("should include timeout and prompt fields when present", async () => { + await ensureDir(join(testDir, ".factory")); + await writeFileContent(join(testDir, ".factory", "settings.json"), JSON.stringify({})); + + const config = { + version: 1, + hooks: { + preToolUse: [{ type: "prompt", prompt: "Check this tool call", timeout: 30000 }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const factorydroidHooks = await FactorydroidHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + + const content = factorydroidHooks.getFileContent(); + const parsed = JSON.parse(content); + const hookDef = parsed.hooks.PreToolUse[0].hooks[0]; + expect(hookDef.type).toBe("prompt"); + expect(hookDef.prompt).toBe("Check this tool call"); + expect(hookDef.timeout).toBe(30000); + }); + }); + + describe("toRulesyncHooks", () => { + it("should throw error with descriptive message when content contains invalid JSON", () => { + const factorydroidHooks = new FactorydroidHooks({ + baseDir: testDir, + relativeDirPath: ".factory", + relativeFilePath: "settings.json", + fileContent: "invalid json {", + validate: false, + }); + + expect(() => factorydroidHooks.toRulesyncHooks()).toThrow( + /Failed to parse Factory Droid hooks content/, + ); + }); + + it("should convert Factory Droid PascalCase hooks to canonical camelCase", () => { + const factorydroidHooks = new FactorydroidHooks({ + baseDir: testDir, + relativeDirPath: ".factory", + relativeFilePath: "settings.json", + fileContent: JSON.stringify({ + hooks: { + SessionStart: [ + { hooks: [{ type: "command", command: "$FACTORY_PROJECT_DIR/echo.sh" }] }, + ], + Stop: [{ hooks: [{ command: "audit.sh" }] }], + }, + }), + validate: false, + }); + + const rulesyncHooks = factorydroidHooks.toRulesyncHooks(); + const json = rulesyncHooks.getJson(); + expect(json.hooks.sessionStart).toHaveLength(1); + expect(json.hooks.sessionStart?.[0]?.command).toContain("echo.sh"); + expect(json.hooks.stop).toHaveLength(1); + }); + + it("should strip $FACTORY_PROJECT_DIR prefix from commands", () => { + const factorydroidHooks = new FactorydroidHooks({ + baseDir: testDir, + relativeDirPath: ".factory", + relativeFilePath: "settings.json", + fileContent: JSON.stringify({ + hooks: { + SessionStart: [ + { + hooks: [ + { type: "command", command: "$FACTORY_PROJECT_DIR/.rulesync/hooks/start.sh" }, + ], + }, + ], + }, + }), + validate: false, + }); + + const rulesyncHooks = factorydroidHooks.toRulesyncHooks(); + const json = rulesyncHooks.getJson(); + expect(json.hooks.sessionStart?.[0]?.command).toBe("./.rulesync/hooks/start.sh"); + }); + + it("should preserve matcher from entries", () => { + const factorydroidHooks = new FactorydroidHooks({ + baseDir: testDir, + relativeDirPath: ".factory", + relativeFilePath: "settings.json", + fileContent: JSON.stringify({ + hooks: { + PreToolUse: [ + { + matcher: "Write|Edit", + hooks: [{ type: "command", command: "format.sh" }], + }, + ], + }, + }), + validate: false, + }); + + const rulesyncHooks = factorydroidHooks.toRulesyncHooks(); + const json = rulesyncHooks.getJson(); + expect(json.hooks.preToolUse).toHaveLength(1); + expect(json.hooks.preToolUse?.[0]?.matcher).toBe("Write|Edit"); + }); + + it("should handle empty hooks object", () => { + const factorydroidHooks = new FactorydroidHooks({ + baseDir: testDir, + relativeDirPath: ".factory", + relativeFilePath: "settings.json", + fileContent: JSON.stringify({ hooks: {} }), + validate: false, + }); + + const rulesyncHooks = factorydroidHooks.toRulesyncHooks(); + const json = rulesyncHooks.getJson(); + expect(json.hooks).toEqual({}); + }); + + it("should handle missing hooks key", () => { + const factorydroidHooks = new FactorydroidHooks({ + baseDir: testDir, + relativeDirPath: ".factory", + relativeFilePath: "settings.json", + fileContent: JSON.stringify({}), + validate: false, + }); + + const rulesyncHooks = factorydroidHooks.toRulesyncHooks(); + const json = rulesyncHooks.getJson(); + expect(json.hooks).toEqual({}); + }); + + it("should skip entries that are not valid matcher entries", () => { + const factorydroidHooks = new FactorydroidHooks({ + baseDir: testDir, + relativeDirPath: ".factory", + relativeFilePath: "settings.json", + fileContent: JSON.stringify({ + hooks: { + SessionStart: [ + { hooks: [{ type: "command", command: "valid.sh" }] }, + "invalid-entry", + { matcher: 123, hooks: [] }, + ], + }, + }), + validate: false, + }); + + const rulesyncHooks = factorydroidHooks.toRulesyncHooks(); + const json = rulesyncHooks.getJson(); + expect(json.hooks.sessionStart).toHaveLength(1); + expect(json.hooks.sessionStart?.[0]?.command).toBe("valid.sh"); + }); + }); + + describe("fromFile", () => { + it("should load from .factory/settings.json when it exists", async () => { + await ensureDir(join(testDir, ".factory")); + await writeFileContent( + join(testDir, ".factory", "settings.json"), + JSON.stringify({ hooks: { SessionStart: [] } }), + ); + + const factorydroidHooks = await FactorydroidHooks.fromFile({ + baseDir: testDir, + validate: false, + }); + expect(factorydroidHooks).toBeInstanceOf(FactorydroidHooks); + const content = factorydroidHooks.getFileContent(); + const parsed = JSON.parse(content); + expect(parsed.hooks.SessionStart).toEqual([]); + }); + + it("should initialize empty hooks when .factory/settings.json does not exist", async () => { + const factorydroidHooks = await FactorydroidHooks.fromFile({ + baseDir: testDir, + validate: false, + }); + expect(factorydroidHooks).toBeInstanceOf(FactorydroidHooks); + const content = factorydroidHooks.getFileContent(); + const parsed = JSON.parse(content); + expect(parsed.hooks).toEqual({}); + }); + }); + + describe("isDeletable", () => { + it("should return false", () => { + const hooks = new FactorydroidHooks({ + baseDir: testDir, + relativeDirPath: ".factory", + relativeFilePath: "settings.json", + fileContent: "{}", + validate: false, + }); + expect(hooks.isDeletable()).toBe(false); + }); + }); + + describe("forDeletion", () => { + it("should return FactorydroidHooks instance with empty hooks for deletion path", () => { + const hooks = FactorydroidHooks.forDeletion({ + baseDir: testDir, + relativeDirPath: ".factory", + relativeFilePath: "settings.json", + }); + expect(hooks).toBeInstanceOf(FactorydroidHooks); + const parsed = JSON.parse(hooks.getFileContent()); + expect(parsed.hooks).toEqual({}); + }); + }); + + describe("round-trip", () => { + it("should preserve hooks through fromRulesyncHooks -> write -> fromFile -> toRulesyncHooks", async () => { + const config = { + version: 1, + hooks: { + sessionStart: [{ type: "command", command: ".rulesync/hooks/session-start.sh" }], + preToolUse: [{ matcher: "Write|Edit", command: "format.sh" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const factorydroidHooks = await FactorydroidHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + await ensureDir(join(testDir, ".factory")); + await writeFileContent(factorydroidHooks.getFilePath(), factorydroidHooks.getFileContent()); + + const loaded = await FactorydroidHooks.fromFile({ baseDir: testDir, validate: false }); + const backToRulesync = loaded.toRulesyncHooks(); + const json = backToRulesync.getJson(); + expect(json.hooks.sessionStart).toHaveLength(1); + expect(json.hooks.sessionStart?.[0]?.command).toBe("./.rulesync/hooks/session-start.sh"); + expect(json.hooks.preToolUse).toHaveLength(1); + expect(json.hooks.preToolUse?.[0]?.matcher).toBe("Write|Edit"); + }); + + it("should preserve all supported event types through round-trip", async () => { + const config = { + version: 1, + hooks: { + sessionStart: [{ command: "start.sh" }], + sessionEnd: [{ command: "end.sh" }], + preToolUse: [{ command: "pre.sh" }], + postToolUse: [{ command: "post.sh" }], + beforeSubmitPrompt: [{ command: "prompt.sh" }], + stop: [{ command: "stop.sh" }], + subagentStop: [{ command: "subagent.sh" }], + preCompact: [{ command: "compact.sh" }], + permissionRequest: [{ command: "perm.sh" }], + notification: [{ command: "notify.sh" }], + setup: [{ command: "setup.sh" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + baseDir: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const factorydroidHooks = await FactorydroidHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: false, + }); + await ensureDir(join(testDir, ".factory")); + await writeFileContent(factorydroidHooks.getFilePath(), factorydroidHooks.getFileContent()); + + const loaded = await FactorydroidHooks.fromFile({ baseDir: testDir, validate: false }); + const backToRulesync = loaded.toRulesyncHooks(); + const json = backToRulesync.getJson(); + + const expectedEvents = [ + "sessionStart", + "sessionEnd", + "preToolUse", + "postToolUse", + "beforeSubmitPrompt", + "stop", + "subagentStop", + "preCompact", + "permissionRequest", + "notification", + "setup", + ]; + for (const event of expectedEvents) { + expect(json.hooks[event]).toHaveLength(1); + } + }); + }); +}); diff --git a/src/features/hooks/factorydroid-hooks.ts b/src/features/hooks/factorydroid-hooks.ts index 477fe6d27..17447d099 100644 --- a/src/features/hooks/factorydroid-hooks.ts +++ b/src/features/hooks/factorydroid-hooks.ts @@ -6,9 +6,9 @@ import type { HooksConfig } from "../../types/hooks.js"; import type { RulesyncHooks } from "./rulesync-hooks.js"; import { - CLAUDE_HOOK_EVENTS, - CLAUDE_TO_CURSOR_EVENT_NAMES, - CURSOR_TO_CLAUDE_EVENT_NAMES, + FACTORYDROID_HOOK_EVENTS, + FACTORYDROID_TO_CANONICAL_EVENT_NAMES, + CANONICAL_TO_FACTORYDROID_EVENT_NAMES, } from "../../types/hooks.js"; import { formatError } from "../../utils/error.js"; import { readFileContentOrNull, readOrInitializeFileContent } from "../../utils/file.js"; @@ -21,13 +21,12 @@ import { } from "./tool-hooks.js"; /** - * Convert canonical (Cursor-style) hooks config to Factory Droid format. - * Factory Droid uses the same PascalCase event names and matcher/hooks - * structure as Claude Code, but with $FACTORY_PROJECT_DIR instead of - * $CLAUDE_PROJECT_DIR. + * Convert canonical hooks config to Factory Droid format. + * Factory Droid uses PascalCase event names and a matcher/hooks structure, + * with $FACTORY_PROJECT_DIR as the project directory variable. */ function canonicalToFactorydroidHooks(config: HooksConfig): Record { - const supported: Set = new Set(CLAUDE_HOOK_EVENTS); + const supported: Set = new Set(FACTORYDROID_HOOK_EVENTS); const sharedHooks: HooksConfig["hooks"] = {}; for (const [event, defs] of Object.entries(config.hooks)) { if (supported.has(event)) { @@ -36,11 +35,11 @@ function canonicalToFactorydroidHooks(config: HooksConfig): Record = {}; for (const [eventName, definitions] of Object.entries(effectiveHooks)) { - const pascalEventName = CURSOR_TO_CLAUDE_EVENT_NAMES[eventName] ?? eventName; + const pascalEventName = CANONICAL_TO_FACTORYDROID_EVENT_NAMES[eventName] ?? eventName; const byMatcher = new Map(); for (const def of definitions) { const key = def.matcher ?? ""; @@ -93,7 +92,7 @@ function factorydroidHooksToCanonical(hooks: unknown): HooksConfig["hooks"] { } const canonical: HooksConfig["hooks"] = {}; for (const [pascalEventName, matcherEntries] of Object.entries(hooks)) { - const eventName = CLAUDE_TO_CURSOR_EVENT_NAMES[pascalEventName] ?? pascalEventName; + const eventName = FACTORYDROID_TO_CANONICAL_EVENT_NAMES[pascalEventName] ?? pascalEventName; if (!Array.isArray(matcherEntries)) continue; const defs: HooksConfig["hooks"][string] = []; for (const rawEntry of matcherEntries) { diff --git a/src/features/hooks/hooks-processor.ts b/src/features/hooks/hooks-processor.ts index 05c96e9d6..8bec0a232 100644 --- a/src/features/hooks/hooks-processor.ts +++ b/src/features/hooks/hooks-processor.ts @@ -14,6 +14,7 @@ import { FeatureProcessor } from "../../types/feature-processor.js"; import { CLAUDE_HOOK_EVENTS, CURSOR_HOOK_EVENTS, + FACTORYDROID_HOOK_EVENTS, OPENCODE_HOOK_EVENTS, type HookEvent, type HookType, @@ -84,7 +85,7 @@ const toolHooksFactories = new Map([ { class: FactorydroidHooks, meta: { supportsProject: true, supportsGlobal: true, supportsImport: true }, - supportedEvents: CLAUDE_HOOK_EVENTS, + supportedEvents: FACTORYDROID_HOOK_EVENTS, supportedHookTypes: ["command", "prompt"], }, ], diff --git a/src/features/hooks/opencode-hooks.ts b/src/features/hooks/opencode-hooks.ts index 4498a4045..6db71a2da 100644 --- a/src/features/hooks/opencode-hooks.ts +++ b/src/features/hooks/opencode-hooks.ts @@ -5,8 +5,8 @@ import type { HooksConfig } from "../../types/hooks.js"; import type { RulesyncHooks } from "./rulesync-hooks.js"; import { + CANONICAL_TO_OPENCODE_EVENT_NAMES, CONTROL_CHARS, - CURSOR_TO_OPENCODE_EVENT_NAMES, OPENCODE_HOOK_EVENTS, } from "../../types/hooks.js"; import { readFileContent } from "../../utils/file.js"; @@ -82,7 +82,7 @@ function groupByOpencodeEvent(config: HooksConfig): { const namedEventHandlers: Record = {}; const genericEventHandlers: Record = {}; for (const [canonicalEvent, definitions] of Object.entries(effectiveHooks)) { - const opencodeEvent = CURSOR_TO_OPENCODE_EVENT_NAMES[canonicalEvent]; + const opencodeEvent = CANONICAL_TO_OPENCODE_EVENT_NAMES[canonicalEvent]; if (!opencodeEvent) continue; const handlers: OpencodeHandler[] = []; diff --git a/src/types/hooks.ts b/src/types/hooks.ts index 92bbc4ac3..64eba9e7d 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -20,7 +20,7 @@ const safeString = z.pipe( ); /** - * Canonical hook definition (Cursor-style). + * Canonical hook definition. * Used in .rulesync/hooks.json and mapped to tool-specific formats. */ export const HookDefinitionSchema = z.looseObject({ @@ -116,10 +116,25 @@ export const OPENCODE_HOOK_EVENTS: readonly HookEvent[] = [ "permissionRequest", ]; +/** Hook events supported by Factory Droid. */ +export const FACTORYDROID_HOOK_EVENTS: readonly HookEvent[] = [ + "sessionStart", + "sessionEnd", + "preToolUse", + "postToolUse", + "beforeSubmitPrompt", + "stop", + "subagentStop", + "preCompact", + "permissionRequest", + "notification", + "setup", +]; + const hooksRecordSchema = z.record(z.string(), z.array(HookDefinitionSchema)); /** - * Canonical hooks config (Cursor-style event names in camelCase). + * Canonical hooks config (canonical event names in camelCase). */ export const HooksConfigSchema = z.looseObject({ version: z.optional(z.number()), @@ -133,10 +148,9 @@ export const HooksConfigSchema = z.looseObject({ export type HooksConfig = z.infer; /** - * Map canonical (Cursor) camelCase event names to Claude PascalCase. - * Includes common and Claude-only events. + * Map canonical camelCase event names to Claude PascalCase. */ -export const CURSOR_TO_CLAUDE_EVENT_NAMES: Record = { +export const CANONICAL_TO_CLAUDE_EVENT_NAMES: Record = { sessionStart: "SessionStart", sessionEnd: "SessionEnd", preToolUse: "PreToolUse", @@ -153,15 +167,72 @@ export const CURSOR_TO_CLAUDE_EVENT_NAMES: Record = { /** * Map Claude PascalCase event names to canonical camelCase. */ -export const CLAUDE_TO_CURSOR_EVENT_NAMES: Record = Object.fromEntries( - Object.entries(CURSOR_TO_CLAUDE_EVENT_NAMES).map(([k, v]) => [v, k]), +export const CLAUDE_TO_CANONICAL_EVENT_NAMES: Record = Object.fromEntries( + Object.entries(CANONICAL_TO_CLAUDE_EVENT_NAMES).map(([k, v]) => [v, k]), +); + +/** + * Map canonical camelCase event names to Cursor camelCase. + * Currently 1:1 but kept explicit so divergences are easy to add. + */ +export const CANONICAL_TO_CURSOR_EVENT_NAMES: Record = { + sessionStart: "sessionStart", + sessionEnd: "sessionEnd", + preToolUse: "preToolUse", + postToolUse: "postToolUse", + beforeSubmitPrompt: "beforeSubmitPrompt", + stop: "stop", + subagentStop: "subagentStop", + preCompact: "preCompact", + postToolUseFailure: "postToolUseFailure", + subagentStart: "subagentStart", + beforeShellExecution: "beforeShellExecution", + afterShellExecution: "afterShellExecution", + beforeMCPExecution: "beforeMCPExecution", + afterMCPExecution: "afterMCPExecution", + beforeReadFile: "beforeReadFile", + afterFileEdit: "afterFileEdit", + afterAgentResponse: "afterAgentResponse", + afterAgentThought: "afterAgentThought", + beforeTabFileRead: "beforeTabFileRead", + afterTabFileEdit: "afterTabFileEdit", +}; + +/** + * Map Cursor camelCase event names to canonical camelCase. + */ +export const CURSOR_TO_CANONICAL_EVENT_NAMES: Record = Object.fromEntries( + Object.entries(CANONICAL_TO_CURSOR_EVENT_NAMES).map(([k, v]) => [v, k]), +); + +/** + * Map canonical camelCase event names to Factory Droid PascalCase. + */ +export const CANONICAL_TO_FACTORYDROID_EVENT_NAMES: Record = { + sessionStart: "SessionStart", + sessionEnd: "SessionEnd", + preToolUse: "PreToolUse", + postToolUse: "PostToolUse", + beforeSubmitPrompt: "UserPromptSubmit", + stop: "Stop", + subagentStop: "SubagentStop", + preCompact: "PreCompact", + permissionRequest: "PermissionRequest", + notification: "Notification", + setup: "Setup", +}; + +/** + * Map Factory Droid PascalCase event names to canonical camelCase. + */ +export const FACTORYDROID_TO_CANONICAL_EVENT_NAMES: Record = Object.fromEntries( + Object.entries(CANONICAL_TO_FACTORYDROID_EVENT_NAMES).map(([k, v]) => [v, k]), ); /** - * Map canonical (Cursor) camelCase event names to OpenCode dot-notation. - * Only includes events that have a meaningful OpenCode plugin equivalent. + * Map canonical camelCase event names to OpenCode dot-notation. */ -export const CURSOR_TO_OPENCODE_EVENT_NAMES: Record = { +export const CANONICAL_TO_OPENCODE_EVENT_NAMES: Record = { sessionStart: "session.created", preToolUse: "tool.execute.before", postToolUse: "tool.execute.after",