fix(codex): harden windows light install

This commit is contained in:
YeonGyu-Kim
2026-05-31 13:23:24 +09:00
parent 4e31d7df5d
commit 92bad87d84
42 changed files with 1675 additions and 23 deletions
@@ -0,0 +1,29 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook pre-tool-use",
"timeout": 5,
"statusMessage": "LazyCodex(0.1.0): Recommending Git Bash Mcp"
}
]
}
],
"PostCompact": [
{
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook post-compact",
"timeout": 5,
"statusMessage": "LazyCodex(0.1.0): Resetting Git Bash Mcp Reminder"
}
]
}
]
}
}
@@ -0,0 +1,23 @@
{
"name": "@sisyphuslabs/codex-git-bash-hook",
"version": "0.1.0",
"description": "Codex hook component that reminds Windows sessions to prefer the OMO git_bash MCP.",
"type": "module",
"private": true,
"bin": {
"omo-git-bash-hook": "./dist/cli.js"
},
"files": ["dist", "hooks"],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "bun test test/*.test.ts",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^25.7.0",
"typescript": "^6.0.3"
},
"engines": {
"node": ">=20.0.0"
}
}
@@ -0,0 +1,33 @@
#!/usr/bin/env node
import { runGitBashHookCli } from "./codex-hook.js";
const TOP_LEVEL_HELP =
"Usage:\n omo-git-bash-hook hook pre-tool-use\n omo-git-bash-hook hook post-compact\n omo-git-bash-hook help | --help | -h\n";
async function main(): Promise<number> {
const argv = process.argv.slice(2);
const command = argv[0];
if (command === undefined || command === "help" || command === "--help" || command === "-h") {
process.stdout.write(TOP_LEVEL_HELP);
return 0;
}
if (command === "hook" && argv[1] === "pre-tool-use") {
await runGitBashHookCli(process.stdin, process.stdout, "pre-tool-use");
return 0;
}
if (command === "hook" && argv[1] === "post-compact") {
await runGitBashHookCli(process.stdin, process.stdout, "post-compact");
return 0;
}
process.stderr.write(`[omo-git-bash-hook] unknown command: ${argv.join(" ")}\n${TOP_LEVEL_HELP}`);
return 1;
}
main()
.then((code) => {
process.exit(code);
})
.catch((error: unknown) => {
process.stderr.write(`[omo-git-bash-hook] ${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});
@@ -0,0 +1,180 @@
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
export interface PreToolUsePayload {
readonly cwd: string;
readonly hook_event_name: "PreToolUse";
readonly model: string;
readonly permission_mode: string;
readonly session_id: string;
readonly tool_input: unknown;
readonly tool_name: string;
readonly tool_use_id: string;
readonly transcript_path: string | null;
readonly turn_id: string;
}
export interface GitBashHookOptions {
readonly env?: NodeJS.ProcessEnv;
readonly platform?: NodeJS.Platform | string;
readonly pluginDataRoot?: string;
}
export interface PostCompactPayload {
readonly hook_event_name: "PostCompact";
readonly session_id: string;
readonly transcript_path?: string | null;
readonly trigger?: string;
}
interface PreToolUseHookOutput {
readonly hookSpecificOutput: {
readonly hookEventName: "PreToolUse";
readonly additionalContext: string;
};
}
const BASH_TOOL_NAME = "Bash";
const REMINDER =
"On Windows, prefer the OMO git_bash MCP for shell commands before using built-in exec_command. Use exec_command only when git_bash is unavailable or for non-shell operations.";
export function parsePreToolUsePayload(raw: string): PreToolUsePayload | null {
if (raw.trim().length === 0) return null;
try {
const parsed: unknown = JSON.parse(raw);
return isPreToolUsePayload(parsed) ? parsed : null;
} catch (error) {
if (error instanceof SyntaxError) return null;
return null;
}
}
export function parsePostCompactPayload(raw: string): PostCompactPayload | null {
if (raw.trim().length === 0) return null;
try {
const parsed: unknown = JSON.parse(raw);
return isPostCompactPayload(parsed) ? parsed : null;
} catch (error) {
if (error instanceof SyntaxError) return null;
return null;
}
}
export function applyGitBashPreToolUseReminder(payload: PreToolUsePayload, options: GitBashHookOptions = {}): string {
if (payload.hook_event_name !== "PreToolUse") return "";
if (payload.tool_name !== BASH_TOOL_NAME) return "";
if (!isWindowsHost(options)) return "";
const markerPath = reminderMarkerPath(payload.session_id, options.pluginDataRoot);
if (hasReminderMarker(markerPath)) return "";
mkdirSync(dirname(markerPath), { recursive: true });
writeFileSync(markerPath, `${new Date().toISOString()}\n`);
const output: PreToolUseHookOutput = {
hookSpecificOutput: {
hookEventName: "PreToolUse",
additionalContext: REMINDER,
},
};
return `${JSON.stringify(output)}\n`;
}
export function applyGitBashPostCompactReset(payload: PostCompactPayload, options: GitBashHookOptions = {}): string {
if (payload.hook_event_name !== "PostCompact") return "";
rmSync(reminderMarkerPath(payload.session_id, options.pluginDataRoot), { force: true });
return "";
}
export async function runGitBashHookCli(
stdin: NodeJS.ReadableStream,
stdout: NodeJS.WritableStream,
eventName: "pre-tool-use" | "post-compact" = "pre-tool-use",
options: GitBashHookOptions = {},
): Promise<void> {
try {
const raw = await readAll(stdin);
const output =
eventName === "post-compact" ? postCompactOutput(raw, options) : preToolUseOutput(raw, options);
if (output.length > 0) stdout.write(output);
} catch (error) {
if (error instanceof Error) return;
return;
}
}
function preToolUseOutput(raw: string, options: GitBashHookOptions): string {
const payload = parsePreToolUsePayload(raw);
if (payload === null) return "";
return applyGitBashPreToolUseReminder(payload, options);
}
function postCompactOutput(raw: string, options: GitBashHookOptions): string {
const payload = parsePostCompactPayload(raw);
if (payload === null) return "";
return applyGitBashPostCompactReset(payload, options);
}
function isWindowsHost(options: GitBashHookOptions): boolean {
const platform = options.platform ?? process.platform;
if (platform === "win32") return true;
const env = options.env ?? process.env;
return env["OS"] === "Windows_NT" || env["ComSpec"] !== undefined || env["SystemRoot"] !== undefined;
}
function hasReminderMarker(path: string): boolean {
return existsSync(path);
}
function reminderMarkerPath(sessionId: string, pluginDataRoot?: string): string {
const root = pluginDataRoot ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".codex", "omo-git-bash");
return join(root, "git-bash-reminder", `${safePathSegment(sessionId)}.seen`);
}
function safePathSegment(value: string): string {
return value.replace(/[^A-Za-z0-9._-]/g, "_");
}
function isPreToolUsePayload(value: unknown): value is PreToolUsePayload {
if (!isRecord(value)) return false;
return (
value["hook_event_name"] === "PreToolUse" &&
typeof value["cwd"] === "string" &&
typeof value["model"] === "string" &&
typeof value["permission_mode"] === "string" &&
typeof value["session_id"] === "string" &&
typeof value["tool_name"] === "string" &&
typeof value["tool_use_id"] === "string" &&
(value["transcript_path"] === null || typeof value["transcript_path"] === "string") &&
typeof value["turn_id"] === "string" &&
Object.hasOwn(value, "tool_input")
);
}
function isPostCompactPayload(value: unknown): value is PostCompactPayload {
if (!isRecord(value)) return false;
return (
value["hook_event_name"] === "PostCompact" &&
typeof value["session_id"] === "string" &&
(value["transcript_path"] === undefined ||
value["transcript_path"] === null ||
typeof value["transcript_path"] === "string") &&
(value["trigger"] === undefined || typeof value["trigger"] === "string")
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readAll(stdin: NodeJS.ReadableStream): Promise<string> {
return new Promise((resolve, reject) => {
let data = "";
stdin.setEncoding("utf8");
stdin.on("data", (chunk: unknown) => {
data += chunk instanceof Buffer ? chunk.toString() : String(chunk);
});
stdin.once("error", reject);
stdin.once("end", () => resolve(data));
});
}
@@ -0,0 +1,10 @@
export {
applyGitBashPostCompactReset,
applyGitBashPreToolUseReminder,
parsePostCompactPayload,
parsePreToolUsePayload,
runGitBashHookCli,
type GitBashHookOptions,
type PostCompactPayload,
type PreToolUsePayload,
} from "./codex-hook.js";
@@ -0,0 +1,195 @@
import { afterEach, describe, expect, it } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable, Writable } from "node:stream";
import {
applyGitBashPostCompactReset,
applyGitBashPreToolUseReminder,
runGitBashHookCli,
type PostCompactPayload,
type PreToolUsePayload,
} from "../src/codex-hook.js";
const temporaryDirectories: string[] = [];
function createTemporaryDirectory(prefix: string): string {
const directory = mkdtempSync(join(tmpdir(), prefix));
temporaryDirectories.push(directory);
return directory;
}
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function preToolPayload(toolName: string, sessionId = "session-1"): PreToolUsePayload {
return {
cwd: "/repo",
hook_event_name: "PreToolUse",
model: "gpt-5.5",
permission_mode: "default",
session_id: sessionId,
tool_input: { command: "pwd" },
tool_name: toolName,
tool_use_id: "call-1",
transcript_path: null,
turn_id: "turn-1",
};
}
function postCompactPayload(sessionId = "session-1"): PostCompactPayload {
return {
hook_event_name: "PostCompact",
session_id: sessionId,
transcript_path: null,
trigger: "manual",
};
}
function windowsEnv(): NodeJS.ProcessEnv {
return { OS: "Windows_NT", ComSpec: "C:\\Windows\\System32\\cmd.exe" };
}
function captureStdout(): { readonly stdout: Writable; readonly read: () => string } {
let captured = "";
const stdout = new Writable({
write(chunk: unknown, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
captured += chunk instanceof Buffer ? chunk.toString() : String(chunk);
callback();
},
});
return { stdout, read: () => captured };
}
describe("applyGitBashPreToolUseReminder", () => {
it("#given first Windows Bash call #when hook runs #then emits non-blocking git_bash guidance", () => {
// given
const pluginDataRoot = createTemporaryDirectory("omo-git-bash-hook-");
// when
const output = applyGitBashPreToolUseReminder(preToolPayload("Bash"), {
env: windowsEnv(),
platform: "linux",
pluginDataRoot,
});
// then
const parsed = JSON.parse(output);
expect(parsed.hookSpecificOutput).toEqual({
hookEventName: "PreToolUse",
additionalContext:
"On Windows, prefer the OMO git_bash MCP for shell commands before using built-in exec_command. Use exec_command only when git_bash is unavailable or for non-shell operations.",
});
});
it("#given second Windows Bash call in same session #when hook runs #then it stays silent", () => {
// given
const pluginDataRoot = createTemporaryDirectory("omo-git-bash-hook-");
const payload = preToolPayload("Bash");
// when
const first = applyGitBashPreToolUseReminder(payload, { env: windowsEnv(), platform: "linux", pluginDataRoot });
const second = applyGitBashPreToolUseReminder(payload, { env: windowsEnv(), platform: "linux", pluginDataRoot });
// then
expect(first).toContain("git_bash");
expect(second).toBe("");
});
it("#given non-Windows Bash call #when hook runs #then it stays silent", () => {
// given
const pluginDataRoot = createTemporaryDirectory("omo-git-bash-hook-");
// when
const output = applyGitBashPreToolUseReminder(preToolPayload("Bash"), {
env: {},
platform: "darwin",
pluginDataRoot,
});
// then
expect(output).toBe("");
});
it("#given non-Bash tool call #when hook runs #then it stays silent", () => {
// given
const pluginDataRoot = createTemporaryDirectory("omo-git-bash-hook-");
// when
const output = applyGitBashPreToolUseReminder(preToolPayload("exec_command"), {
env: windowsEnv(),
platform: "linux",
pluginDataRoot,
});
// then
expect(output).toBe("");
});
});
describe("applyGitBashPostCompactReset", () => {
it("#given reminder already emitted #when PostCompact runs #then next Windows Bash call emits reminder again", () => {
// given
const pluginDataRoot = createTemporaryDirectory("omo-git-bash-hook-");
const payload = preToolPayload("Bash");
const first = applyGitBashPreToolUseReminder(payload, { env: windowsEnv(), platform: "linux", pluginDataRoot });
const second = applyGitBashPreToolUseReminder(payload, { env: windowsEnv(), platform: "linux", pluginDataRoot });
// when
applyGitBashPostCompactReset(postCompactPayload(), { pluginDataRoot });
const afterCompact = applyGitBashPreToolUseReminder(payload, {
env: windowsEnv(),
platform: "linux",
pluginDataRoot,
});
// then
expect(first).toContain("git_bash");
expect(second).toBe("");
expect(afterCompact).toContain("git_bash");
});
});
describe("runGitBashHookCli", () => {
it("#given Codex PreToolUse stdin on Windows #when CLI hook runs #then it writes reminder JSON", async () => {
// given
const pluginDataRoot = createTemporaryDirectory("omo-git-bash-hook-");
const stdin = Readable.from([JSON.stringify(preToolPayload("Bash"))]);
const capture = captureStdout();
// when
await runGitBashHookCli(stdin, capture.stdout, "pre-tool-use", {
env: windowsEnv(),
platform: "linux",
pluginDataRoot,
});
// then
expect(capture.read()).toContain("git_bash MCP");
});
it("#given PostCompact stdin #when CLI hook runs #then it resets the one-shot reminder", async () => {
// given
const pluginDataRoot = createTemporaryDirectory("omo-git-bash-hook-");
const payload = preToolPayload("Bash");
applyGitBashPreToolUseReminder(payload, { env: windowsEnv(), platform: "linux", pluginDataRoot });
const resetStdin = Readable.from([JSON.stringify(postCompactPayload())]);
const capture = captureStdout();
// when
await runGitBashHookCli(resetStdin, capture.stdout, "post-compact", { pluginDataRoot });
const afterCompact = applyGitBashPreToolUseReminder(payload, {
env: windowsEnv(),
platform: "linux",
pluginDataRoot,
});
// then
expect(capture.read()).toBe("");
expect(afterCompact).toContain("git_bash");
});
});
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": false,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"noEmit": false,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["test/**/*"]
}
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": true,
"verbatimModuleSyntax": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["node", "bun-types"],
"noEmit": true
},
"include": ["src/**/*", "test/**/*"]
}