From 92bad87d84d894b0752ede09a8fbc5f9d4794f52 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 31 May 2026 13:23:24 +0900 Subject: [PATCH] fix(codex): harden windows light install --- bun.lock | 15 ++ docs/guide/installation.md | 13 +- docs/reference/configuration.md | 7 + package.json | 10 +- packages/git-bash-mcp/package.json | 23 ++ packages/git-bash-mcp/src/cli.ts | 19 ++ .../git-bash-mcp/src/git-bash-resolver.ts | 104 ++++++++ packages/git-bash-mcp/src/index.ts | 6 + packages/git-bash-mcp/src/mcp.test.ts | 151 ++++++++++++ packages/git-bash-mcp/src/mcp.ts | 225 ++++++++++++++++++ packages/git-bash-mcp/src/runner.test.ts | 50 ++++ packages/git-bash-mcp/src/runner.ts | 55 +++++ packages/git-bash-mcp/tsconfig.json | 14 ++ packages/omo-codex/README.md | 7 +- packages/omo-codex/plugin/.mcp.json | 5 + .../components/git-bash/hooks/hooks.json | 29 +++ .../plugin/components/git-bash/package.json | 23 ++ .../plugin/components/git-bash/src/cli.ts | 33 +++ .../components/git-bash/src/codex-hook.ts | 180 ++++++++++++++ .../plugin/components/git-bash/src/index.ts | 10 + .../git-bash/test/codex-hook.test.ts | 195 +++++++++++++++ .../components/git-bash/tsconfig.build.json | 13 + .../plugin/components/git-bash/tsconfig.json | 25 ++ packages/omo-codex/plugin/hooks/hooks.json | 22 ++ packages/omo-codex/plugin/package-lock.json | 19 ++ packages/omo-codex/plugin/package.json | 1 + .../scripts/build-bundled-mcp-runtimes.mjs | 16 +- .../plugin/scripts/build-components.mjs | 2 +- .../omo-codex/plugin/test/aggregate.test.mjs | 27 ++- .../test/install-time-build-runtime.test.mjs | 20 ++ .../omo-codex/scripts/install-config.test.mjs | 81 +++++++ packages/omo-codex/scripts/install-local.mjs | 1 + .../scripts/install-mcp-runtime.test.mjs | 17 ++ packages/omo-codex/scripts/install/config.mjs | 18 +- .../scripts/install/git-bash.test.mjs | 43 ++++ .../install-codex/codex-cache-bundled-mcps.ts | 7 + src/cli/install-codex/codex-cache.test.ts | 6 +- .../install-codex/codex-config-toml.test.ts | 63 +++++ src/cli/install-codex/codex-config-toml.ts | 22 +- src/cli/install-codex/git-bash.test.ts | 49 ++++ src/cli/install-codex/install-codex.test.ts | 71 +++++- src/cli/install-codex/install-codex.ts | 1 + 42 files changed, 1675 insertions(+), 23 deletions(-) create mode 100644 packages/git-bash-mcp/package.json create mode 100644 packages/git-bash-mcp/src/cli.ts create mode 100644 packages/git-bash-mcp/src/git-bash-resolver.ts create mode 100644 packages/git-bash-mcp/src/index.ts create mode 100644 packages/git-bash-mcp/src/mcp.test.ts create mode 100644 packages/git-bash-mcp/src/mcp.ts create mode 100644 packages/git-bash-mcp/src/runner.test.ts create mode 100644 packages/git-bash-mcp/src/runner.ts create mode 100644 packages/git-bash-mcp/tsconfig.json create mode 100644 packages/omo-codex/plugin/components/git-bash/hooks/hooks.json create mode 100644 packages/omo-codex/plugin/components/git-bash/package.json create mode 100644 packages/omo-codex/plugin/components/git-bash/src/cli.ts create mode 100644 packages/omo-codex/plugin/components/git-bash/src/codex-hook.ts create mode 100644 packages/omo-codex/plugin/components/git-bash/src/index.ts create mode 100644 packages/omo-codex/plugin/components/git-bash/test/codex-hook.test.ts create mode 100644 packages/omo-codex/plugin/components/git-bash/tsconfig.build.json create mode 100644 packages/omo-codex/plugin/components/git-bash/tsconfig.json create mode 100644 packages/omo-codex/plugin/test/install-time-build-runtime.test.mjs diff --git a/bun.lock b/bun.lock index 3dda9a855..f30a78608 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,7 @@ "@oh-my-opencode/ast-grep-mcp": "workspace:*", "@oh-my-opencode/boulder-state": "workspace:*", "@oh-my-opencode/comment-checker-core": "workspace:*", + "@oh-my-opencode/git-bash-mcp": "workspace:*", "@oh-my-opencode/hashline-core": "workspace:*", "@oh-my-opencode/model-core": "workspace:*", "@oh-my-opencode/omo-codex": "workspace:*", @@ -97,6 +98,16 @@ "name": "@oh-my-opencode/comment-checker-core", "version": "0.1.0", }, + "packages/git-bash-mcp": { + "name": "@oh-my-opencode/git-bash-mcp", + "version": "0.0.0", + "bin": { + "omo-git-bash": "dist/cli.js", + }, + "devDependencies": { + "bun-types": "1.3.12", + }, + }, "packages/hashline-core": { "name": "@oh-my-opencode/hashline-core", "version": "0.1.0", @@ -231,6 +242,8 @@ "@oh-my-opencode/comment-checker-core": ["@oh-my-opencode/comment-checker-core@workspace:packages/comment-checker-core"], + "@oh-my-opencode/git-bash-mcp": ["@oh-my-opencode/git-bash-mcp@workspace:packages/git-bash-mcp"], + "@oh-my-opencode/hashline-core": ["@oh-my-opencode/hashline-core@workspace:packages/hashline-core"], "@oh-my-opencode/model-core": ["@oh-my-opencode/model-core@workspace:packages/model-core"], @@ -541,6 +554,8 @@ "@oh-my-opencode/ast-grep-mcp/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "@oh-my-opencode/git-bash-mcp/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "@opencode-ai/plugin/effect": ["effect@4.0.0-beta.66", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw=="], "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 80d68278c..05e7c95f6 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -42,7 +42,7 @@ bunx lazycodex install --no-tui --codex-autonomous It writes only to `~/.codex/`. No OpenCode interaction, no provider flags. Codex config will register marketplace `sisyphuslabs` from the local built cache under `~/.codex/plugins/cache/sisyphuslabs` and enable plugin `omo@sisyphuslabs`. -On native Windows Codex installs, install Git Bash first: +On native Windows Codex installs, the installer prepares Git Bash before writing Codex config. If Git Bash is missing and `winget` is available, it tries the same best-effort command shown here, then checks again: ```powershell winget install --id Git.Git -e --source winget @@ -59,7 +59,9 @@ setx OMO_CODEX_GIT_BASH_PATH "C:\Program Files\Git\bin\bash.exe" $env:OMO_CODEX_GIT_BASH_PATH = "C:\Program Files\Git\bin\bash.exe" ``` -Codex may still start Windows shell calls through its own defaults. The Light edition does not write a global Codex shell config; instead it verifies Git Bash is available and injects Windows guidance telling Codex to use Git Bash for shell commands. +Set `OMO_CODEX_SKIP_GIT_BASH_AUTO_INSTALL=1` before running the installer if you want to skip the best-effort `winget install --id Git.Git -e --source winget` attempt and handle Git Bash manually. + +Codex may still start Windows shell calls through its own defaults. The Light edition does not write a global Codex shell config; instead it verifies Git Bash is available, enables the Windows-only `git_bash` MCP policy, and injects guidance before the first shell-like call. After compaction, the reminder resets so the next shell-like call gets the same `git_bash` recommendation. > **Clean install note for oh-my-codex / omx users.** Before installing the Light edition into a Codex home that previously used [`oh-my-codex`](https://github.com/Yeachan-Heo/oh-my-codex), uninstall it first with `omx uninstall`, then re-run this installer. Both projects write Codex marketplace plugins, lifecycle hooks, and the `ultrawork`/`ulw` keyword into the same `~/.codex`, so a clean Codex home avoids stale shared `config.toml` keys and duplicate hooks. > @@ -194,7 +196,7 @@ fi The installer expects `~/.codex/` to be writable. Codex CLI's first run creates this directory; if it does not exist yet, install Codex CLI and run it once before continuing. -On native Windows Codex installs, Git Bash is also required: +On native Windows Codex installs, Git Bash is also prepared: ```powershell winget install --id Git.Git -e --source winget @@ -211,6 +213,8 @@ setx OMO_CODEX_GIT_BASH_PATH "C:\Program Files\Git\bin\bash.exe" $env:OMO_CODEX_GIT_BASH_PATH = "C:\Program Files\Git\bin\bash.exe" ``` +Set `OMO_CODEX_SKIP_GIT_BASH_AUTO_INSTALL=1` to disable the best-effort `winget install --id Git.Git -e --source winget` attempt. + ### Step 2: Run the installer Run with the platform flag and the subscription flags you collected in Step 0: @@ -614,7 +618,7 @@ Skip this section if `--platform=opencode`. Otherwise, the user installed the ** - **Plugin cache:** `~/.codex/plugins/cache/sisyphuslabs/omo//` - **Codex marketplace snapshot:** `~/.codex/.tmp/marketplaces/sisyphuslabs/` (stable local copy used by bundled agent TOML links) -- **Component binaries:** `~/.local/bin/omo`, `omo-comment-checker`, `omo-lsp`, `omo-rules`, `omo-start-work-continuation`, `omo-telemetry`, `omo-ultrawork` (or the same names under `$CODEX_LOCAL_BIN_DIR` if set) +- **Component binaries:** `~/.local/bin/omo`, `omo-comment-checker`, `omo-git-bash-hook`, `omo-lsp`, `omo-rules`, `omo-start-work-continuation`, `omo-telemetry`, `omo-ultrawork` (or the same names under `$CODEX_LOCAL_BIN_DIR` if set) - **Codex agent roles:** `~/.codex/agents/{codex-ultrawork-reviewer,explorer,librarian,metis,momus,plan}.toml` linked or copied from the stable marketplace snapshot, so they keep resolving when Codex prunes old plugin-cache versions - **Codex config edits:** `~/.codex/config.toml` gained `[features] plugins = true`, `[features] plugin_hooks = true`, `[marketplaces.sisyphuslabs]` pointing at `~/.codex/plugins/cache/sisyphuslabs`, `[plugins."omo@sisyphuslabs"]`, SHA256-pinned `[hooks.state."omo@sisyphuslabs:..."]` entries, and optionally autonomous permission settings if accepted @@ -624,6 +628,7 @@ Skip this section if `--platform=opencode`. Otherwise, the user installed the ** |-----------|----------|-------------|--------------| | `rules` | TypeScript | `SessionStart`, `UserPromptSubmit`, `PostToolUse`, `PostCompact` | Injects `AGENTS.md`, `CLAUDE.md`, and `.omo/rules/**` into Codex's context | | `comment-checker` | TypeScript | `PostToolUse` (`apply_patch`, `edit`, `write`) | Blocks AI-slop comment patterns in generated code | +| `git-bash` | TypeScript + MCP | `PreToolUse` (`Bash`), `PostCompact`, MCP server | On Windows, exposes `git_bash`; reminds Codex before the first shell-like call and again after compaction | | `lsp` | TypeScript + MCP | MCP server + post-edit hooks | Exposes LSP diagnostics, navigation, symbols, rename via MCP | | `ultrawork` | TypeScript | `UserPromptSubmit` keyword detector | Detects `ulw`/`ultrawork` keyword; the installer links bundled Codex agent TOMLs into `$CODEX_HOME/agents` | | `ulw-loop` | TypeScript | Durable orchestration via `.omo/ulw-loop/` | Multi-goal orchestration with evidence audit trail | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 13ea0570d..ba19ca059 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1010,9 +1010,16 @@ When enabled, OmO registers the hash-anchored `edit` tool and activates the `has | `OMO_CODEX_DISABLE_POSTHOG` | Set to `1` or `true` to disable PostHog telemetry for the `omo-codex` adapter only. Does not affect oh-my-opencode telemetry | | `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY` | Set to `0`, `false`, or `no` to disable anonymous telemetry for `omo-codex` only | | `OMO_CODEX_GIT_BASH_PATH` | Native Windows Codex installs only. Absolute path to Git Bash, for example `C:\Program Files\Git\bin\bash.exe`, when `where bash` cannot find it | +| `OMO_CODEX_SKIP_GIT_BASH_AUTO_INSTALL` | Set to `1` to skip the best-effort `winget install --id Git.Git -e --source winget` attempt during native Windows Codex installs | | `POSTHOG_API_KEY` | Optional override for the built-in PostHog project API key | | `POSTHOG_HOST` | Override the PostHog ingestion host. Defaults to `https://us.i.posthog.com` | +### Codex Light Git Bash MCP + +Native Windows Codex installs bundle a `git_bash` MCP server and write `[plugins."omo@sisyphuslabs".mcp_servers.git_bash] enabled = true`. Non-Windows installs keep the bundled manifest entry but write `enabled = false`, so the plugin detail can still show the server while policy prevents exposure. + +The installer prepares Git Bash with normal detection, `OMO_CODEX_GIT_BASH_PATH`, and a best-effort `winget install --id Git.Git -e --source winget` retry unless `OMO_CODEX_SKIP_GIT_BASH_AUTO_INSTALL=1` is set. The Light plugin also emits a fixed reminder before the first Codex shell-like `Bash` hook call in a Windows session, and resets that reminder after `PostCompact` so the first post-compaction shell call recommends `git_bash` again. + ### Provider-Specific #### Google Auth diff --git a/package.json b/package.json index 5aa490dee..6f91e6380 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "packages/rules-engine", "packages/ast-grep-core", "packages/ast-grep-mcp", + "packages/git-bash-mcp", "packages/utils", "packages/model-core", "packages/prompts-core", @@ -35,6 +36,7 @@ ".agents/skills", "packages/lsp-tools-mcp/dist", "packages/ast-grep-mcp/dist", + "packages/git-bash-mcp/dist", "packages/shared-skills/package.json", "packages/shared-skills/index.mjs", "packages/shared-skills/skills", @@ -52,7 +54,7 @@ "./schema.json": "./dist/oh-my-opencode.schema.json" }, "scripts": { - "build": "bun run build:ast-grep-mcp && bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi --external zod && bun run build:node-require-shim && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema", + "build": "bun run build:ast-grep-mcp && bun run build:git-bash-mcp && bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi --external zod && bun run build:node-require-shim && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema", "build:lsp-tools-mcp": "npm --prefix packages/lsp-tools-mcp ci && npm --prefix packages/lsp-tools-mcp run build", "build:node-require-shim": "bun run script/patch-node-require-shim.ts", "build:all": "bun run build && bun run build:binaries", @@ -65,12 +67,13 @@ "prepublishOnly": "bun run clean && bun run build:lsp-tools-mcp && bun run build", "test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail", "typecheck": "tsgo --noEmit && bun run typecheck:packages", - "typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/prompts-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/hashline-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json && tsgo --noEmit -p packages/omo-codex/tsconfig.json", + "typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/git-bash-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/prompts-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/hashline-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json && tsgo --noEmit -p packages/omo-codex/tsconfig.json", "typecheck:script": "tsgo --noEmit -p script/tsconfig.json", "test": "bun test", "test:codex": "bun run build:ast-grep-mcp && bun run build:lsp-tools-mcp && npm --prefix packages/omo-codex/plugin ci && bun run --cwd packages/omo-codex/plugin build && bun test src/cli/cli-installer.platform.test.ts src/cli/install-codex/codex-cache.test.ts src/cli/install-codex/codex-config-agent-cleanup.test.ts src/cli/install-codex/codex-config-toml.test.ts src/cli/install-codex/install-codex.test.ts src/cli/install-codex/link-cached-plugin-agents.test.ts packages/omo-codex/src/**/*.test.ts packages/utils/src/jsonc-parser.test.ts packages/utils/src/frontmatter.test.ts packages/hashline-core/src/hash-computation.test.ts packages/hashline-core/src/smoke-untested-modules.test.ts packages/rules-engine/src/index.test.ts packages/rules-engine/src/security-boundary.test.ts packages/agents-md-core/src/injector.test.ts packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts && node --test packages/omo-codex/plugin/test/*.test.mjs packages/omo-codex/scripts/install-cache-copy.test.mjs packages/omo-codex/scripts/install-config.test.mjs packages/omo-codex/scripts/install-local.test.mjs packages/omo-codex/scripts/install-mcp-runtime.test.mjs packages/omo-codex/scripts/install-agent-links.test.mjs packages/omo-codex/scripts/install-bin-links.test.mjs packages/omo-codex/scripts/sync-telemetry-component.test.mjs", "test:windows-codex": "bun run test:codex", - "build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build" + "build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build", + "build:git-bash-mcp": "bun run --cwd packages/git-bash-mcp build" }, "keywords": [ "opencode", @@ -113,6 +116,7 @@ "devDependencies": { "@oh-my-opencode/ast-grep-core": "workspace:*", "@oh-my-opencode/ast-grep-mcp": "workspace:*", + "@oh-my-opencode/git-bash-mcp": "workspace:*", "@oh-my-opencode/agents-md-core": "workspace:*", "@oh-my-opencode/boulder-state": "workspace:*", "@oh-my-opencode/comment-checker-core": "workspace:*", diff --git a/packages/git-bash-mcp/package.json b/packages/git-bash-mcp/package.json new file mode 100644 index 000000000..c2efe797f --- /dev/null +++ b/packages/git-bash-mcp/package.json @@ -0,0 +1,23 @@ +{ + "name": "@oh-my-opencode/git-bash-mcp", + "version": "0.0.0", + "type": "module", + "private": true, + "bin": { + "omo-git-bash": "dist/cli.js" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "build": "bun build src/cli.ts --outdir dist --target node --format esm", + "typecheck": "tsgo --noEmit -p tsconfig.json", + "test": "bun test src/*.test.ts" + }, + "devDependencies": { + "bun-types": "1.3.12" + } +} diff --git a/packages/git-bash-mcp/src/cli.ts b/packages/git-bash-mcp/src/cli.ts new file mode 100644 index 000000000..4d030b258 --- /dev/null +++ b/packages/git-bash-mcp/src/cli.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env node +import { argv, stderr } from "node:process"; +import { runMcpStdioServer } from "./mcp"; + +async function main(): Promise { + const [command = "mcp"] = argv.slice(2); + if (command === "mcp") { + await runMcpStdioServer(process.stdin, process.stdout); + return; + } + + stderr.write("Usage: omo-git-bash [mcp]\n"); + process.exitCode = 2; +} + +main().catch((error: unknown) => { + stderr.write(`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/packages/git-bash-mcp/src/git-bash-resolver.ts b/packages/git-bash-mcp/src/git-bash-resolver.ts new file mode 100644 index 000000000..f405c6e8e --- /dev/null +++ b/packages/git-bash-mcp/src/git-bash-resolver.ts @@ -0,0 +1,104 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; + +export const GIT_BASH_ENV_KEY = "OMO_CODEX_GIT_BASH_PATH"; + +const PROGRAM_FILES_GIT_BASH = "C:\\Program Files\\Git\\bin\\bash.exe"; +const PROGRAM_FILES_X86_GIT_BASH = "C:\\Program Files (x86)\\Git\\bin\\bash.exe"; + +export type GitBashSource = "not-required" | "env" | "program-files" | "program-files-x86" | "path"; + +export type GitBashResolution = + | { + readonly found: true; + readonly path: string | null; + readonly source: GitBashSource; + } + | { + readonly found: false; + readonly checkedPaths: readonly string[]; + readonly installHint: string; + }; + +export interface GitBashResolverInput { + readonly platform: string; + readonly env: { readonly [key: string]: string | undefined }; + readonly exists: (path: string) => boolean; + readonly where: (command: "bash") => readonly string[]; +} + +export function resolveGitBash(input: GitBashResolverInput): GitBashResolution { + if (input.platform !== "win32") return { found: true, path: null, source: "not-required" }; + + const checkedPaths: string[] = []; + const envPath = nonEmptyEnvValue(input.env, GIT_BASH_ENV_KEY); + if (envPath !== undefined) { + checkedPaths.push(envPath); + if (isBashExePath(envPath) && input.exists(envPath)) return { found: true, path: envPath, source: "env" }; + return missingGitBash(checkedPaths); + } + + for (const candidate of [ + { path: PROGRAM_FILES_GIT_BASH, source: "program-files" }, + { path: PROGRAM_FILES_X86_GIT_BASH, source: "program-files-x86" }, + ] as const) { + checkedPaths.push(candidate.path); + if (input.exists(candidate.path)) return { found: true, path: candidate.path, source: candidate.source }; + } + + for (const pathCandidate of input.where("bash")) { + const candidate = pathCandidate.trim(); + if (candidate.length === 0) continue; + checkedPaths.push(candidate); + if (isBashExePath(candidate) && input.exists(candidate)) return { found: true, path: candidate, source: "path" }; + } + + return missingGitBash(checkedPaths); +} + +export function resolveGitBashForCurrentProcess(input: { + readonly platform?: string; + readonly env?: { readonly [key: string]: string | undefined }; +} = {}): GitBashResolution { + return resolveGitBash({ + platform: input.platform ?? process.platform, + env: input.env ?? process.env, + exists: existsSync, + where: whereCommand, + }); +} + +function missingGitBash(checkedPaths: readonly string[]): GitBashResolution { + return { + found: false, + checkedPaths, + installHint: [ + "Git Bash is required before the git_bash MCP can run commands on native Windows.", + "Install it with: winget install --id Git.Git -e --source winget", + `For a custom install, set ${GIT_BASH_ENV_KEY}=C:\\path\\to\\bash.exe`, + ].join("\n"), + }; +} + +function nonEmptyEnvValue(env: { readonly [key: string]: string | undefined }, key: string): string | undefined { + const value = env[key]; + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} + +function isBashExePath(path: string): boolean { + return path.toLowerCase().endsWith("bash.exe"); +} + +function whereCommand(command: "bash"): readonly string[] { + try { + return execFileSync("where", [command], { encoding: "utf8" }) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + } catch (error) { + if (error instanceof Error) return []; + throw error; + } +} diff --git a/packages/git-bash-mcp/src/index.ts b/packages/git-bash-mcp/src/index.ts new file mode 100644 index 000000000..522474263 --- /dev/null +++ b/packages/git-bash-mcp/src/index.ts @@ -0,0 +1,6 @@ +export { handleGitBashMcpRequest, runMcpStdioServer } from "./mcp"; +export { resolveGitBash, resolveGitBashForCurrentProcess } from "./git-bash-resolver"; +export { runGitBashCommand } from "./runner"; +export type { GitBashMcpOptions, JsonRpcResponse } from "./mcp"; +export type { GitBashResolution, GitBashResolverInput, GitBashSource } from "./git-bash-resolver"; +export type { GitBashRunInput, GitBashRunResult, RunGitBashCommand } from "./runner"; diff --git a/packages/git-bash-mcp/src/mcp.test.ts b/packages/git-bash-mcp/src/mcp.test.ts new file mode 100644 index 000000000..f3ea884b1 --- /dev/null +++ b/packages/git-bash-mcp/src/mcp.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "bun:test"; +import { handleGitBashMcpRequest } from "./mcp"; +import type { JsonRpcResponse } from "./mcp"; +import type { RunGitBashCommand } from "./runner"; + +describe("git_bash MCP", () => { + it("#given simulated Windows with env override #when which_bash is called #then returns path and source", async () => { + const response = await handleGitBashMcpRequest( + { + jsonrpc: "2.0", + id: "which", + method: "tools/call", + params: { name: "which_bash", arguments: {} }, + }, + { + platform: "win32", + env: { OMO_CODEX_GIT_BASH_PATH: "C:\\Tools\\Git\\bin\\bash.exe" }, + exists: (path) => path === "C:\\Tools\\Git\\bin\\bash.exe", + where: () => [], + }, + ); + + const payload = JSON.parse(textFromResponse(response)) as { readonly source: string; readonly path: string }; + expect(isErrorFromResponse(response)).toBe(false); + expect(payload.source).toBe("env"); + expect(payload.path).toBe("C:\\Tools\\Git\\bin\\bash.exe"); + }); + + it("#given non-Windows platform #when diagnose is called #then reports disabled state", async () => { + const response = await handleGitBashMcpRequest( + { + jsonrpc: "2.0", + id: "diagnose", + method: "tools/call", + params: { name: "diagnose", arguments: {} }, + }, + { platform: "darwin", env: {}, exists: () => false, where: () => [] }, + ); + + const payload = JSON.parse(textFromResponse(response)) as { readonly enabled: boolean; readonly status: string }; + expect(isErrorFromResponse(response)).toBe(false); + expect(payload.enabled).toBe(false); + expect(payload.status).toContain("native Windows"); + }); + + it("#given non-Windows platform #when tools are listed #then command-running tool is hidden", async () => { + const response = await handleGitBashMcpRequest( + { jsonrpc: "2.0", id: "tools", method: "tools/list" }, + { platform: "linux", env: {}, exists: () => false, where: () => [] }, + ); + + expect(toolNamesFromResponse(response)).toEqual(["which_bash", "diagnose"]); + }); + + it("#given run call on simulated Windows #when handled #then uses resolved Git Bash with command payload", async () => { + const captured: { bashPath?: string; command?: string; cwd?: string; timeoutMs?: number } = {}; + const runGitBash: RunGitBashCommand = async (input) => { + captured.bashPath = input.bashPath; + captured.command = input.command; + captured.cwd = input.cwd; + captured.timeoutMs = input.timeoutMs; + return { exitCode: 0, stdout: "ok\n", stderr: "", timedOut: false }; + }; + + const response = await handleGitBashMcpRequest( + { + jsonrpc: "2.0", + id: "run", + method: "tools/call", + params: { name: "run", arguments: { command: "printf ok", cwd: "C:\\repo", timeout_ms: 5000 } }, + }, + { + platform: "win32", + env: { OMO_CODEX_GIT_BASH_PATH: "C:\\Program Files\\Git\\bin\\bash.exe" }, + exists: (path) => path === "C:\\Program Files\\Git\\bin\\bash.exe", + where: () => [], + runGitBash, + }, + ); + + const payload = JSON.parse(textFromResponse(response)) as { readonly stdout: string }; + expect(isErrorFromResponse(response)).toBe(false); + expect(payload.stdout).toBe("ok\n"); + expect(captured).toEqual({ + bashPath: "C:\\Program Files\\Git\\bin\\bash.exe", + command: "printf ok", + cwd: "C:\\repo", + timeoutMs: 5000, + }); + }); + + it("#given malformed run command #when handled #then rejects without spawning", async () => { + let didRun = false; + const response = await handleGitBashMcpRequest( + { + jsonrpc: "2.0", + id: "run", + method: "tools/call", + params: { name: "run", arguments: { command: " " } }, + }, + { + platform: "win32", + env: { OMO_CODEX_GIT_BASH_PATH: "C:\\Program Files\\Git\\bin\\bash.exe" }, + exists: () => true, + where: () => [], + runGitBash: async () => { + didRun = true; + return { exitCode: 0, stdout: "", stderr: "", timedOut: false }; + }, + }, + ); + + expect(isErrorFromResponse(response)).toBe(true); + expect(textFromResponse(response)).toContain("non-empty string"); + expect(didRun).toBe(false); + }); +}); + +function textFromResponse(response: Awaited>): string { + const result = resultFromResponse(response); + const content = result?.content; + if (!Array.isArray(content)) return ""; + const first = content[0]; + if (typeof first !== "object" || first === null || Array.isArray(first)) return ""; + const text = first.text; + return typeof text === "string" ? text : ""; +} + +function toolNamesFromResponse(response: Awaited>): readonly string[] { + const result = resultFromResponse(response); + const tools = result?.tools; + if (!Array.isArray(tools)) return []; + return tools.flatMap((tool) => { + if (typeof tool !== "object" || tool === null || Array.isArray(tool)) return []; + return typeof tool.name === "string" ? [tool.name] : []; + }); +} + +function isErrorFromResponse(response: Awaited>): boolean | undefined { + return booleanField(resultFromResponse(response), "isError"); +} + +function resultFromResponse(response: JsonRpcResponse | undefined): Record | undefined { + if (response === undefined || "error" in response) return undefined; + return response.result; +} + +function booleanField(record: Record | undefined, key: string): boolean | undefined { + const value = record?.[key]; + return typeof value === "boolean" ? value : undefined; +} diff --git a/packages/git-bash-mcp/src/mcp.ts b/packages/git-bash-mcp/src/mcp.ts new file mode 100644 index 000000000..9b2dd8b46 --- /dev/null +++ b/packages/git-bash-mcp/src/mcp.ts @@ -0,0 +1,225 @@ +import type { Readable, Writable } from "node:stream"; +import { resolveGitBash, resolveGitBashForCurrentProcess, type GitBashResolution } from "./git-bash-resolver"; +import { runGitBashCommand, type GitBashRunResult, type RunGitBashCommand } from "./runner"; + +const DEFAULT_TIMEOUT_MS = 120_000; +const MAX_TIMEOUT_MS = 30 * 60_000; + +export interface GitBashMcpOptions { + readonly platform?: string; + readonly env?: { readonly [key: string]: string | undefined }; + readonly exists?: (path: string) => boolean; + readonly where?: (command: "bash") => readonly string[]; + readonly runGitBash?: RunGitBashCommand; +} + +export type JsonRpcResponse = + | { + readonly jsonrpc: "2.0"; + readonly id: string | number | null; + readonly result: Record; + } + | { + readonly jsonrpc: "2.0"; + readonly id: string | number | null; + readonly error: { + readonly code: number; + readonly message: string; + readonly data?: unknown; + }; + }; + +interface ToolDefinition { + readonly name: string; + readonly description: string; + readonly inputSchema: Record; +} + +export async function handleGitBashMcpRequest(input: unknown, options: GitBashMcpOptions = {}): Promise { + if (!isRecord(input)) return errorResponse(null, -32600, "Invalid Request"); + const id = jsonRpcId(input.id); + const method = typeof input.method === "string" ? input.method : null; + + if (method === "initialize") { + const protocolVersion = protocolVersionFromInput(input) ?? "2024-11-05"; + return successResponse(id, { + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "git_bash", version: "0.1.0" }, + protocolVersion, + }); + } + + if (method === "tools/list") return successResponse(id, { tools: toolsForPlatform(platformFromOptions(options)) }); + + if (method === "tools/call") { + const params = isRecord(input.params) ? input.params : {}; + const name = typeof params.name === "string" ? params.name : ""; + const args = isRecord(params.arguments) ? params.arguments : {}; + return await callTool(id, name, args, options); + } + + if (method === "notifications/initialized") return undefined; + + return errorResponse(id, -32601, "Method not found"); +} + +export async function runMcpStdioServer(input: Readable, output: Writable, options: GitBashMcpOptions = {}): Promise { + let buffer = ""; + for await (const chunk of input) { + buffer += String(chunk); + while (true) { + const lineEnd = buffer.indexOf("\n"); + if (lineEnd === -1) break; + const line = buffer.slice(0, lineEnd).trim(); + buffer = buffer.slice(lineEnd + 1); + if (line.length === 0) continue; + const response = await handleGitBashMcpRequest(parseJsonRpcLine(line), options); + if (response !== undefined) output.write(`${JSON.stringify(response)}\n`); + } + } +} + +async function callTool(id: string | number | null, name: string, args: Record, options: GitBashMcpOptions): Promise { + if (name === "which_bash") return toolResponse(id, whichBashPayload(resolve(options))); + if (name === "diagnose") return toolResponse(id, diagnosePayload(resolve(options), platformFromOptions(options))); + if (name === "run") return await runToolResponse(id, args, options); + return toolResponse(id, `Unknown git_bash tool: ${name}`, true); +} + +async function runToolResponse(id: string | number | null, args: Record, options: GitBashMcpOptions): Promise { + const platform = platformFromOptions(options); + if (platform !== "win32") return toolResponse(id, "git_bash run is only available on native Windows.", true); + + const command = typeof args.command === "string" ? args.command.trim() : ""; + if (command.length === 0) return toolResponse(id, "run.command must be a non-empty string.", true); + + const cwd = args.cwd === undefined ? undefined : typeof args.cwd === "string" && args.cwd.trim().length > 0 ? args.cwd : null; + if (cwd === null) return toolResponse(id, "run.cwd must be a non-empty string when provided.", true); + + const timeoutMs = parseTimeoutMs(args.timeout_ms); + if (timeoutMs === null) return toolResponse(id, `run.timeout_ms must be an integer between 1 and ${MAX_TIMEOUT_MS}.`, true); + + const resolution = resolve(options); + if (!resolution.found || resolution.path === null) return toolResponse(id, whichBashPayload(resolution), true); + + try { + const run = options.runGitBash ?? runGitBashCommand; + const result = await run({ bashPath: resolution.path, command, cwd, timeoutMs, env: process.env }); + return toolResponse(id, runPayload(result)); + } catch (error) { + return toolResponse(id, error instanceof Error ? error.message : String(error), true); + } +} + +function toolsForPlatform(platform: string): readonly ToolDefinition[] { + const sharedTools: ToolDefinition[] = [ + { + name: "which_bash", + description: "Resolve the Git Bash bash.exe path used by the git_bash MCP.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, + { + name: "diagnose", + description: "Report whether Git Bash command execution is available on this host.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, + ]; + if (platform !== "win32") return sharedTools; + return [ + { + name: "run", + description: "Run a shell command through Git Bash on native Windows.", + inputSchema: { + type: "object", + properties: { + command: { type: "string" }, + cwd: { type: "string" }, + timeout_ms: { type: "integer", minimum: 1, maximum: MAX_TIMEOUT_MS }, + }, + required: ["command"], + additionalProperties: false, + }, + }, + ...sharedTools, + ]; +} + +function resolve(options: GitBashMcpOptions): GitBashResolution { + if (options.exists === undefined && options.where === undefined) { + return resolveGitBashForCurrentProcess({ + platform: options.platform, + env: options.env, + }); + } + + return resolveGitBash({ + platform: platformFromOptions(options), + env: options.env ?? process.env, + exists: options.exists ?? (() => false), + where: options.where ?? (() => []), + }); +} + +function platformFromOptions(options: GitBashMcpOptions): string { + return options.platform ?? process.platform; +} + +function whichBashPayload(resolution: GitBashResolution): string { + return JSON.stringify(resolution, null, 2); +} + +function diagnosePayload(resolution: GitBashResolution, platform: string): string { + const enabled = platform === "win32" && resolution.found && resolution.path !== null; + const payload = { + platform, + enabled, + status: platform === "win32" ? (enabled ? "ready" : "missing-git-bash") : "disabled: git_bash command execution is only exposed on native Windows", + resolution, + }; + return JSON.stringify(payload, null, 2); +} + +function runPayload(result: GitBashRunResult): string { + return JSON.stringify(result, null, 2); +} + +function toolResponse(id: string | number | null, text: string, isError = false): JsonRpcResponse { + return successResponse(id, { content: [{ type: "text", text }], isError }); +} + +function successResponse(id: string | number | null, result: Record): JsonRpcResponse { + return { jsonrpc: "2.0", id, result }; +} + +function errorResponse(id: string | number | null, code: number, message: string, data?: unknown): JsonRpcResponse { + return { jsonrpc: "2.0", id, error: data === undefined ? { code, message } : { code, message, data } }; +} + +function parseTimeoutMs(value: unknown): number | null { + if (value === undefined) return DEFAULT_TIMEOUT_MS; + if (!Number.isInteger(value)) return null; + const timeoutMs = Number(value); + if (timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) return null; + return timeoutMs; +} + +function protocolVersionFromInput(input: Record): string | null { + if (!isRecord(input.params)) return null; + return typeof input.params.protocolVersion === "string" ? input.params.protocolVersion : null; +} + +function parseJsonRpcLine(line: string): unknown { + try { + return JSON.parse(line) as unknown; + } catch (error) { + return { jsonrpc: "2.0", id: null, method: null, parseError: error instanceof Error ? error.message : String(error) }; + } +} + +function jsonRpcId(value: unknown): string | number | null { + return typeof value === "string" || typeof value === "number" || value === null ? value : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/git-bash-mcp/src/runner.test.ts b/packages/git-bash-mcp/src/runner.test.ts new file mode 100644 index 000000000..ccb985d74 --- /dev/null +++ b/packages/git-bash-mcp/src/runner.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runGitBashCommand } from "./runner"; + +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 }); + } +}); + +describe("Git Bash runner", () => { + it("#given fake bash executable #when command runs #then invokes bash with -lc and command payload", async () => { + const directory = createTemporaryDirectory("omo-git-bash-runner-"); + const argvPath = join(directory, "argv.txt"); + const fakeBashPath = join(directory, "bash.exe"); + writeFileSync( + fakeBashPath, + [ + "#!/bin/sh", + "printf '%s\\n' \"$@\" > \"$FAKE_BASH_ARGV_PATH\"", + "printf 'fake stdout\\n'", + "printf 'fake stderr\\n' >&2", + "exit 7", + "", + ].join("\n"), + ); + chmodSync(fakeBashPath, 0o755); + + const result = await runGitBashCommand({ + bashPath: fakeBashPath, + command: "printf ok", + cwd: directory, + timeoutMs: 5000, + env: { ...process.env, FAKE_BASH_ARGV_PATH: argvPath }, + }); + + expect(readFileSync(argvPath, "utf8")).toBe("-lc\nprintf ok\n"); + expect(result).toEqual({ exitCode: 7, stdout: "fake stdout\n", stderr: "fake stderr\n", timedOut: false }); + }); +}); diff --git a/packages/git-bash-mcp/src/runner.ts b/packages/git-bash-mcp/src/runner.ts new file mode 100644 index 000000000..293baa344 --- /dev/null +++ b/packages/git-bash-mcp/src/runner.ts @@ -0,0 +1,55 @@ +import { spawn } from "node:child_process"; + +export interface GitBashRunInput { + readonly bashPath: string; + readonly command: string; + readonly cwd?: string; + readonly timeoutMs: number; + readonly env?: NodeJS.ProcessEnv; +} + +export interface GitBashRunResult { + readonly exitCode: number | null; + readonly stdout: string; + readonly stderr: string; + readonly timedOut: boolean; +} + +export type RunGitBashCommand = (input: GitBashRunInput) => Promise; + +export async function runGitBashCommand(input: GitBashRunInput): Promise { + return await new Promise((resolve, reject) => { + const child = spawn(input.bashPath, ["-lc", input.command], { + cwd: input.cwd, + env: input.env, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + child.kill(); + }, input.timeoutMs); + timeout.unref(); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on("close", (exitCode) => { + clearTimeout(timeout); + resolve({ exitCode, stdout, stderr, timedOut }); + }); + }); +} diff --git a/packages/git-bash-mcp/tsconfig.json b/packages/git-bash-mcp/tsconfig.json new file mode 100644 index 000000000..079c808c1 --- /dev/null +++ b/packages/git-bash-mcp/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ESNext", "DOM"], + "types": ["bun-types"] + }, + "include": ["src/**/*"] +} diff --git a/packages/omo-codex/README.md b/packages/omo-codex/README.md index 892ad10d9..8d8173ce7 100644 --- a/packages/omo-codex/README.md +++ b/packages/omo-codex/README.md @@ -17,6 +17,7 @@ Codex harness adapter for **oh-my-openagent**. Brings the OMO experience (rules - `rules` (TypeScript) - injects `AGENTS.md` / `CLAUDE.md` / `.omo/rules/**` into context via `SessionStart`, `UserPromptSubmit`, `PostToolUse`, `PostCompact`. - `comment-checker` (TypeScript) - runs `@code-yeongyu/comment-checker` after `apply_patch` / `edit` / `write` tool use. - `lsp` (TypeScript + LSP MCP) - exposes LSP diagnostics, navigation, symbols, rename via MCP + post-edit hooks. +- `git-bash` (TypeScript + Git Bash MCP) - exposes the Windows-only `git_bash` MCP and reminds Codex on the first shell-like call, including the first one after compaction. - `ultrawork` (TypeScript) - keyword detector (`ulw` / `ultrawork`) that injects the full ultrawork directive; bundled agent TOML files are installed into `CODEX_HOME/agents`. - `ulw-loop` (TypeScript) - durable multi-goal orchestration backed by `.omo/ulw-loop/` evidence audit. - `start-work-continuation` (TypeScript) - `Stop` / `SubagentStop` continuation hook for `.omo/boulder.json` start-work plans. @@ -39,7 +40,7 @@ To install **both** the Ultimate edition (OpenCode plugin) and the Light edition The installer copies the built plugin into `~/.codex/plugins/cache/sisyphuslabs/omo//`, writes stable agent TOML links through `~/.codex/.tmp/marketplaces/sisyphuslabs/plugins/omo/`, enables `omo@sisyphuslabs` in `~/.codex/config.toml`, and registers the `sisyphuslabs` marketplace from the local built cache. `lazycodex` is the repo/npm/bin alias; the marketplace identity remains `sisyphuslabs`. -Native Windows installs require Git Bash before the installer mutates `~/.codex/`: +Native Windows installs prepare Git Bash before the installer mutates `~/.codex/`. If `bash.exe` is not already discoverable, the installer first tries the same best-effort install command shown here, then resolves Git Bash again: ```powershell winget install --id Git.Git -e --source winget @@ -56,7 +57,9 @@ setx OMO_CODEX_GIT_BASH_PATH "C:\Program Files\Git\bin\bash.exe" $env:OMO_CODEX_GIT_BASH_PATH = "C:\Program Files\Git\bin\bash.exe" ``` -The installer does not write a global Codex shell config. The rules component injects conditional Windows guidance telling Codex to use Git Bash for shell commands. +Set `OMO_CODEX_SKIP_GIT_BASH_AUTO_INSTALL=1` to skip the best-effort `winget install --id Git.Git -e --source winget` attempt and keep the explicit install guidance path. + +The installer does not write a global Codex shell config. On Windows it enables the plugin MCP policy for `git_bash`; on non-Windows it keeps the manifest bundled but writes `enabled = false` for that MCP server. The Git Bash hook injects fixed guidance before the first Codex shell-like `Bash` hook call in a session, and again before the first shell-like call after `PostCompact`, recommending `git_bash` before built-in `exec_command`. To install both editions in one command, use `--platform=both`. diff --git a/packages/omo-codex/plugin/.mcp.json b/packages/omo-codex/plugin/.mcp.json index b38f76a57..10e12ade7 100644 --- a/packages/omo-codex/plugin/.mcp.json +++ b/packages/omo-codex/plugin/.mcp.json @@ -5,6 +5,11 @@ "args": ["../../ast-grep-mcp/dist/cli.js", "mcp"], "cwd": "." }, + "git_bash": { + "command": "node", + "args": ["../../git-bash-mcp/dist/cli.js", "mcp"], + "cwd": "." + }, "lsp": { "command": "node", "args": ["../../lsp-tools-mcp/dist/cli.js", "mcp"], diff --git a/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json b/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json new file mode 100644 index 000000000..1224be0f5 --- /dev/null +++ b/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json @@ -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" + } + ] + } + ] + } +} diff --git a/packages/omo-codex/plugin/components/git-bash/package.json b/packages/omo-codex/plugin/components/git-bash/package.json new file mode 100644 index 000000000..e154fa00c --- /dev/null +++ b/packages/omo-codex/plugin/components/git-bash/package.json @@ -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" + } +} diff --git a/packages/omo-codex/plugin/components/git-bash/src/cli.ts b/packages/omo-codex/plugin/components/git-bash/src/cli.ts new file mode 100644 index 000000000..4f26ceab2 --- /dev/null +++ b/packages/omo-codex/plugin/components/git-bash/src/cli.ts @@ -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 { + 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); + }); diff --git a/packages/omo-codex/plugin/components/git-bash/src/codex-hook.ts b/packages/omo-codex/plugin/components/git-bash/src/codex-hook.ts new file mode 100644 index 000000000..52deed2c9 --- /dev/null +++ b/packages/omo-codex/plugin/components/git-bash/src/codex-hook.ts @@ -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 { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readAll(stdin: NodeJS.ReadableStream): Promise { + 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)); + }); +} diff --git a/packages/omo-codex/plugin/components/git-bash/src/index.ts b/packages/omo-codex/plugin/components/git-bash/src/index.ts new file mode 100644 index 000000000..f0801aa86 --- /dev/null +++ b/packages/omo-codex/plugin/components/git-bash/src/index.ts @@ -0,0 +1,10 @@ +export { + applyGitBashPostCompactReset, + applyGitBashPreToolUseReminder, + parsePostCompactPayload, + parsePreToolUsePayload, + runGitBashHookCli, + type GitBashHookOptions, + type PostCompactPayload, + type PreToolUsePayload, +} from "./codex-hook.js"; diff --git a/packages/omo-codex/plugin/components/git-bash/test/codex-hook.test.ts b/packages/omo-codex/plugin/components/git-bash/test/codex-hook.test.ts new file mode 100644 index 000000000..97146e83e --- /dev/null +++ b/packages/omo-codex/plugin/components/git-bash/test/codex-hook.test.ts @@ -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"); + }); +}); diff --git a/packages/omo-codex/plugin/components/git-bash/tsconfig.build.json b/packages/omo-codex/plugin/components/git-bash/tsconfig.build.json new file mode 100644 index 000000000..d428d2a08 --- /dev/null +++ b/packages/omo-codex/plugin/components/git-bash/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "noEmit": false, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["test/**/*"] +} diff --git a/packages/omo-codex/plugin/components/git-bash/tsconfig.json b/packages/omo-codex/plugin/components/git-bash/tsconfig.json new file mode 100644 index 000000000..3c017b67d --- /dev/null +++ b/packages/omo-codex/plugin/components/git-bash/tsconfig.json @@ -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/**/*"] +} diff --git a/packages/omo-codex/plugin/hooks/hooks.json b/packages/omo-codex/plugin/hooks/hooks.json index 2d57bd104..d7c48f35f 100644 --- a/packages/omo-codex/plugin/hooks/hooks.json +++ b/packages/omo-codex/plugin/hooks/hooks.json @@ -55,6 +55,17 @@ } ], "PreToolUse": [ + { + "matcher": "^Bash$", + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/components/git-bash/dist/cli.js\" hook pre-tool-use", + "timeout": 5, + "statusMessage": "LazyCodex(0.1.0): Recommending Git Bash Mcp" + } + ] + }, { "matcher": "^create_goal$", "hooks": [ @@ -98,6 +109,17 @@ } ], "PostCompact": [ + { + "matcher": "manual|auto", + "hooks": [ + { + "type": "command", + "command": "node \"${PLUGIN_ROOT}/components/git-bash/dist/cli.js\" hook post-compact", + "timeout": 5, + "statusMessage": "LazyCodex(0.1.0): Resetting Git Bash Mcp Reminder" + } + ] + }, { "matcher": "manual|auto", "hooks": [ diff --git a/packages/omo-codex/plugin/package-lock.json b/packages/omo-codex/plugin/package-lock.json index e2d627e10..e9ed8fa8c 100644 --- a/packages/omo-codex/plugin/package-lock.json +++ b/packages/omo-codex/plugin/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "workspaces": [ "components/comment-checker", + "components/git-bash", "components/rules", "components/lsp", "components/telemetry", @@ -61,6 +62,20 @@ "@code-yeongyu/comment-checker": "^0.8.0" } }, + "components/git-bash": { + "name": "@sisyphuslabs/codex-git-bash-hook", + "version": "0.1.0", + "bin": { + "omo-git-bash-hook": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^25.7.0", + "typescript": "^6.0.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "components/lsp": { "name": "@code-yeongyu/codex-lsp", "version": "0.2.0", @@ -765,6 +780,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@sisyphuslabs/codex-git-bash-hook": { + "resolved": "components/git-bash", + "link": true + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", diff --git a/packages/omo-codex/plugin/package.json b/packages/omo-codex/plugin/package.json index e4718866c..08788130f 100644 --- a/packages/omo-codex/plugin/package.json +++ b/packages/omo-codex/plugin/package.json @@ -7,6 +7,7 @@ "private": true, "workspaces": [ "components/comment-checker", + "components/git-bash", "components/rules", "components/lsp", "components/telemetry", diff --git a/packages/omo-codex/plugin/scripts/build-bundled-mcp-runtimes.mjs b/packages/omo-codex/plugin/scripts/build-bundled-mcp-runtimes.mjs index bc4a95618..448e4fcfd 100644 --- a/packages/omo-codex/plugin/scripts/build-bundled-mcp-runtimes.mjs +++ b/packages/omo-codex/plugin/scripts/build-bundled-mcp-runtimes.mjs @@ -18,6 +18,11 @@ const runtimes = [ packageRoot: join(repoPackagesRoot, "ast-grep-mcp"), requiredOutputs: ["dist/cli.js"], }, + { + label: "git-bash-mcp", + packageRoot: join(repoPackagesRoot, "git-bash-mcp"), + requiredOutputs: ["dist/cli.js"], + }, ]; for (const runtime of runtimes) { @@ -25,13 +30,18 @@ for (const runtime of runtimes) { } function buildRuntime(runtime) { + if (hasBundledDist(runtime)) { + console.log(`Using bundled ${runtime.label} dist`); + return; + } + if (!existsSync(join(runtime.packageRoot, "package.json"))) { assertBundledDist(runtime); console.log(`Using bundled ${runtime.label} dist`); return; } - const result = spawnSync("bun", ["run", "build"], { + const result = spawnSync("npm", ["run", "build"], { cwd: runtime.packageRoot, stdio: "inherit", }); @@ -39,6 +49,10 @@ function buildRuntime(runtime) { if (result.status !== 0) process.exit(result.status ?? 1); } +function hasBundledDist(runtime) { + return runtime.requiredOutputs.every((output) => existsSync(join(runtime.packageRoot, output))); +} + function assertBundledDist(runtime) { const missingOutputs = runtime.requiredOutputs.filter((output) => !existsSync(join(runtime.packageRoot, output))); if (missingOutputs.length === 0) return; diff --git a/packages/omo-codex/plugin/scripts/build-components.mjs b/packages/omo-codex/plugin/scripts/build-components.mjs index f054eab53..172d9e284 100644 --- a/packages/omo-codex/plugin/scripts/build-components.mjs +++ b/packages/omo-codex/plugin/scripts/build-components.mjs @@ -14,7 +14,7 @@ for (const workspace of workspaces) { if (typeof workspacePackageJson.scripts?.build !== "string") continue; console.log(`Building ${workspace}`); - const result = spawnSync("bun", ["run", "--cwd", workspace, "build"], { + const result = spawnSync("npm", ["run", "--workspace", workspace, "build"], { cwd: root, stdio: "inherit", }); diff --git a/packages/omo-codex/plugin/test/aggregate.test.mjs b/packages/omo-codex/plugin/test/aggregate.test.mjs index 6c7fd0481..0425d7044 100644 --- a/packages/omo-codex/plugin/test/aggregate.test.mjs +++ b/packages/omo-codex/plugin/test/aggregate.test.mjs @@ -154,7 +154,7 @@ test("#given hook status messages #when inspected #then labels describe OMO resp assert.deepEqual(genericStatusMessages, []); }); -test("#given aggregate OMO plugin is enabled #when hooks are inspected #then ulw-loop guards budgeted create_goal calls", async () => { +test("#given aggregate OMO plugin is enabled #when hooks are inspected #then shell guidance and ulw-loop guard are registered", async () => { // given const hooks = await readJson("hooks/hooks.json"); const text = JSON.stringify(hooks); @@ -163,9 +163,13 @@ test("#given aggregate OMO plugin is enabled #when hooks are inspected #then ulw const preToolUseGroups = hooks.hooks.PreToolUse; // then + assert.match(text, /components\/git-bash\/dist\/cli\.js/); + assert.match(text, /Recommending Git Bash Mcp/); + assert.match(text, /hook post-compact/); + assert.match(text, /Resetting Git Bash Mcp Reminder/); assert.match(text, /components\/ulw-loop\/dist\/cli\.js/); assert.match(text, /hook pre-tool-use/); - assert.deepEqual(preToolUseGroups.map((group) => group.matcher), ["^create_goal$"]); + assert.deepEqual(preToolUseGroups.map((group) => group.matcher), ["^Bash$", "^create_goal$"]); }); test("#given aggregate MCP config #when inspected #then code MCPs reference package runtimes without package names", async () => { @@ -178,17 +182,19 @@ test("#given aggregate MCP config #when inspected #then code MCPs reference pack // when const lspServer = mcp.mcpServers.lsp; const astGrepServer = mcp.mcpServers.ast_grep; + const gitBashServer = mcp.mcpServers.git_bash; const codeMcpNames = Object.keys(mcp.mcpServers) - .filter((name) => name === "lsp" || name === "ast_grep") + .filter((name) => name === "lsp" || name === "ast_grep" || name === "git_bash") .sort(); const componentLocalMcpSources = lspSources.filter((name) => name.startsWith("lazy-mcp") || name === "lazy-lsp-mcp.ts"); // then - assert.deepEqual(codeMcpNames, ["ast_grep", "lsp"]); + assert.deepEqual(codeMcpNames, ["ast_grep", "git_bash", "lsp"]); assert.equal(packageJson.workspaces.includes("components/lsp/packages/lsp-tools-mcp"), false); assert.equal(packageJson.workspaces.includes("components/ast-grep/packages/ast-grep-mcp"), false); assert.deepEqual(packageJson.dependencies, { "@oh-my-opencode/shared-skills": "file:../../shared-skills" }); assert.match(bundledMcpBuildScript, /ast-grep-mcp/); + assert.match(bundledMcpBuildScript, /git-bash-mcp/); assert.doesNotMatch(packageJson.scripts.build, /--workspaces/); assert.equal(lspServer.command, "node"); assert.deepEqual(lspServer.args, ["../../lsp-tools-mcp/dist/cli.js", "mcp"]); @@ -196,6 +202,9 @@ test("#given aggregate MCP config #when inspected #then code MCPs reference pack assert.equal(astGrepServer.command, "node"); assert.deepEqual(astGrepServer.args, ["../../ast-grep-mcp/dist/cli.js", "mcp"]); assert.equal(astGrepServer.cwd, "."); + assert.equal(gitBashServer.command, "node"); + assert.deepEqual(gitBashServer.args, ["../../git-bash-mcp/dist/cli.js", "mcp"]); + assert.equal(gitBashServer.cwd, "."); assert.deepEqual(componentLocalMcpSources, []); }); @@ -203,12 +212,17 @@ test("#given package-level MCP CLIs #when package metadata is inspected #then bi // given const lspPackageJson = await readJson("../../lsp-tools-mcp/package.json"); const astGrepPackageJson = await readJson("../../ast-grep-mcp/package.json"); + const gitBashPackageJson = await readJson("../../git-bash-mcp/package.json"); // when - const binNames = [...Object.keys(lspPackageJson.bin ?? {}), ...Object.keys(astGrepPackageJson.bin ?? {})].sort(); + const binNames = [ + ...Object.keys(lspPackageJson.bin ?? {}), + ...Object.keys(astGrepPackageJson.bin ?? {}), + ...Object.keys(gitBashPackageJson.bin ?? {}), + ].sort(); // then - assert.deepEqual(binNames, ["omo-ast-grep", "omo-lsp"]); + assert.deepEqual(binNames, ["omo-ast-grep", "omo-git-bash", "omo-lsp"]); for (const name of binNames) { assert.match(name, /^omo-/); } @@ -252,6 +266,7 @@ test("#given component directories #when scanned #then only intentional resource // then assert.deepEqual(componentNames, [ "comment-checker", + "git-bash", "lsp", "rules", "start-work-continuation", diff --git a/packages/omo-codex/plugin/test/install-time-build-runtime.test.mjs b/packages/omo-codex/plugin/test/install-time-build-runtime.test.mjs new file mode 100644 index 000000000..dd21a3a84 --- /dev/null +++ b/packages/omo-codex/plugin/test/install-time-build-runtime.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); + +test("#given aggregate build scripts #when inspected #then install-time build does not invoke Bun", async () => { + // given + const buildComponentsScript = await readFile(join(root, "scripts", "build-components.mjs"), "utf8"); + const buildBundledMcpRuntimesScript = await readFile(join(root, "scripts", "build-bundled-mcp-runtimes.mjs"), "utf8"); + + // when + const installTimeBuildScripts = [buildComponentsScript, buildBundledMcpRuntimesScript].join("\n"); + + // then + assert.doesNotMatch(installTimeBuildScripts, /spawnSync\("bun"/); + assert.doesNotMatch(installTimeBuildScripts, /\bbun\s+run\b/); +}); diff --git a/packages/omo-codex/scripts/install-config.test.mjs b/packages/omo-codex/scripts/install-config.test.mjs index 7e2933bba..efbd450dd 100644 --- a/packages/omo-codex/scripts/install-config.test.mjs +++ b/packages/omo-codex/scripts/install-config.test.mjs @@ -241,3 +241,84 @@ test("#given managed agent role sections #when script installer updates config # assert.match(config, /description = "read-only explorer"/); assert.match(config, /config_file = "\.\/agents\/explorer\.toml"/); }); + +test("#given existing trust and lsp blocks #when updating config #then existing blocks are preserved", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-config-baseline-")); + const configPath = join(root, "config.toml"); + await writeFile( + configPath, + [ + '[plugins."omo@sisyphuslabs"]', + "enabled = true", + "", + '[plugins."omo@sisyphuslabs".mcp_servers.lsp]', + "enabled = true", + "", + '[hooks.state."omo@sisyphuslabs:hooks/hooks.json:post_tool_use:0:0"]', + 'trusted_hash = "sha256:keep"', + "", + ].join("\n"), + ); + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "sisyphuslabs", + marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex/cache/sisyphuslabs" }, + pluginNames: ["omo"], + trustedHookStates: [{ key: "omo@sisyphuslabs:hooks/hooks.json:post_tool_use:0:0", trustedHash: "sha256:keep" }], + }); + + // then + const content = await readFile(configPath, "utf8"); + assert.match(content, /\[plugins\."omo@sisyphuslabs"\]/); + assert.match(content, /\[plugins\."omo@sisyphuslabs"\.mcp_servers\.lsp\]/); + assert.match(content, /\[hooks\.state\."omo@sisyphuslabs:hooks\/hooks\.json:post_tool_use:0:0"\]/); + assert.match(content, /trusted_hash = "sha256:keep"/); +}); + +test("#given windows platform #when updating config #then enables git_bash plugin mcp policy", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-config-git-bash-win32-")); + const configPath = join(root, "config.toml"); + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "sisyphuslabs", + marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex/cache/sisyphuslabs" }, + pluginNames: ["omo"], + platform: "win32", + }); + + // then + const content = await readFile(configPath, "utf8"); + assert.match(content, /\[plugins\."omo@sisyphuslabs"\.mcp_servers\.git_bash\]/); + assert.match(content, /\[plugins\."omo@sisyphuslabs"\.mcp_servers\.git_bash\][\s\S]*?enabled = true/); +}); + +test("#given non-windows platforms #when updating config #then disables git_bash plugin mcp policy", async () => { + for (const platform of ["linux", "darwin"]) { + // given + const root = await mkdtemp(join(tmpdir(), `omo-codex-config-git-bash-${platform}-`)); + const configPath = join(root, "config.toml"); + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "sisyphuslabs", + marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex/cache/sisyphuslabs" }, + pluginNames: ["omo"], + platform, + }); + + // then + const content = await readFile(configPath, "utf8"); + assert.match(content, /\[plugins\."omo@sisyphuslabs"\.mcp_servers\.git_bash\]/); + assert.match(content, /\[plugins\."omo@sisyphuslabs"\.mcp_servers\.git_bash\][\s\S]*?enabled = false/); + } +}); diff --git a/packages/omo-codex/scripts/install-local.mjs b/packages/omo-codex/scripts/install-local.mjs index 01533ea9c..210618b91 100644 --- a/packages/omo-codex/scripts/install-local.mjs +++ b/packages/omo-codex/scripts/install-local.mjs @@ -133,6 +133,7 @@ export async function installMarketplaceLocally(options = {}) { marketplaceName: marketplace.name, marketplaceSource: { sourceType: "local", source: marketplaceRoot }, pluginNames, + platform, trustedHookStates, agentConfigs: [...agentConfigs.values()].sort((left, right) => left.name.localeCompare(right.name)), }); diff --git a/packages/omo-codex/scripts/install-mcp-runtime.test.mjs b/packages/omo-codex/scripts/install-mcp-runtime.test.mjs index 83d3507f2..fc07b358c 100644 --- a/packages/omo-codex/scripts/install-mcp-runtime.test.mjs +++ b/packages/omo-codex/scripts/install-mcp-runtime.test.mjs @@ -12,6 +12,7 @@ test("#given external MCP package runtime #when installing cached plugin #then r const codexHome = await makeTempDir(); const sourceRoot = join(repoRoot, "packages", "omo-codex", "plugin"); const astGrepPackageRoot = join(repoRoot, "packages", "ast-grep-mcp"); + const gitBashPackageRoot = join(repoRoot, "packages", "git-bash-mcp"); const lspPackageRoot = join(repoRoot, "packages", "lsp-tools-mcp"); await writeJson(join(astGrepPackageRoot, "package.json"), { @@ -26,6 +27,12 @@ test("#given external MCP package runtime #when installing cached plugin #then r type: "module", bin: { "omo-lsp": "./dist/cli.js" }, }); + await writeJson(join(gitBashPackageRoot, "package.json"), { + name: "@example/git-bash-mcp", + version: "0.1.0", + type: "module", + bin: { "omo-git-bash": "./dist/cli.js" }, + }); await writeJson(join(sourceRoot, "package.json"), { name: "@example/omo", version: "0.1.0", @@ -37,6 +44,11 @@ test("#given external MCP package runtime #when installing cached plugin #then r args: ["../../ast-grep-mcp/dist/cli.js", "mcp"], cwd: ".", }, + git_bash: { + command: "node", + args: ["../../git-bash-mcp/dist/cli.js", "mcp"], + cwd: ".", + }, lsp: { command: "node", args: ["../../lsp-tools-mcp/dist/cli.js", "mcp"], @@ -45,6 +57,7 @@ test("#given external MCP package runtime #when installing cached plugin #then r }, }); await writeJson(join(astGrepPackageRoot, "dist", "cli.js"), { executable: true }); + await writeJson(join(gitBashPackageRoot, "dist", "cli.js"), { executable: true }); await writeJson(join(lspPackageRoot, "dist", "cli.js"), { executable: true }); await writeJson(join(lspPackageRoot, "dist", "lsp", "manager.js"), { copied: true }); @@ -59,13 +72,17 @@ test("#given external MCP package runtime #when installing cached plugin #then r const cachedMcp = JSON.parse(await readFile(join(result.path, ".mcp.json"), "utf8")); const copiedAstGrepCli = join(result.path, "mcp", "ast_grep", "dist", "cli.js"); + const copiedGitBashCli = join(result.path, "mcp", "git_bash", "dist", "cli.js"); const copiedCli = join(result.path, "mcp", "lsp", "dist", "cli.js"); assert.deepEqual(cachedMcp.mcpServers.ast_grep.args, [copiedAstGrepCli, "mcp"]); + assert.deepEqual(cachedMcp.mcpServers.git_bash.args, [copiedGitBashCli, "mcp"]); assert.deepEqual(cachedMcp.mcpServers.lsp.args, [copiedCli, "mcp"]); assert.equal(Object.hasOwn(cachedMcp.mcpServers.ast_grep, "cwd"), false); + assert.equal(Object.hasOwn(cachedMcp.mcpServers.git_bash, "cwd"), false); assert.equal(Object.hasOwn(cachedMcp.mcpServers.lsp, "cwd"), false); assert.equal((await stat(copiedAstGrepCli)).isFile(), true); + assert.equal((await stat(copiedGitBashCli)).isFile(), true); assert.equal((await stat(copiedCli)).isFile(), true); assert.equal((await stat(join(result.path, "mcp", "lsp", "dist", "lsp", "manager.js"))).isFile(), true); }); diff --git a/packages/omo-codex/scripts/install/config.mjs b/packages/omo-codex/scripts/install/config.mjs index f03093434..a8412635d 100644 --- a/packages/omo-codex/scripts/install/config.mjs +++ b/packages/omo-codex/scripts/install/config.mjs @@ -30,6 +30,7 @@ export async function updateCodexConfig({ marketplaceName, marketplaceSource = defaultMarketplaceSource(marketplaceName, repoRoot), pluginNames, + platform = process.platform, trustedHookStates = [], agentConfigs = [], }) { @@ -53,6 +54,7 @@ export async function updateCodexConfig({ for (const pluginName of pluginNames) { config = ensurePluginEnabled(config, `${pluginName}@${marketplaceName}`); } + config = ensureOmoGitBashMcpPolicy(config, { marketplaceName, pluginNames, platform }); for (const state of trustedHookStates) { config = ensureHookTrusted(config, state.key, state.trustedHash); } @@ -150,6 +152,19 @@ function ensurePluginEnabled(config, pluginKey) { return replaceOrInsertSetting(config, section, "enabled", "true"); } +function ensurePluginMcpEnabled(config, pluginKey, serverName, enabled) { + const header = `plugins.${JSON.stringify(pluginKey)}.mcp_servers.${serverName}`; + const section = findTomlSection(config, header); + const enabledValue = enabled ? "true" : "false"; + if (!section) return appendBlock(config, `[${header}]\nenabled = ${enabledValue}\n`); + return replaceOrInsertSetting(config, section, "enabled", enabledValue); +} + +function ensureOmoGitBashMcpPolicy(config, { marketplaceName, pluginNames, platform }) { + if (marketplaceName !== "sisyphuslabs" || !pluginNames.includes("omo")) return config; + return ensurePluginMcpEnabled(config, "omo@sisyphuslabs", "git_bash", platform === "win32"); +} + function ensureHookTrusted(config, key, trustedHash) { const header = `hooks.state.${JSON.stringify(key)}`; const section = findTomlSection(config, header); @@ -237,7 +252,8 @@ function parseJsonString(value) { try { const parsed = JSON.parse(value); return typeof parsed === "string" ? parsed : null; - } catch { + } catch (error) { + if (error instanceof Error) return null; return null; } } diff --git a/packages/omo-codex/scripts/install/git-bash.test.mjs b/packages/omo-codex/scripts/install/git-bash.test.mjs index ff79f47b0..e2a039b9c 100644 --- a/packages/omo-codex/scripts/install/git-bash.test.mjs +++ b/packages/omo-codex/scripts/install/git-bash.test.mjs @@ -128,3 +128,46 @@ test("#given non-Windows platform #when preparing #then winget is never called", assert.deepEqual(runCalls, []); assert.deepEqual(result, { found: true, path: null, source: "not-required" }); }); + +test("#given Windows without Git Bash and winget fails #when preparing #then original install hint is preserved", async () => { + const missingResolution = { + found: false, + checkedPaths: [programFilesGitBash, programFilesX86GitBash], + installHint: "install hint", + }; + + const result = await prepareGitBashForInstall({ + platform: "win32", + env: {}, + cwd: "C:\\repo", + resolveGitBash: () => missingResolution, + runCommand: async () => { + throw new Error("winget unavailable"); + }, + }); + + assert.deepEqual(result, missingResolution); +}); + +test("#given Windows without Git Bash and winget succeeds but bash is still missing #when preparing #then install hint remains", async () => { + const missingResolution = { + found: false, + checkedPaths: [programFilesGitBash, programFilesX86GitBash], + installHint: "install hint", + }; + let resolveCallCount = 0; + + const result = await prepareGitBashForInstall({ + platform: "win32", + env: {}, + cwd: "C:\\repo", + resolveGitBash: () => { + resolveCallCount += 1; + return missingResolution; + }, + runCommand: async () => {}, + }); + + assert.equal(resolveCallCount, 2); + assert.deepEqual(result, missingResolution); +}); diff --git a/src/cli/install-codex/codex-cache-bundled-mcps.ts b/src/cli/install-codex/codex-cache-bundled-mcps.ts index 73299f32f..edf627d09 100644 --- a/src/cli/install-codex/codex-cache-bundled-mcps.ts +++ b/src/cli/install-codex/codex-cache-bundled-mcps.ts @@ -17,6 +17,13 @@ const BUNDLED_MCP_RUNTIMES = [ destinationArg: "./components/ast-grep-mcp/dist/cli.js", destinationDistFromPlugin: "components/ast-grep-mcp/dist", }, + { + label: "Git Bash MCP", + sourceArg: "../../git-bash-mcp/dist/cli.js", + sourceDistFromPlugin: "../../git-bash-mcp/dist", + destinationArg: "./components/git-bash-mcp/dist/cli.js", + destinationDistFromPlugin: "components/git-bash-mcp/dist", + }, { label: "LSP MCP", sourceArg: "../../lsp-tools-mcp/dist/cli.js", diff --git a/src/cli/install-codex/codex-cache.test.ts b/src/cli/install-codex/codex-cache.test.ts index 00a838f1a..80474bcfa 100644 --- a/src/cli/install-codex/codex-cache.test.ts +++ b/src/cli/install-codex/codex-cache.test.ts @@ -39,6 +39,7 @@ describe("codex-cache", () => { mcpServers: { ast_grep: { cwd: ".", args: ["../../ast-grep-mcp/dist/cli.js", "mcp"] }, custom: { args: ["/usr/local/bin/custom-mcp", "--stdio"] }, + git_bash: { cwd: ".", args: ["../../git-bash-mcp/dist/cli.js", "mcp"] }, lsp: { cwd: ".", args: ["../../lsp-tools-mcp/dist/cli.js", "mcp"] }, }, }), @@ -52,13 +53,16 @@ describe("codex-cache", () => { mcpServers: { ast_grep: { cwd?: string; args: string[] } custom: { args: string[] } + git_bash: { cwd?: string; args: string[] } lsp: { cwd?: string; args: string[] } } } - expect(Object.keys(rewritten.mcpServers).sort()).toEqual(["ast_grep", "custom", "lsp"]) + expect(Object.keys(rewritten.mcpServers).sort()).toEqual(["ast_grep", "custom", "git_bash", "lsp"]) expect(rewritten.mcpServers.ast_grep.cwd).toBeUndefined() expect(rewritten.mcpServers.ast_grep.args[0]).toBe(join(cacheRoot, "components", "ast-grep-mcp", "dist", "cli.js")) expect(rewritten.mcpServers.custom.args).toEqual(["/usr/local/bin/custom-mcp", "--stdio"]) + expect(rewritten.mcpServers.git_bash.cwd).toBeUndefined() + expect(rewritten.mcpServers.git_bash.args[0]).toBe(join(cacheRoot, "components", "git-bash-mcp", "dist", "cli.js")) expect(rewritten.mcpServers.lsp.cwd).toBeUndefined() expect(rewritten.mcpServers.lsp.args[0]).toBe(join(cacheRoot, "components", "lsp-tools-mcp", "dist", "cli.js")) }) diff --git a/src/cli/install-codex/codex-config-toml.test.ts b/src/cli/install-codex/codex-config-toml.test.ts index 7fd8ed83f..10582c648 100644 --- a/src/cli/install-codex/codex-config-toml.test.ts +++ b/src/cli/install-codex/codex-config-toml.test.ts @@ -396,4 +396,67 @@ describe("codex-config-toml", () => { expect(content).not.toContain("stale-explorer") expect(content).not.toContain("ref = undefined") }) + + test("#given windows platform #when updating sisyphuslabs plugin config #then enables git_bash plugin mcp policy", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-config-git-bash-win32-")) + const configPath = join(root, "config.toml") + await writeFile( + configPath, + [ + '[plugins."omo@sisyphuslabs"]', + "enabled = true", + "", + '[plugins."omo@sisyphuslabs".mcp_servers.lsp]', + "enabled = true", + "", + '[hooks.state."omo@sisyphuslabs:hooks/hooks.json:post_tool_use:0:0"]', + 'trusted_hash = "sha256:keep"', + "", + ].join("\n"), + ) + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "sisyphuslabs", + marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex/cache/sisyphuslabs" }, + pluginNames: ["omo"], + platform: "win32", + trustedHookStates: [{ key: "omo@sisyphuslabs:hooks/hooks.json:post_tool_use:0:0", trustedHash: "sha256:keep" }], + }) + + // then + const content = await readFile(configPath, "utf8") + expect(content).toContain('[plugins."omo@sisyphuslabs".mcp_servers.lsp]') + expect(content).toContain('[plugins."omo@sisyphuslabs".mcp_servers.git_bash]') + expect(content).toContain("[hooks.state.\"omo@sisyphuslabs:hooks/hooks.json:post_tool_use:0:0\"]") + expect(content).toMatch(/\[plugins\."omo@sisyphuslabs"\.mcp_servers\.git_bash\][\s\S]*?enabled = true/) + }) + + test("#given non-windows platforms #when updating sisyphuslabs plugin config #then disables git_bash plugin mcp policy", async () => { + for (const platform of ["linux", "darwin"] as const) { + // given + const root = await mkdtemp(join(tmpdir(), `omo-codex-config-git-bash-${platform}-`)) + const configPath = join(root, "config.toml") + + // when + await updateCodexConfig({ + configPath, + repoRoot: "/repo/packages/omo-codex", + marketplaceName: "sisyphuslabs", + marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex/cache/sisyphuslabs" }, + pluginNames: ["omo"], + platform, + }) + + // then + const content = await readFile(configPath, "utf8") + expect(content).toContain('[plugins."omo@sisyphuslabs".mcp_servers.git_bash]') + expect(content).toMatch(/\[plugins\."omo@sisyphuslabs"\.mcp_servers\.git_bash\][\s\S]*?enabled = false/) + expect(content).toContain('[plugins."omo@sisyphuslabs"]') + expect(content).toContain("enabled = true") + } + }) }) diff --git a/src/cli/install-codex/codex-config-toml.ts b/src/cli/install-codex/codex-config-toml.ts index 635eb0569..4229dc29b 100644 --- a/src/cli/install-codex/codex-config-toml.ts +++ b/src/cli/install-codex/codex-config-toml.ts @@ -4,7 +4,7 @@ import { ensureContext7McpServer } from "./codex-config-mcp" import { ensureAutonomousPermissions } from "./codex-config-permissions" import { ensureCodexMultiAgentV2Config } from "./codex-multi-agent-v2-config" import { appendBlock, findTomlSection, replaceOrInsertSetting } from "./toml-section-editor" -import type { CodexAgentConfig, CodexMarketplaceSource, TrustedHookState } from "./types" +import type { CodexAgentConfig, CodexInstallPlatform, CodexMarketplaceSource, TrustedHookState } from "./types" const SISYPHUS_LEGACY_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"] as const const MANAGED_CODEX_AGENT_NAMES = [ @@ -22,6 +22,7 @@ export async function updateCodexConfig(input: { readonly marketplaceName: string readonly marketplaceSource: CodexMarketplaceSource readonly pluginNames: readonly string[] + readonly platform?: CodexInstallPlatform readonly trustedHookStates?: readonly TrustedHookState[] readonly agentConfigs?: readonly CodexAgentConfig[] readonly autonomousPermissions?: boolean @@ -51,6 +52,7 @@ export async function updateCodexConfig(input: { for (const pluginName of input.pluginNames) { config = ensurePluginEnabled(config, `${pluginName}@${input.marketplaceName}`) } + config = ensureOmoGitBashMcpPolicy(config, input) for (const state of input.trustedHookStates ?? []) { config = ensureHookTrusted(config, state.key, state.trustedHash) } @@ -142,6 +144,24 @@ function ensurePluginEnabled(config: string, pluginKey: string): string { return replaceOrInsertSetting(config, section, "enabled", "true") } +function ensurePluginMcpEnabled(config: string, pluginKey: string, serverName: string, enabled: boolean): string { + const header = `plugins.${JSON.stringify(pluginKey)}.mcp_servers.${serverName}` + const section = findTomlSection(config, header) + const enabledValue = enabled ? "true" : "false" + if (!section) return appendBlock(config, `[${header}]\nenabled = ${enabledValue}\n`) + return replaceOrInsertSetting(config, section, "enabled", enabledValue) +} + +function ensureOmoGitBashMcpPolicy(config: string, input: { + readonly marketplaceName: string + readonly pluginNames: readonly string[] + readonly platform?: CodexInstallPlatform +}): string { + if (input.marketplaceName !== "sisyphuslabs" || !input.pluginNames.includes("omo")) return config + const enabled = (input.platform ?? process.platform) === "win32" + return ensurePluginMcpEnabled(config, "omo@sisyphuslabs", "git_bash", enabled) +} + function ensureHookTrusted(config: string, key: string, trustedHash: string): string { const header = `hooks.state.${JSON.stringify(key)}` const section = findTomlSection(config, header) diff --git a/src/cli/install-codex/git-bash.test.ts b/src/cli/install-codex/git-bash.test.ts index 2688def90..c0ceac830 100644 --- a/src/cli/install-codex/git-bash.test.ts +++ b/src/cli/install-codex/git-bash.test.ts @@ -185,4 +185,53 @@ describe("git-bash", () => { expect(runCalls).toEqual([]) expect(result).toEqual({ found: true, path: null, source: "not-required" }) }) + + test("#given Windows without Git Bash and winget fails #when preparing #then original install hint is preserved", async () => { + // given + const missingResolution = { + found: false, + checkedPaths: [PROGRAM_FILES_GIT_BASH, PROGRAM_FILES_X86_GIT_BASH], + installHint: "install hint", + } as const + + // when + const result = await prepareGitBashForInstall({ + platform: "win32", + env: {}, + cwd: "C:\\repo", + resolveGitBash: () => missingResolution, + runCommand: async () => { + throw new Error("winget unavailable") + }, + }) + + // then + expect(result).toEqual(missingResolution) + }) + + test("#given Windows without Git Bash and winget exits successfully but bash is still missing #when preparing #then installer still fails with install hint", async () => { + // given + const missingResolution = { + found: false, + checkedPaths: [PROGRAM_FILES_GIT_BASH, PROGRAM_FILES_X86_GIT_BASH], + installHint: "install hint", + } as const + let resolveCallCount = 0 + + // when + const result = await prepareGitBashForInstall({ + platform: "win32", + env: {}, + cwd: "C:\\repo", + resolveGitBash: () => { + resolveCallCount += 1 + return missingResolution + }, + runCommand: async () => undefined, + }) + + // then + expect(resolveCallCount).toBe(2) + expect(result).toEqual(missingResolution) + }) }) diff --git a/src/cli/install-codex/install-codex.test.ts b/src/cli/install-codex/install-codex.test.ts index 6eca662ab..55d7ef76c 100644 --- a/src/cli/install-codex/install-codex.test.ts +++ b/src/cli/install-codex/install-codex.test.ts @@ -10,6 +10,7 @@ import { findRepoRoot, findRepoRootFromImporter, resolveCodexInstallerBinDir, ru const EXPECTED_OMO_COMPONENT_BINS = [ { name: "omo", target: join("components", "ulw-loop", "dist", "cli.js") }, { name: "omo-comment-checker", target: join("components", "comment-checker", "dist", "cli.js") }, + { name: "omo-git-bash-hook", target: join("components", "git-bash", "dist", "cli.js") }, { name: "omo-lsp", target: join("components", "lsp", "dist", "cli.js") }, { name: "omo-rules", target: join("components", "rules", "dist", "cli.js") }, { name: "omo-start-work-continuation", target: join("components", "start-work-continuation", "dist", "cli.js") }, @@ -153,10 +154,12 @@ describe("install-codex", () => { expect(skillNames).toContain("ulw-loop") expect(skillNames).not.toContain("planing-prometheustic") const mcpManifest = JSON.parse(await readFile(join(pluginPath ?? "", ".mcp.json"), "utf8")) as { - mcpServers: { ast_grep: { args: string[] }; lsp: { args: string[] } } + mcpServers: { ast_grep: { args: string[] }; git_bash: { args: string[] }; lsp: { args: string[] } } } expect(mcpManifest.mcpServers.ast_grep.args[0]).toBe(join(pluginPath ?? "", "components", "ast-grep-mcp", "dist", "cli.js")) expect((await stat(mcpManifest.mcpServers.ast_grep.args[0] ?? "")).isFile()).toBe(true) + expect(mcpManifest.mcpServers.git_bash.args[0]).toBe(join(pluginPath ?? "", "components", "git-bash-mcp", "dist", "cli.js")) + expect((await stat(mcpManifest.mcpServers.git_bash.args[0] ?? "")).isFile()).toBe(true) expect(mcpManifest.mcpServers.lsp.args[0]).toBe(join(pluginPath ?? "", "components", "lsp-tools-mcp", "dist", "cli.js")) expect(mcpManifest.mcpServers.lsp.args[0]).not.toContain("components/lsp/packages") expect(mcpManifest.mcpServers.lsp.args[0]?.startsWith(pluginPath ?? "")).toBe(true) @@ -175,7 +178,64 @@ describe("install-codex", () => { legacyCacheMissing = error instanceof Error } expect(legacyCacheMissing).toBe(true) - }, { timeout: 15_000 }) + }) + + test("#given simulated Windows Codex install #when installing omo #then enables git_bash MCP and trusts shell hooks", async () => { + // given + const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-home-git-bash-win-")) + const binDir = await mkdtemp(join(tmpdir(), "omo-codex-bin-git-bash-win-")) + const repoRoot = process.cwd() + + // when + const result = await runCodexInstaller({ + codexHome, + binDir, + repoRoot, + platform: "win32", + gitBashResolver: () => ({ found: true, path: "C:\\Program Files\\Git\\bin\\bash.exe", source: "program-files" }), + runCommand: async () => undefined, + }) + + // then + const configContent = await readFile(join(codexHome, "config.toml"), "utf8") + expect(configContent).toContain('[plugins."omo@sisyphuslabs".mcp_servers.git_bash]') + expect(configContent).toContain("enabled = true") + expect(configContent).toContain("pre_tool_use") + expect(configContent).toContain("post_compact") + expect(result.gitBashPath).toBe("C:\\Program Files\\Git\\bin\\bash.exe") + const pluginPath = result.installed[0]?.path ?? "" + const mcpManifest = JSON.parse(await readFile(join(pluginPath, ".mcp.json"), "utf8")) as { + readonly mcpServers: { readonly git_bash: { readonly args: readonly string[] } } + } + expect(mcpManifest.mcpServers.git_bash.args[0]).toBe(join(pluginPath, "components", "git-bash-mcp", "dist", "cli.js")) + expect((await stat(mcpManifest.mcpServers.git_bash.args[0] ?? "")).isFile()).toBe(true) + }) + + test("#given simulated Linux Codex install #when installing omo #then keeps git_bash manifest but disables policy exposure", async () => { + // given + const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-home-git-bash-linux-")) + const binDir = await mkdtemp(join(tmpdir(), "omo-codex-bin-git-bash-linux-")) + const repoRoot = process.cwd() + + // when + const result = await runCodexInstaller({ + codexHome, + binDir, + repoRoot, + platform: "linux", + runCommand: async () => undefined, + }) + + // then + const configContent = await readFile(join(codexHome, "config.toml"), "utf8") + expect(configContent).toContain('[plugins."omo@sisyphuslabs".mcp_servers.git_bash]') + expect(configContent).toContain("enabled = false") + const pluginPath = result.installed[0]?.path ?? "" + const mcpManifest = JSON.parse(await readFile(join(pluginPath, ".mcp.json"), "utf8")) as { + readonly mcpServers: { readonly git_bash: { readonly args: readonly string[] } } + } + expect(mcpManifest.mcpServers.git_bash.args[0]).toBe(join(pluginPath, "components", "git-bash-mcp", "dist", "cli.js")) + }) test("#given codex installer #when installing omo #then links omo-prefixed component CLIs to existing cached runtimes", async () => { // given @@ -267,6 +327,7 @@ describe("install-codex", () => { const snapshotMcpManifest: { readonly mcpServers: { readonly ast_grep: { readonly args: readonly string[] } + readonly git_bash: { readonly args: readonly string[] } readonly lsp: { readonly args: readonly string[] } } } = JSON.parse(await readFile(join(snapshotPluginPath, ".mcp.json"), "utf8")) @@ -274,13 +335,17 @@ describe("install-codex", () => { join(snapshotPluginPath, "components", "ast-grep-mcp", "dist", "cli.js"), ) expect((await stat(snapshotMcpManifest.mcpServers.ast_grep.args[0] ?? "")).isFile()).toBe(true) + expect(snapshotMcpManifest.mcpServers.git_bash.args[0]).toBe( + join(snapshotPluginPath, "components", "git-bash-mcp", "dist", "cli.js"), + ) + expect((await stat(snapshotMcpManifest.mcpServers.git_bash.args[0] ?? "")).isFile()).toBe(true) expect(snapshotMcpManifest.mcpServers.lsp.args[0]).toBe( join(snapshotPluginPath, "components", "lsp-tools-mcp", "dist", "cli.js"), ) expect(snapshotMcpManifest.mcpServers.lsp.args[0]).not.toContain("../../lsp-tools-mcp") expect(snapshotMcpManifest.mcpServers.lsp.args[0]).not.toContain("components/lsp/packages") expect((await stat(snapshotMcpManifest.mcpServers.lsp.args[0] ?? "")).isFile()).toBe(true) - }, { timeout: 15_000 }) + }) test("#given autonomous permissions requested #when installing omo #then writes Codex autonomy settings", async () => { // given diff --git a/src/cli/install-codex/install-codex.ts b/src/cli/install-codex/install-codex.ts index 92ae3cc9e..ddd8a5448 100644 --- a/src/cli/install-codex/install-codex.ts +++ b/src/cli/install-codex/install-codex.ts @@ -129,6 +129,7 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom marketplaceName: marketplace.name, marketplaceSource: codexMarketplaceSource(marketplaceRoot), pluginNames: marketplace.plugins.map((plugin) => plugin.name), + platform, trustedHookStates, agentConfigs: [...agentConfigs.values()].sort((left, right) => left.name.localeCompare(right.name)), autonomousPermissions: options.autonomousPermissions === true,