Skip to content
Draft
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
2 changes: 1 addition & 1 deletion plugin/hooks/hooks.codex.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
],
"PreToolUse": [
{
"matcher": "Edit|Write|Read|Glob|Grep",
"matcher": "Edit|Write|Read|Glob|Grep|apply_patch|exec_command|shell_command|Bash",
"hooks": [
{
"type": "command",
Expand Down
147 changes: 114 additions & 33 deletions plugin/scripts/pre-tool-use.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,113 @@ function authHeaders() {
if (SECRET) h["Authorization"] = `Bearer ${SECRET}`;
return h;
}
function unique(values) {
return [...new Set(values.filter((value) => value.trim().length > 0))];
}
function extractCodexPatchFiles(patch) {
const files = [];
for (const line of patch.split("\n")) {
const match = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/);
if (match) files.push(match[1].trim());
}
return unique(files);
}
function extractCodexCommandTarget(command) {
const trimmed = command.trim();
if (!trimmed || /[;&|`$<>]/.test(trimmed)) return void 0;
const tokens = (trimmed.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []).map((part) => part.replace(/^["']|["']$/g, ""));
const commandName = tokens[0]?.split("/").pop();
if (!commandName) return void 0;
if (commandName === "rg") {
const positional = tokens.slice(1).filter((token) => !token.startsWith("-"));
if (positional.length === 0) return void 0;
const [pattern, ...paths] = positional;
return {
files: unique(paths),
terms: pattern ? [pattern] : [],
toolName: "grep"
};
}
if ([
"sed",
"cat",
"head",
"tail",
"nl"
].includes(commandName)) {
const paths = tokens.slice(1).filter((token) => !token.startsWith("-") && /[./]/.test(token));
if (paths.length === 0) return void 0;
return {
files: unique(paths),
terms: [],
toolName: "read"
};
}
if (["ls", "find"].includes(commandName)) {
const paths = tokens.slice(1).filter((token) => !token.startsWith("-"));
if (paths.length === 0) return void 0;
return {
files: unique(paths),
terms: [],
toolName: "glob"
};
}
}
function enrichTargetForTool(toolName, toolInput) {
const normalizedToolName = toolName.toLowerCase();
if ([
"edit",
"write",
"create",
"read",
"view",
"glob",
"grep"
].includes(normalizedToolName)) {
const files = [];
const fileKeys = normalizedToolName === "grep" ? ["path", "file"] : [
"file_path",
"path",
"file",
"pattern"
];
for (const key of fileKeys) {
const val = toolInput[key];
if (typeof val === "string" && val.length > 0) files.push(val);
}
if (files.length === 0) return void 0;
const terms = [];
if (normalizedToolName === "grep" || normalizedToolName === "glob") {
const pattern = toolInput["pattern"];
if (typeof pattern === "string" && pattern.length > 0) terms.push(pattern);
}
return {
files: unique(files),
terms,
toolName
};
}
if (normalizedToolName === "apply_patch") {
const patch = toolInput["patch"] ?? toolInput["input"] ?? toolInput["command"];
if (typeof patch !== "string") return void 0;
const files = extractCodexPatchFiles(patch);
if (files.length === 0) return void 0;
return {
files,
terms: [],
toolName: "edit"
};
}
if ([
"exec_command",
"shell_command",
"bash"
].includes(normalizedToolName)) {
const command = toolInput["cmd"] ?? toolInput["command"];
if (typeof command !== "string") return void 0;
return extractCodexCommandTarget(command);
}
}
async function main() {
if (!INJECT_CONTEXT) return;
let input = "";
Expand All @@ -26,35 +133,9 @@ async function main() {
if (isSdkChildContext(data)) return;
const toolName = typeof data.tool_name === "string" ? data.tool_name : typeof data.toolName === "string" ? data.toolName : void 0;
if (!toolName) return;
const normalizedToolName = toolName.toLowerCase();
if (![
"edit",
"write",
"create",
"read",
"view",
"glob",
"grep"
].includes(normalizedToolName)) return;
const rawToolInput = data.tool_input ?? data.toolArgs;
const toolInput = typeof rawToolInput === "object" && rawToolInput !== null && !Array.isArray(rawToolInput) ? rawToolInput : {};
const files = [];
const fileKeys = normalizedToolName === "grep" ? ["path", "file"] : [
"file_path",
"path",
"file",
"pattern"
];
for (const key of fileKeys) {
const val = toolInput[key];
if (typeof val === "string" && val.length > 0) files.push(val);
}
if (files.length === 0) return;
const terms = [];
if (normalizedToolName === "grep" || normalizedToolName === "glob") {
const pattern = toolInput["pattern"];
if (typeof pattern === "string" && pattern.length > 0) terms.push(pattern);
}
const target = enrichTargetForTool(toolName, typeof rawToolInput === "object" && rawToolInput !== null && !Array.isArray(rawToolInput) ? rawToolInput : {});
if (!target) return;
const rawSessionId = data.session_id || data.sessionId;
const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : "unknown";
const project = typeof data.project === "string" && data.project.trim().length > 0 ? data.project.trim() : void 0;
Expand All @@ -64,9 +145,9 @@ async function main() {
headers: authHeaders(),
body: JSON.stringify({
sessionId,
files,
terms,
toolName,
files: target.files,
terms: target.terms,
toolName: target.toolName,
...project !== void 0 && { project }
}),
signal: AbortSignal.timeout(2e3)
Expand All @@ -78,7 +159,7 @@ async function main() {
} catch {}
}
main();

//#endregion
export { };
export {};

//# sourceMappingURL=pre-tool-use.mjs.map
132 changes: 108 additions & 24 deletions src/hooks/pre-tool-use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,109 @@ function authHeaders(): Record<string, string> {
return h;
}

type EnrichTarget = {
files: string[];
terms: string[];
toolName: string;
};

function unique(values: string[]): string[] {
return [...new Set(values.filter((value) => value.trim().length > 0))];
}

function extractCodexPatchFiles(patch: string): string[] {
const files: string[] = [];
for (const line of patch.split("\n")) {
const match = line.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/);
if (match) files.push(match[1].trim());
}
return unique(files);
}

function extractCodexCommandTarget(command: string): EnrichTarget | undefined {
const trimmed = command.trim();
if (!trimmed || /[;&|`$<>]/.test(trimmed)) return undefined;

const parts = trimmed.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
const tokens = parts.map((part) => part.replace(/^["']|["']$/g, ""));
const commandName = tokens[0]?.split("/").pop();
if (!commandName) return undefined;

if (commandName === "rg") {
const positional = tokens.slice(1).filter((token) => !token.startsWith("-"));
if (positional.length === 0) return undefined;
const [pattern, ...paths] = positional;
return {
files: unique(paths),
terms: pattern ? [pattern] : [],
toolName: "grep",
};
}

if (["sed", "cat", "head", "tail", "nl"].includes(commandName)) {
const paths = tokens
.slice(1)
.filter((token) => !token.startsWith("-") && /[./]/.test(token));
if (paths.length === 0) return undefined;
return { files: unique(paths), terms: [], toolName: "read" };
}

if (["ls", "find"].includes(commandName)) {
const paths = tokens.slice(1).filter((token) => !token.startsWith("-"));
if (paths.length === 0) return undefined;
return { files: unique(paths), terms: [], toolName: "glob" };
}

return undefined;
}

function enrichTargetForTool(
toolName: string,
toolInput: Record<string, unknown>,
): EnrichTarget | undefined {
const normalizedToolName = toolName.toLowerCase();
const fileTools = ["edit", "write", "create", "read", "view", "glob", "grep"];

if (fileTools.includes(normalizedToolName)) {
const files: string[] = [];
const fileKeys =
normalizedToolName === "grep"
? ["path", "file"]
: ["file_path", "path", "file", "pattern"];
for (const key of fileKeys) {
const val = toolInput[key];
if (typeof val === "string" && val.length > 0) files.push(val);
}
if (files.length === 0) return undefined;

const terms: string[] = [];
if (normalizedToolName === "grep" || normalizedToolName === "glob") {
const pattern = toolInput["pattern"];
if (typeof pattern === "string" && pattern.length > 0) {
terms.push(pattern);
}
}

return { files: unique(files), terms, toolName };
}

if (normalizedToolName === "apply_patch") {
const patch = toolInput["patch"] ?? toolInput["input"] ?? toolInput["command"];
if (typeof patch !== "string") return undefined;
const files = extractCodexPatchFiles(patch);
if (files.length === 0) return undefined;
return { files, terms: [], toolName: "edit" };
}

if (["exec_command", "shell_command", "bash"].includes(normalizedToolName)) {
const command = toolInput["cmd"] ?? toolInput["command"];
if (typeof command !== "string") return undefined;
return extractCodexCommandTarget(command);
}

return undefined;
}

async function main() {
// Default off: exit immediately so we don't even open stdin. This keeps
// Claude Code's tool-call hot path as cheap as possible.
Expand Down Expand Up @@ -58,35 +161,16 @@ async function main() {
: undefined;
if (!toolName) return;

const normalizedToolName = toolName.toLowerCase();
const fileTools = ["edit", "write", "create", "read", "view", "glob", "grep"];
if (!fileTools.includes(normalizedToolName)) return;

const rawToolInput = data.tool_input ?? data.toolArgs;
const toolInput =
typeof rawToolInput === "object" &&
rawToolInput !== null &&
!Array.isArray(rawToolInput)
? (rawToolInput as Record<string, unknown>)
: {};
const files: string[] = [];
const fileKeys =
normalizedToolName === "grep"
? ["path", "file"]
: ["file_path", "path", "file", "pattern"];
for (const key of fileKeys) {
const val = toolInput[key];
if (typeof val === "string" && val.length > 0) files.push(val);
}
if (files.length === 0) return;

const terms: string[] = [];
if (normalizedToolName === "grep" || normalizedToolName === "glob") {
const pattern = toolInput["pattern"];
if (typeof pattern === "string" && pattern.length > 0) {
terms.push(pattern);
}
}
const target = enrichTargetForTool(toolName, toolInput);
if (!target) return;

const rawSessionId = data.session_id || data.sessionId;
const sessionId =
Expand All @@ -104,9 +188,9 @@ async function main() {
headers: authHeaders(),
body: JSON.stringify({
sessionId,
files,
terms,
toolName,
files: target.files,
terms: target.terms,
toolName: target.toolName,
...(project !== undefined && { project }),
}),
signal: AbortSignal.timeout(2000),
Expand Down
4 changes: 3 additions & 1 deletion test/codex-connect-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ describe("buildMergedHooks", () => {
const preToolUse = merged.hooks["PreToolUse"];
expect(preToolUse).toBeDefined();
expect(preToolUse!.length).toBeGreaterThan(0);
expect(preToolUse![0].matcher).toBe("Edit|Write|Read|Glob|Grep");
expect(preToolUse![0].matcher).toBe(
"Edit|Write|Read|Glob|Grep|apply_patch|exec_command|shell_command|Bash",
);
});

it("includes all six expected lifecycle events", () => {
Expand Down
12 changes: 11 additions & 1 deletion test/codex-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ function readJson<T = unknown>(path: string): T {
}

type HookHandler = { type: string; command: string };
type HookEntry = { hooks: HookHandler[] };
type HookEntry = { matcher?: string; hooks: HookHandler[] };

function hookCommands(path: string): string[] {
const manifest = readJson<{ hooks: Record<string, HookEntry[]> }>(path);
Expand Down Expand Up @@ -117,6 +117,16 @@ describe("Codex plugin manifest (developers.openai.com/codex/plugins)", () => {
expect(events).toContain("Stop");
});

it("routes Codex-native file tool names to PreToolUse", () => {
const hooks = readJson<{ hooks: Record<string, HookEntry[]> }>(
join(pluginRoot, "hooks/hooks.codex.json"),
);
const matcher = hooks.hooks.PreToolUse?.[0]?.matcher;
expect(matcher).toBeDefined();
expect(matcher).toContain("apply_patch");
expect(matcher).toContain("exec_command");
});

it("hook command scripts referenced in hooks.codex.json exist on disk", () => {
const hooks = readJson<{ hooks: Record<string, HookEntry[]> }>(
join(pluginRoot, "hooks/hooks.codex.json"),
Expand Down
Loading