Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ |
Expand Down Expand Up @@ -237,6 +238,8 @@ takt: # takt specific parameters (optional; emitted under .takt/facets/instructi
extends: "base" # (optional) emit a leading `{extends:<parent>}` 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")
---
Expand Down
3 changes: 3 additions & 0 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ |
Expand Down Expand Up @@ -237,6 +238,8 @@ takt: # takt specific parameters (optional; emitted under .takt/facets/instructi
extends: "base" # (optional) emit a leading `{extends:<parent>}` 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")
---
Expand Down
117 changes: 112 additions & 5 deletions src/features/commands/codexcli-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand All @@ -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", () => {
Expand All @@ -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,
});

Expand All @@ -89,14 +121,31 @@ It can be multiline.`;
outputRoot: testDir,
relativeDirPath: ".codex/prompts",
relativeFilePath: "test-command.md",
fileContent: "Test body",
frontmatter: {},
body: "Test body",
validate: true,
});

const rulesyncCommand = command.toRulesyncCommand();
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", () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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,
});

Expand Down
105 changes: 95 additions & 10 deletions src/features/commands/codexcli-command.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<typeof CodexcliCommandFrontmatterSchema>;

export type CodexcliCommandParams = {
frontmatter: CodexcliCommandFrontmatter;
body: string;
} & Omit<AiFileParams, "fileContent">;

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 }.");
Expand All @@ -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,
});
}
Expand All @@ -48,22 +99,49 @@ 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,
});
}

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 {
Expand All @@ -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,
});
}
Expand All @@ -103,7 +187,8 @@ export class CodexcliCommand extends ToolCommand {
outputRoot,
relativeDirPath,
relativeFilePath,
fileContent: "",
frontmatter: {},
body: "",
validate: false,
});
}
Expand Down
Loading