fix(codex): harden windows light install
This commit is contained in:
@@ -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/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user