diff --git a/.gitignore b/.gitignore index 07db0ffb..bff2ced8 100644 --- a/.gitignore +++ b/.gitignore @@ -249,6 +249,7 @@ rulesync.local.jsonc **/.codex/agents/ **/.codex/memories/ **/.codex/config.toml +**/.codex/hooks.json **/.cursor/ **/.cursorignore **/.deepagents/AGENTS.md diff --git a/README.md b/README.md index 9e7bb3a8..875b0d29 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ See [Quick Start guide](https://dyoshikawa.github.io/rulesync/getting-started/qu | AGENTS.md | agentsmd | ✅ | | | 🎮 | 🎮 | 🎮 | | | AgentsSkills | agentsskills | | | | | | ✅ | | | Claude Code | claudecode | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ 🌏 | ✅ 🌏 | | +| Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Gemini CLI | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | | Goose | goose | ✅ 🌏 | ✅ | | | | | | | GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index 3f892b51..5d42df53 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -7,7 +7,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | AGENTS.md | agentsmd | ✅ | | | 🎮 | 🎮 | 🎮 | | | AgentsSkills | agentsskills | | | | | | ✅ | | | Claude Code | claudecode | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ 🌏 | ✅ 🌏 | | +| Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Gemini CLI | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | | GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ | ✅ | ✅ | | GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | | | | diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index 3f892b51..5d42df53 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -7,7 +7,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | AGENTS.md | agentsmd | ✅ | | | 🎮 | 🎮 | 🎮 | | | AgentsSkills | agentsskills | | | | | | ✅ | | | Claude Code | claudecode | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | -| Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ 🌏 | ✅ 🌏 | | +| Codex CLI | codexcli | ✅ 🌏 | | ✅ 🌏 🔧 | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | Gemini CLI | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | | GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ | ✅ | ✅ | | GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | | | | diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index a3b14b8c..58c73054 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -78,6 +78,7 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ { target: "codexcli", feature: "subagents", entry: "**/.codex/agents/" }, { target: "codexcli", feature: "general", entry: "**/.codex/memories/" }, { target: "codexcli", feature: "general", entry: "**/.codex/config.toml" }, + { target: "codexcli", feature: "hooks", entry: "**/.codex/hooks.json" }, // Cursor { target: "cursor", feature: "rules", entry: "**/.cursor/" }, diff --git a/src/features/hooks/codexcli-hooks.test.ts b/src/features/hooks/codexcli-hooks.test.ts new file mode 100644 index 00000000..31f4e188 --- /dev/null +++ b/src/features/hooks/codexcli-hooks.test.ts @@ -0,0 +1,371 @@ +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { ensureDir, writeFileContent } from "../../utils/file.js"; +import { CodexcliConfigToml, CodexcliHooks } from "./codexcli-hooks.js"; +import { RulesyncHooks } from "./rulesync-hooks.js"; + +function createMockAiFileParams( + override: Partial[0]> = {}, +) { + return { + baseDir: "/mock", + relativeDirPath: ".rulesync", + relativeFilePath: "hooks.json", + fileContent: "{}", + ...override, + }; +} + +describe("CodexcliHooks", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + }); + + afterEach(async () => { + await cleanup(); + }); + + describe("fromRulesyncHooks", () => { + it("should convert canonical hooks to Codex CLI format with PascalCase event names", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + sessionStart: [{ command: "echo start" }], + preToolUse: [{ command: "./scripts/lint.sh", matcher: "Bash", timeout: 30 }], + }, + }), + }), + ); + + const codexHooks = await CodexcliHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(codexHooks.getFileContent()); + expect(parsed.hooks).toBeDefined(); + expect(parsed.hooks.SessionStart).toBeDefined(); + expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe("echo start"); + expect(parsed.hooks.SessionStart[0].hooks[0].type).toBe("command"); + expect(parsed.hooks.PreToolUse).toBeDefined(); + expect(parsed.hooks.PreToolUse[0].matcher).toBe("Bash"); + expect(parsed.hooks.PreToolUse[0].hooks[0].command).toBe("./scripts/lint.sh"); + expect(parsed.hooks.PreToolUse[0].hooks[0].timeout).toBe(30); + }); + + it("should filter unsupported events", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + sessionStart: [{ command: "echo start" }], + sessionEnd: [{ command: "echo end" }], + subagentStop: [{ command: "echo sub" }], + }, + }), + }), + ); + + const codexHooks = await CodexcliHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(codexHooks.getFileContent()); + expect(parsed.hooks.SessionStart).toBeDefined(); + expect(parsed.hooks.SessionEnd).toBeUndefined(); + expect(parsed.hooks.SubagentStop).toBeUndefined(); + }); + + it("should not prefix commands with a project dir variable", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + sessionStart: [{ command: "./hooks/start.sh" }], + }, + }), + }), + ); + + const codexHooks = await CodexcliHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(codexHooks.getFileContent()); + expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe("./hooks/start.sh"); + }); + + it("should process tool-specific overrides", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + sessionStart: [{ command: "echo shared" }], + }, + codexcli: { + hooks: { + sessionStart: [{ command: "echo override" }], + stop: [{ command: "echo stop" }], + }, + }, + }), + }), + ); + + const codexHooks = await CodexcliHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(codexHooks.getFileContent()); + expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe("echo override"); + expect(parsed.hooks.Stop[0].hooks[0].command).toBe("echo stop"); + }); + + it("should not write config.toml as a side effect", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + sessionStart: [{ command: "echo start" }], + }, + }), + }), + ); + + await CodexcliHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: true, + }); + + const { readFileContentOrNull } = await import("../../utils/file.js"); + const configContent = await readFileContentOrNull(join(testDir, ".codex", "config.toml")); + expect(configContent).toBeNull(); + }); + + it("should filter out non-command hook types", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + sessionStart: [ + { type: "command", command: "echo start" }, + { type: "prompt", command: "summarize" }, + ], + preToolUse: [{ type: "prompt", command: "review" }], + }, + }), + }), + ); + + const codexHooks = await CodexcliHooks.fromRulesyncHooks({ + baseDir: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(codexHooks.getFileContent()); + expect(parsed.hooks.SessionStart).toBeDefined(); + expect(parsed.hooks.SessionStart[0].hooks).toHaveLength(1); + expect(parsed.hooks.SessionStart[0].hooks[0].type).toBe("command"); + expect(parsed.hooks.SessionStart[0].hooks[0].command).toBe("echo start"); + // preToolUse had only prompt hooks, so it should be excluded entirely + expect(parsed.hooks.PreToolUse).toBeUndefined(); + }); + }); + + describe("toRulesyncHooks", () => { + it("should convert Codex CLI format to canonical format", () => { + const codexHooks = new CodexcliHooks( + createMockAiFileParams({ + relativeDirPath: ".codex", + relativeFilePath: "hooks.json", + fileContent: JSON.stringify({ + hooks: { + SessionStart: [ + { + matcher: "init", + hooks: [ + { + type: "command", + command: "echo start", + timeout: 1000, + }, + ], + }, + ], + }, + }), + }), + ); + + const rulesyncHooks = codexHooks.toRulesyncHooks(); + const parsed = rulesyncHooks.getJson(); + + expect(parsed.hooks.sessionStart).toBeDefined(); + expect(parsed.hooks.sessionStart?.[0]).toEqual({ + type: "command", + command: "echo start", + timeout: 1000, + matcher: "init", + }); + }); + + it("should handle missing optional fields", () => { + const codexHooks = new CodexcliHooks( + createMockAiFileParams({ + relativeDirPath: ".codex", + relativeFilePath: "hooks.json", + fileContent: JSON.stringify({ + hooks: { + Stop: [ + { + hooks: [{ command: "echo done" }], + }, + ], + }, + }), + }), + ); + + const rulesyncHooks = codexHooks.toRulesyncHooks(); + const parsed = rulesyncHooks.getJson(); + + expect(parsed.hooks.stop).toBeDefined(); + expect(parsed.hooks.stop?.[0]).toEqual({ + type: "command", + command: "echo done", + }); + }); + + it("should ignore invalid entries", () => { + const codexHooks = new CodexcliHooks( + createMockAiFileParams({ + relativeDirPath: ".codex", + relativeFilePath: "hooks.json", + fileContent: JSON.stringify({ + hooks: { + SessionStart: "invalid", + Stop: ["invalid", { hooks: "invalid" }], + }, + }), + }), + ); + + const rulesyncHooks = codexHooks.toRulesyncHooks(); + const parsed = rulesyncHooks.getJson(); + + expect(parsed.hooks.sessionStart).toBeUndefined(); + expect(parsed.hooks.stop).toBeUndefined(); + }); + }); + + describe("fromFile", () => { + it("should load from .codex/hooks.json when it exists", async () => { + await ensureDir(join(testDir, ".codex")); + await writeFileContent( + join(testDir, ".codex", "hooks.json"), + JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: "command", command: "echo start" }] }], + }, + }), + ); + + const codexHooks = await CodexcliHooks.fromFile({ + baseDir: testDir, + validate: false, + }); + expect(codexHooks).toBeInstanceOf(CodexcliHooks); + const content = codexHooks.getFileContent(); + const parsed = JSON.parse(content); + expect(parsed.hooks.SessionStart).toHaveLength(1); + }); + + it("should initialize empty hooks when hooks.json does not exist", async () => { + const codexHooks = await CodexcliHooks.fromFile({ + baseDir: testDir, + validate: false, + }); + expect(codexHooks).toBeInstanceOf(CodexcliHooks); + const content = codexHooks.getFileContent(); + const parsed = JSON.parse(content); + expect(parsed.hooks).toEqual({}); + }); + }); + + describe("isDeletable", () => { + it("should return true", () => { + const hooks = new CodexcliHooks( + createMockAiFileParams({ + relativeDirPath: ".codex", + relativeFilePath: "hooks.json", + }), + ); + expect(hooks.isDeletable()).toBe(true); + }); + }); + + describe("forDeletion", () => { + it("should create instance with empty hooks", () => { + const hooks = CodexcliHooks.forDeletion({ + relativeDirPath: ".codex", + relativeFilePath: "hooks.json", + }); + const parsed = JSON.parse(hooks.getFileContent()); + expect(parsed.hooks).toEqual({}); + }); + }); +}); + +describe("CodexcliConfigToml", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + }); + + afterEach(async () => { + await cleanup(); + }); + + it("should generate config.toml with codex_hooks feature flag", async () => { + const configToml = await CodexcliConfigToml.fromBaseDir({ baseDir: testDir }); + expect(configToml.getFileContent()).toContain("codex_hooks"); + }); + + it("should preserve existing config.toml content", async () => { + await ensureDir(join(testDir, ".codex")); + await writeFileContent( + join(testDir, ".codex", "config.toml"), + '[mcp_servers.myserver]\ncommand = "node"\n', + ); + + const configToml = await CodexcliConfigToml.fromBaseDir({ baseDir: testDir }); + const content = configToml.getFileContent(); + expect(content).toContain("codex_hooks"); + expect(content).toContain("mcp_servers"); + expect(content).toContain("myserver"); + }); + + it("should set correct file paths", async () => { + const configToml = await CodexcliConfigToml.fromBaseDir({ baseDir: testDir }); + expect(configToml.getRelativeDirPath()).toBe(".codex"); + expect(configToml.getRelativeFilePath()).toBe("config.toml"); + }); +}); diff --git a/src/features/hooks/codexcli-hooks.ts b/src/features/hooks/codexcli-hooks.ts new file mode 100644 index 00000000..3db1f9b5 --- /dev/null +++ b/src/features/hooks/codexcli-hooks.ts @@ -0,0 +1,253 @@ +import { join } from "node:path"; + +import * as smolToml from "smol-toml"; +import { z } from "zod/mini"; + +import type { AiFileParams } from "../../types/ai-file.js"; +import type { ValidationResult } from "../../types/ai-file.js"; +import type { HooksConfig } from "../../types/hooks.js"; +import { + CODEXCLI_HOOK_EVENTS, + CODEXCLI_TO_CANONICAL_EVENT_NAMES, + CANONICAL_TO_CODEXCLI_EVENT_NAMES, +} from "../../types/hooks.js"; +import { ToolFile } from "../../types/tool-file.js"; +import { formatError } from "../../utils/error.js"; +import { readFileContentOrNull } from "../../utils/file.js"; +import type { RulesyncHooks } from "./rulesync-hooks.js"; +import { + ToolHooks, + type ToolHooksForDeletionParams, + type ToolHooksFromFileParams, + type ToolHooksFromRulesyncHooksParams, + type ToolHooksSettablePaths, +} from "./tool-hooks.js"; + +/** + * Convert canonical hooks config to Codex CLI format. + * Filters shared hooks to CODEXCLI_HOOK_EVENTS, merges config.codexcli?.hooks, + * then converts to PascalCase and Codex CLI matcher/hooks structure. + * Unlike Claude Code or Gemini CLI, Codex CLI has no project directory variable, + * so commands are passed through as-is. + */ +function canonicalToCodexcliHooks(config: HooksConfig): Record { + const codexSupported: Set = new Set(CODEXCLI_HOOK_EVENTS); + const sharedHooks: HooksConfig["hooks"] = {}; + for (const [event, defs] of Object.entries(config.hooks)) { + if (codexSupported.has(event)) { + sharedHooks[event] = defs; + } + } + const effectiveHooks: HooksConfig["hooks"] = { + ...sharedHooks, + ...config.codexcli?.hooks, + }; + const codex: Record = {}; + for (const [eventName, definitions] of Object.entries(effectiveHooks)) { + const codexEventName = CANONICAL_TO_CODEXCLI_EVENT_NAMES[eventName] ?? eventName; + const byMatcher = new Map(); + for (const def of definitions) { + const key = def.matcher ?? ""; + const list = byMatcher.get(key); + if (list) list.push(def); + else byMatcher.set(key, [def]); + } + const entries: unknown[] = []; + for (const [matcherKey, defs] of byMatcher) { + const commandDefs = defs.filter((def) => !def.type || def.type === "command"); + if (commandDefs.length === 0) continue; + const hooks = commandDefs.map((def) => ({ + type: "command" as const, + ...(def.command !== undefined && def.command !== null && { command: def.command }), + ...(def.timeout !== undefined && def.timeout !== null && { timeout: def.timeout }), + })); + entries.push(matcherKey ? { matcher: matcherKey, hooks } : { hooks }); + } + if (entries.length > 0) { + codex[codexEventName] = entries; + } + } + return codex; +} + +/** + * Codex CLI hook entry as stored in each matcher group's `hooks` array. + * Uses `z.looseObject` so that unknown fields added by future Codex CLI + * versions are accepted and silently ignored during import. + */ +const CodexHookEntrySchema = z.looseObject({ + type: z.optional(z.string()), + command: z.optional(z.string()), + timeout: z.optional(z.number()), +}); + +/** + * A matcher group entry in a Codex CLI event array. + * Each event maps to an array of these groups. + */ +const CodexMatcherEntrySchema = z.looseObject({ + matcher: z.optional(z.string()), + hooks: z.optional(z.array(CodexHookEntrySchema)), +}); + +/** + * Extract hooks from Codex CLI hooks.json into canonical format. + */ +function codexcliHooksToCanonical(codexHooks: unknown): HooksConfig["hooks"] { + if (codexHooks === null || codexHooks === undefined || typeof codexHooks !== "object") { + return {}; + } + const canonical: HooksConfig["hooks"] = {}; + for (const [codexEventName, matcherEntries] of Object.entries(codexHooks)) { + const eventName = CODEXCLI_TO_CANONICAL_EVENT_NAMES[codexEventName] ?? codexEventName; + if (!Array.isArray(matcherEntries)) continue; + const defs: HooksConfig["hooks"][string] = []; + for (const rawEntry of matcherEntries) { + const parseResult = CodexMatcherEntrySchema.safeParse(rawEntry); + if (!parseResult.success) continue; + const entry = parseResult.data; + const hooks = entry.hooks ?? []; + for (const h of hooks) { + const hookType = h.type === "command" || h.type === "prompt" ? h.type : "command"; + defs.push({ + type: hookType, + ...(h.command !== undefined && h.command !== null && { command: h.command }), + ...(h.timeout !== undefined && h.timeout !== null && { timeout: h.timeout }), + ...(entry.matcher !== undefined && + entry.matcher !== null && + entry.matcher !== "" && { matcher: entry.matcher }), + }); + } + } + if (defs.length > 0) { + canonical[eventName] = defs; + } + } + return canonical; +} + +/** + * Build the content for `.codex/config.toml` with `[features] codex_hooks = true`. + * Reads the existing file (if any), parses TOML, sets the flag, and returns the content + * without writing to disk. The caller is responsible for writing via the normal write phase. + */ +async function buildCodexConfigTomlContent({ baseDir }: { baseDir: string }): Promise { + const configPath = join(baseDir, ".codex", "config.toml"); + const existingContent = (await readFileContentOrNull(configPath)) ?? smolToml.stringify({}); + const configToml = smolToml.parse(existingContent); + + if (typeof configToml.features !== "object" || configToml.features === null) { + // eslint-disable-next-line no-type-assertion/no-type-assertion + configToml.features = {} as smolToml.TomlTable; + } + // eslint-disable-next-line no-type-assertion/no-type-assertion + (configToml.features as smolToml.TomlTable).codex_hooks = true; + + return smolToml.stringify(configToml); +} + +/** + * Represents the `.codex/config.toml` file as a generated ToolFile, + * so it goes through the normal write phase and respects dry-run mode. + */ +export class CodexcliConfigToml extends ToolFile { + validate(): ValidationResult { + return { success: true, error: null }; + } + + static async fromBaseDir({ baseDir }: { baseDir: string }): Promise { + const fileContent = await buildCodexConfigTomlContent({ baseDir }); + return new CodexcliConfigToml({ + baseDir, + relativeDirPath: ".codex", + relativeFilePath: "config.toml", + fileContent, + }); + } +} + +export class CodexcliHooks extends ToolHooks { + constructor(params: AiFileParams) { + super({ + ...params, + fileContent: params.fileContent ?? "{}", + }); + } + + static getSettablePaths(_options: { global?: boolean } = {}): ToolHooksSettablePaths { + return { relativeDirPath: ".codex", relativeFilePath: "hooks.json" }; + } + + static async fromFile({ + baseDir = process.cwd(), + validate = true, + global = false, + }: ToolHooksFromFileParams): Promise { + const paths = CodexcliHooks.getSettablePaths({ global }); + const filePath = join(baseDir, paths.relativeDirPath, paths.relativeFilePath); + const fileContent = (await readFileContentOrNull(filePath)) ?? '{"hooks":{}}'; + return new CodexcliHooks({ + baseDir, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + static async fromRulesyncHooks({ + baseDir = process.cwd(), + rulesyncHooks, + validate = true, + global = false, + }: ToolHooksFromRulesyncHooksParams & { global?: boolean }): Promise { + const paths = CodexcliHooks.getSettablePaths({ global }); + const config = rulesyncHooks.getJson(); + const codexHooks = canonicalToCodexcliHooks(config); + const fileContent = JSON.stringify({ hooks: codexHooks }, null, 2); + + return new CodexcliHooks({ + baseDir, + relativeDirPath: paths.relativeDirPath, + relativeFilePath: paths.relativeFilePath, + fileContent, + validate, + }); + } + + toRulesyncHooks(): RulesyncHooks { + let parsed: { hooks?: unknown }; + try { + parsed = JSON.parse(this.getFileContent()); + } catch (error) { + throw new Error( + `Failed to parse Codex CLI hooks content in ${join(this.getRelativeDirPath(), this.getRelativeFilePath())}: ${formatError(error)}`, + { + cause: error, + }, + ); + } + const hooks = codexcliHooksToCanonical(parsed.hooks); + return this.toRulesyncHooksDefault({ + fileContent: JSON.stringify({ version: 1, hooks }, null, 2), + }); + } + + validate(): ValidationResult { + return { success: true, error: null }; + } + + static forDeletion({ + baseDir = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolHooksForDeletionParams): CodexcliHooks { + return new CodexcliHooks({ + baseDir, + relativeDirPath, + relativeFilePath, + fileContent: JSON.stringify({ hooks: {} }, null, 2), + validate: false, + }); + } +} diff --git a/src/features/hooks/hooks-processor.test.ts b/src/features/hooks/hooks-processor.test.ts index 46c8cd55..b3d5003b 100644 --- a/src/features/hooks/hooks-processor.test.ts +++ b/src/features/hooks/hooks-processor.test.ts @@ -409,6 +409,7 @@ describe("HooksProcessor", () => { expect(targets).toEqual([ "cursor", "claudecode", + "codexcli", "copilot", "kilo", "opencode", @@ -421,6 +422,7 @@ describe("HooksProcessor", () => { const targets = HooksProcessor.getToolTargets({ global: true }); expect(targets).toEqual([ "claudecode", + "codexcli", "kilo", "opencode", "factorydroid", @@ -431,12 +433,25 @@ describe("HooksProcessor", () => { it("should exclude non-importable targets when importOnly is true", () => { const targets = HooksProcessor.getToolTargets({ global: false, importOnly: true }); - expect(targets).toEqual(["cursor", "claudecode", "copilot", "factorydroid", "geminicli"]); + expect(targets).toEqual([ + "cursor", + "claudecode", + "codexcli", + "copilot", + "factorydroid", + "geminicli", + ]); }); it("should exclude non-importable targets when importOnly is true in global mode", () => { const targets = HooksProcessor.getToolTargets({ global: true, importOnly: true }); - expect(targets).toEqual(["claudecode", "factorydroid", "geminicli", "deepagents"]); + expect(targets).toEqual([ + "claudecode", + "codexcli", + "factorydroid", + "geminicli", + "deepagents", + ]); }); }); }); diff --git a/src/features/hooks/hooks-processor.ts b/src/features/hooks/hooks-processor.ts index a03bf0ce..d7ffc06c 100644 --- a/src/features/hooks/hooks-processor.ts +++ b/src/features/hooks/hooks-processor.ts @@ -4,6 +4,7 @@ import { RULESYNC_HOOKS_RELATIVE_FILE_PATH } from "../../constants/rulesync-path import { FeatureProcessor } from "../../types/feature-processor.js"; import { CLAUDE_HOOK_EVENTS, + CODEXCLI_HOOK_EVENTS, COPILOT_HOOK_EVENTS, CURSOR_HOOK_EVENTS, DEEPAGENTS_HOOK_EVENTS, @@ -20,6 +21,7 @@ import type { ToolTarget } from "../../types/tool-targets.js"; import { formatError } from "../../utils/error.js"; import type { Logger } from "../../utils/logger.js"; import { ClaudecodeHooks } from "./claudecode-hooks.js"; +import { CodexcliConfigToml, CodexcliHooks } from "./codexcli-hooks.js"; import { CopilotHooks } from "./copilot-hooks.js"; import { CursorHooks } from "./cursor-hooks.js"; import { DeepagentsHooks } from "./deepagents-hooks.js"; @@ -39,6 +41,7 @@ const hooksProcessorToolTargetTuple = [ "kilo", "cursor", "claudecode", + "codexcli", "copilot", "opencode", "factorydroid", @@ -102,6 +105,20 @@ const toolHooksFactories = new Map([ supportsMatcher: true, }, ], + [ + "codexcli", + { + class: CodexcliHooks, + meta: { + supportsProject: true, + supportsGlobal: true, + supportsImport: true, + }, + supportedEvents: CODEXCLI_HOOK_EVENTS, + supportedHookTypes: ["command"], + supportsMatcher: true, + }, + ], [ "copilot", { @@ -352,7 +369,15 @@ export class HooksProcessor extends FeatureProcessor { validate: true, global: this.global, }); - return [toolHooks]; + + const result: ToolFile[] = [toolHooks]; + + // For codexcli, also generate .codex/config.toml with the feature flag + if (this.toolTarget === "codexcli") { + result.push(await CodexcliConfigToml.fromBaseDir({ baseDir: this.baseDir })); + } + + return result; } async convertToolFilesToRulesyncFiles(toolFiles: ToolFile[]): Promise { diff --git a/src/types/hooks.ts b/src/types/hooks.ts index 7289d2ba..99e56a74 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -179,6 +179,15 @@ export const GEMINICLI_HOOK_EVENTS: readonly HookEvent[] = [ "notification", ]; +/** Hook events supported by Codex CLI. */ +export const CODEXCLI_HOOK_EVENTS: readonly HookEvent[] = [ + "sessionStart", + "preToolUse", + "postToolUse", + "beforeSubmitPrompt", + "stop", +]; + const hooksRecordSchema = z.record(z.string(), z.array(HookDefinitionSchema)); /** @@ -194,6 +203,7 @@ export const HooksConfigSchema = z.looseObject({ kilo: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), factorydroid: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), geminicli: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), + codexcli: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), deepagents: z.optional(z.looseObject({ hooks: z.optional(hooksRecordSchema) })), }); @@ -346,6 +356,24 @@ export const GEMINICLI_TO_CANONICAL_EVENT_NAMES: Record = Object Object.entries(CANONICAL_TO_GEMINICLI_EVENT_NAMES).map(([k, v]) => [v, k]), ); +/** + * Map canonical camelCase event names to Codex CLI PascalCase. + */ +export const CANONICAL_TO_CODEXCLI_EVENT_NAMES: Record = { + sessionStart: "SessionStart", + preToolUse: "PreToolUse", + postToolUse: "PostToolUse", + beforeSubmitPrompt: "UserPromptSubmit", + stop: "Stop", +}; + +/** + * Map Codex CLI PascalCase event names to canonical camelCase. + */ +export const CODEXCLI_TO_CANONICAL_EVENT_NAMES: Record = Object.fromEntries( + Object.entries(CANONICAL_TO_CODEXCLI_EVENT_NAMES).map(([k, v]) => [v, k]), +); + /** * Map canonical camelCase event names to deepagents-cli dot-notation. */