From 25cca4a545c5dfbfedd85b06b12fe7b3e94091e8 Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Tue, 16 Jun 2026 02:29:56 -0700 Subject: [PATCH] feat(codexcli): add PostCompact hook event and preserve prompt frontmatter Part A: add the PostCompact Codex CLI hook event (matcher applied to trigger: manual/auto), distinct from PreCompact. Wire it through the canonical HookEvent union, CODEXCLI_HOOK_EVENTS, and the Codex CLI event-name maps. Part B: preserve description and argument-hint frontmatter on Codex CLI custom prompts across generate and import, modeling frontmatter with z.looseObject like other command adapters. Closes #1879 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/reference/file-formats.md | 3 + skills/rulesync/file-formats.md | 3 + .../commands/codexcli-command.test.ts | 117 +++++++++++++++++- src/features/commands/codexcli-command.ts | 105 ++++++++++++++-- src/features/hooks/codexcli-hooks.test.ts | 47 +++++++ src/types/hooks.test.ts | 23 ++++ src/types/hooks.ts | 3 + 7 files changed, 286 insertions(+), 15 deletions(-) diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index ee58ee4c9..9762054d7 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -140,6 +140,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s | `subagentStart` | ✅ | — | — | — | — | ✅ | — | — | ✅ | — | — | — | — | — | — | ✅ | | `subagentStop` | ✅ | ✅ | — | — | — | ✅ | ✅ | — | ✅ | — | — | — | — | — | — | ✅ | | `preCompact` | ✅ | ✅ | — | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — | +| `postCompact` | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | | `afterFileEdit` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ | | `beforeShellExecution` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ | | `afterShellExecution` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ | @@ -237,6 +238,8 @@ takt: # takt specific parameters (optional; emitted under .takt/facets/instructi extends: "base" # (optional) emit a leading `{extends:}` facet-inheritance directive (Takt 0.39.0+) pi: # pi coding agent specific parameters (optional) argument-hint: "[message]" # Hint shown in Pi's command palette +codexcli: # Codex CLI custom-prompt specific parameters (optional) + argument-hint: "[message]" # Hint shown for the custom prompt's arguments roo: # Roo Code specific parameters (optional) mode: "architect" # (optional) mode slug to switch to before running the command body (e.g. "code", "architect") --- diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index ee58ee4c9..9762054d7 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -140,6 +140,7 @@ Events present in the shared `hooks` block but unsupported by a given tool are s | `subagentStart` | ✅ | — | — | — | — | ✅ | — | — | ✅ | — | — | — | — | — | — | ✅ | | `subagentStop` | ✅ | ✅ | — | — | — | ✅ | ✅ | — | ✅ | — | — | — | — | — | — | ✅ | | `preCompact` | ✅ | ✅ | — | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — | +| `postCompact` | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | | `afterFileEdit` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ | | `beforeShellExecution` | ✅ | — | — | — | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ | | `afterShellExecution` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ | @@ -237,6 +238,8 @@ takt: # takt specific parameters (optional; emitted under .takt/facets/instructi extends: "base" # (optional) emit a leading `{extends:}` facet-inheritance directive (Takt 0.39.0+) pi: # pi coding agent specific parameters (optional) argument-hint: "[message]" # Hint shown in Pi's command palette +codexcli: # Codex CLI custom-prompt specific parameters (optional) + argument-hint: "[message]" # Hint shown for the custom prompt's arguments roo: # Roo Code specific parameters (optional) mode: "architect" # (optional) mode slug to switch to before running the command body (e.g. "code", "architect") --- diff --git a/src/features/commands/codexcli-command.test.ts b/src/features/commands/codexcli-command.test.ts index 6b44c6e53..80e138d2d 100644 --- a/src/features/commands/codexcli-command.test.ts +++ b/src/features/commands/codexcli-command.test.ts @@ -46,7 +46,8 @@ It can be multiline.`; outputRoot: testDir, relativeDirPath: ".codex/prompts", relativeFilePath: "test-command.md", - fileContent: "This is the body of the codexcli command.\nIt can be multiline.", + frontmatter: {}, + body: "This is the body of the codexcli command.\nIt can be multiline.", validate: true, }); @@ -61,12 +62,42 @@ It can be multiline.`; outputRoot: testDir, relativeDirPath: ".codex/prompts", relativeFilePath: "test-command.md", - fileContent: "Test body", + frontmatter: {}, + body: "Test body", validate: false, }); expect(command).toBeInstanceOf(CodexcliCommand); }); + + it("should not emit frontmatter when no metadata fields are present", () => { + const command = new CodexcliCommand({ + outputRoot: testDir, + relativeDirPath: ".codex/prompts", + relativeFilePath: "test-command.md", + frontmatter: {}, + body: "Plain body", + validate: true, + }); + + expect(command.getFileContent()).toBe("Plain body"); + }); + + it("should emit description and argument-hint frontmatter when present", () => { + const command = new CodexcliCommand({ + outputRoot: testDir, + relativeDirPath: ".codex/prompts", + relativeFilePath: "test-command.md", + frontmatter: { description: "Do a thing", "argument-hint": "[file]" }, + body: "Body", + validate: true, + }); + + const content = command.getFileContent(); + expect(content).toContain("description: Do a thing"); + expect(content).toContain("argument-hint: '[file]'"); + expect(content).toContain("Body"); + }); }); describe("getBody", () => { @@ -75,7 +106,8 @@ It can be multiline.`; outputRoot: testDir, relativeDirPath: ".codex/prompts", relativeFilePath: "test-command.md", - fileContent: "This is the body content.\nWith multiple lines.", + frontmatter: {}, + body: "This is the body content.\nWith multiple lines.", validate: true, }); @@ -89,7 +121,8 @@ It can be multiline.`; outputRoot: testDir, relativeDirPath: ".codex/prompts", relativeFilePath: "test-command.md", - fileContent: "Test body", + frontmatter: {}, + body: "Test body", validate: true, }); @@ -97,6 +130,22 @@ It can be multiline.`; expect(rulesyncCommand).toBeInstanceOf(RulesyncCommand); expect(rulesyncCommand.getBody()).toBe("Test body"); }); + + it("should preserve description and argument-hint when converting to RulesyncCommand", () => { + const command = new CodexcliCommand({ + outputRoot: testDir, + relativeDirPath: ".codex/prompts", + relativeFilePath: "test-command.md", + frontmatter: { description: "A prompt", "argument-hint": "[path]" }, + body: "Body", + validate: true, + }); + + const rulesyncCommand = command.toRulesyncCommand(); + const frontmatter = rulesyncCommand.getFrontmatter(); + expect(frontmatter.description).toBe("A prompt"); + expect(frontmatter.codexcli).toEqual({ "argument-hint": "[path]" }); + }); }); describe("fromRulesyncCommand", () => { @@ -151,6 +200,36 @@ It can be multiline.`; expect(codexcliCommand.getRelativeFilePath()).toBe("complex-command.txt"); }); + it("should re-emit description and argument-hint from the codexcli section", () => { + const rulesyncCommand = new RulesyncCommand({ + outputRoot: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "test-command.md", + frontmatter: { + targets: ["codexcli"], + description: "Generate a report", + codexcli: { "argument-hint": "[range]" }, + }, + body: "Body content", + fileContent: "", + validate: true, + }); + + const codexcliCommand = CodexcliCommand.fromRulesyncCommand({ + outputRoot: testDir, + rulesyncCommand, + validate: true, + global: true, + }); + + const frontmatter = codexcliCommand.getFrontmatter(); + expect(frontmatter.description).toBe("Generate a report"); + expect(frontmatter["argument-hint"]).toBe("[range]"); + const content = codexcliCommand.getFileContent(); + expect(content).toContain("description: Generate a report"); + expect(content).toContain("argument-hint: '[range]'"); + }); + it("should handle empty body", () => { const rulesyncCommand = new RulesyncCommand({ outputRoot: testDir, @@ -197,6 +276,33 @@ It can be multiline.`; expect(command.getRelativeFilePath()).toBe("test-file-command.md"); }); + it("should parse description and argument-hint frontmatter from file", async () => { + const commandsDir = join(testDir, ".codex", "prompts"); + const filePath = join(commandsDir, "with-frontmatter.md"); + + await writeFileContent( + filePath, + `--- +description: Review the diff +argument-hint: "[file]" +--- + +Review body.`, + ); + + const command = await CodexcliCommand.fromFile({ + outputRoot: testDir, + relativeFilePath: "with-frontmatter.md", + validate: true, + global: true, + }); + + const frontmatter = command.getFrontmatter(); + expect(frontmatter.description).toBe("Review the diff"); + expect(frontmatter["argument-hint"]).toBe("[file]"); + expect(command.getBody()).toBe("Review body."); + }); + it("should throw error when file does not exist", async () => { await expect( CodexcliCommand.fromFile({ @@ -215,7 +321,8 @@ It can be multiline.`; outputRoot: testDir, relativeDirPath: ".codex/prompts", relativeFilePath: "valid-command.md", - fileContent: "Valid body", + frontmatter: {}, + body: "Valid body", validate: false, }); diff --git a/src/features/commands/codexcli-command.ts b/src/features/commands/codexcli-command.ts index 261be8386..01867da61 100644 --- a/src/features/commands/codexcli-command.ts +++ b/src/features/commands/codexcli-command.ts @@ -1,9 +1,12 @@ import { join } from "node:path"; +import { z } from "zod/mini"; + import { CODEXCLI_PROMPTS_DIR_PATH } from "../../constants/codexcli-paths.js"; import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { formatError } from "../../utils/error.js"; import { readFileContent } from "../../utils/file.js"; -import { parseFrontmatter } from "../../utils/frontmatter.js"; +import { parseFrontmatter, stringifyFrontmatter } from "../../utils/frontmatter.js"; import { RulesyncCommand, RulesyncCommandFrontmatter } from "./rulesync-command.js"; import { ToolCommand, @@ -13,9 +16,48 @@ import { ToolCommandSettablePaths, } from "./tool-command.js"; -export type CodexcliCommandParams = AiFileParams; +// looseObject preserves unknown keys during parsing so future Codex frontmatter +// additions survive a round trip. https://developers.openai.com/codex/custom-prompts +export const CodexcliCommandFrontmatterSchema = z.looseObject({ + description: z.optional(z.string()), + "argument-hint": z.optional(z.string()), +}); + +export type CodexcliCommandFrontmatter = z.infer; + +export type CodexcliCommandParams = { + frontmatter: CodexcliCommandFrontmatter; + body: string; +} & Omit; export class CodexcliCommand extends ToolCommand { + private readonly frontmatter: CodexcliCommandFrontmatter; + private readonly body: string; + + constructor({ frontmatter, body, ...rest }: CodexcliCommandParams) { + // Validate frontmatter before calling super to avoid validation order issues + if (rest.validate) { + const result = CodexcliCommandFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error( + `Invalid frontmatter in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`, + ); + } + } + + // Only emit frontmatter when at least one field is present so prompts without + // metadata stay as plain Markdown bodies. + const hasFrontmatter = Object.keys(frontmatter ?? {}).length > 0; + + super({ + ...rest, + fileContent: hasFrontmatter ? stringifyFrontmatter(body, frontmatter) : body, + }); + + this.frontmatter = frontmatter; + this.body = body; + } + static getSettablePaths({ global }: { global?: boolean } = {}): ToolCommandSettablePaths { if (!global) { throw new Error("CodexcliCommand only supports global mode. Please pass { global: true }."); @@ -25,18 +67,27 @@ export class CodexcliCommand extends ToolCommand { }; } + getFrontmatter(): CodexcliCommandFrontmatter { + return this.frontmatter; + } + toRulesyncCommand(): RulesyncCommand { + const { description, ...restFields } = this.frontmatter; + const rulesyncFrontmatter: RulesyncCommandFrontmatter = { targets: ["*"], + ...(description !== undefined && { description }), + // Preserve Codex-specific fields (e.g. argument-hint) in the codexcli section. + ...(Object.keys(restFields).length > 0 && { codexcli: restFields }), }; return new RulesyncCommand({ outputRoot: ".", // RulesyncCommand outputRoot is always the project root directory frontmatter: rulesyncFrontmatter, - body: this.getFileContent(), + body: this.body, relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath, relativeFilePath: this.relativeFilePath, - fileContent: this.getFileContent(), + fileContent: stringifyFrontmatter(this.body, rulesyncFrontmatter), validate: true, }); } @@ -48,10 +99,22 @@ export class CodexcliCommand extends ToolCommand { global = false, }: ToolCommandFromRulesyncCommandParams): CodexcliCommand { const paths = this.getSettablePaths({ global }); + const rulesyncFrontmatter = rulesyncCommand.getFrontmatter(); + + // Merge codexcli-specific fields (e.g. argument-hint) from rulesync frontmatter. + const codexcliFields = rulesyncFrontmatter.codexcli ?? {}; + + const codexcliFrontmatter: CodexcliCommandFrontmatter = { + ...(rulesyncFrontmatter.description !== undefined && { + description: rulesyncFrontmatter.description, + }), + ...codexcliFields, + }; return new CodexcliCommand({ outputRoot: outputRoot, - fileContent: rulesyncCommand.getBody(), + frontmatter: codexcliFrontmatter, + body: rulesyncCommand.getBody(), relativeDirPath: paths.relativeDirPath, relativeFilePath: rulesyncCommand.getRelativeFilePath(), validate, @@ -59,11 +122,26 @@ export class CodexcliCommand extends ToolCommand { } validate(): ValidationResult { - return { success: true, error: null }; + // Check if frontmatter is set (may be undefined during construction) + if (!this.frontmatter) { + return { success: true, error: null }; + } + + const result = CodexcliCommandFrontmatterSchema.safeParse(this.frontmatter); + if (result.success) { + return { success: true, error: null }; + } else { + return { + success: false, + error: new Error( + `Invalid frontmatter in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`, + ), + }; + } } getBody(): string { - return this.getFileContent(); + return this.body; } static isTargetedByRulesyncCommand(rulesyncCommand: RulesyncCommand): boolean { @@ -83,13 +161,19 @@ export class CodexcliCommand extends ToolCommand { const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath); const fileContent = await readFileContent(filePath); - const { body: content } = parseFrontmatter(fileContent, filePath); + const { frontmatter, body: content } = parseFrontmatter(fileContent, filePath); + + const result = CodexcliCommandFrontmatterSchema.safeParse(frontmatter); + if (!result.success) { + throw new Error(`Invalid frontmatter in ${filePath}: ${formatError(result.error)}`); + } return new CodexcliCommand({ outputRoot: outputRoot, relativeDirPath: paths.relativeDirPath, relativeFilePath, - fileContent: content.trim(), + frontmatter: result.data, + body: content.trim(), validate, }); } @@ -103,7 +187,8 @@ export class CodexcliCommand extends ToolCommand { outputRoot, relativeDirPath, relativeFilePath, - fileContent: "", + frontmatter: {}, + body: "", validate: false, }); } diff --git a/src/features/hooks/codexcli-hooks.test.ts b/src/features/hooks/codexcli-hooks.test.ts index aa21f4e83..74f5c4560 100644 --- a/src/features/hooks/codexcli-hooks.test.ts +++ b/src/features/hooks/codexcli-hooks.test.ts @@ -114,6 +114,30 @@ describe("CodexcliHooks", () => { expect(parsed.hooks.PreCompact[0].hooks[0].command).toBe("echo compact"); }); + it("should convert postCompact to PostCompact with a trigger matcher", async () => { + const rulesyncHooks = new RulesyncHooks( + createMockAiFileParams({ + fileContent: JSON.stringify({ + hooks: { + postCompact: [{ command: "echo post-compact", matcher: "auto" }], + }, + }), + }), + ); + + const codexHooks = await CodexcliHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: true, + }); + + const parsed = JSON.parse(codexHooks.getFileContent()); + expect(parsed.hooks.PostCompact).toBeDefined(); + expect(parsed.hooks.PostCompact[0].matcher).toBe("auto"); + expect(parsed.hooks.PostCompact[0].hooks[0].command).toBe("echo post-compact"); + expect(parsed.hooks.PreCompact).toBeUndefined(); + }); + it("should not prefix commands with a project dir variable", async () => { const rulesyncHooks = new RulesyncHooks( createMockAiFileParams({ @@ -393,6 +417,29 @@ describe("CodexcliHooks", () => { expect(parsed.hooks.preCompact?.[0]?.command).toBe("echo compact"); }); + it("should convert PostCompact to canonical postCompact preserving the trigger matcher", () => { + const codexHooks = new CodexcliHooks( + createMockAiFileParams({ + relativeDirPath: ".codex", + relativeFilePath: "hooks.json", + fileContent: JSON.stringify({ + hooks: { + PostCompact: [{ matcher: "manual", hooks: [{ command: "echo post-compact" }] }], + }, + }), + }), + ); + + const rulesyncHooks = codexHooks.toRulesyncHooks(); + const parsed = rulesyncHooks.getJson(); + + expect(parsed.hooks.postCompact?.[0]).toEqual({ + type: "command", + command: "echo post-compact", + matcher: "manual", + }); + }); + it("should ignore invalid entries", () => { const codexHooks = new CodexcliHooks( createMockAiFileParams({ diff --git a/src/types/hooks.test.ts b/src/types/hooks.test.ts index d760abf2e..7f638051d 100644 --- a/src/types/hooks.test.ts +++ b/src/types/hooks.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it } from "vitest"; import { CANONICAL_TO_CLAUDE_EVENT_NAMES, + CANONICAL_TO_CODEXCLI_EVENT_NAMES, CANONICAL_TO_CURSOR_EVENT_NAMES, CANONICAL_TO_DEEPAGENTS_EVENT_NAMES, CANONICAL_TO_FACTORYDROID_EVENT_NAMES, CANONICAL_TO_OPENCODE_EVENT_NAMES, CLAUDE_HOOK_EVENTS, + CODEXCLI_HOOK_EVENTS, + CODEXCLI_TO_CANONICAL_EVENT_NAMES, CURSOR_HOOK_EVENTS, DEEPAGENTS_HOOK_EVENTS, FACTORYDROID_HOOK_EVENTS, @@ -43,6 +46,26 @@ describe("Event map completeness", () => { expect(CANONICAL_TO_DEEPAGENTS_EVENT_NAMES).toHaveProperty(event); } }); + + it("every CODEXCLI_HOOK_EVENTS entry should exist in CANONICAL_TO_CODEXCLI_EVENT_NAMES", () => { + for (const event of CODEXCLI_HOOK_EVENTS) { + expect(CANONICAL_TO_CODEXCLI_EVENT_NAMES).toHaveProperty(event); + } + }); +}); + +describe("Codex CLI event naming", () => { + it("should support the postCompact event mapped to PostCompact", () => { + // Verified against https://developers.openai.com/codex/hooks + expect(CODEXCLI_HOOK_EVENTS).toContain("postCompact"); + expect(CANONICAL_TO_CODEXCLI_EVENT_NAMES.postCompact).toBe("PostCompact"); + expect(CODEXCLI_TO_CANONICAL_EVENT_NAMES.PostCompact).toBe("postCompact"); + }); + + it("should keep preCompact distinct from postCompact", () => { + expect(CANONICAL_TO_CODEXCLI_EVENT_NAMES.preCompact).toBe("PreCompact"); + expect(CODEXCLI_TO_CANONICAL_EVENT_NAMES.PreCompact).toBe("preCompact"); + }); }); describe("Cursor event naming", () => { diff --git a/src/types/hooks.ts b/src/types/hooks.ts index 0cee73f37..fe3a50547 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -57,6 +57,7 @@ export type HookEvent = | "stop" | "subagentStop" | "preCompact" + | "postCompact" | "contextOffload" | "postToolUseFailure" | "subagentStart" @@ -248,6 +249,7 @@ export const CODEXCLI_HOOK_EVENTS: readonly HookEvent[] = [ "subagentStart", "subagentStop", "preCompact", + "postCompact", ]; /** @@ -578,6 +580,7 @@ export const CANONICAL_TO_CODEXCLI_EVENT_NAMES: Record = { subagentStart: "SubagentStart", subagentStop: "SubagentStop", preCompact: "PreCompact", + postCompact: "PostCompact", }; /**