fix(codex): harden windows light install

This commit is contained in:
YeonGyu-Kim
2026-05-31 13:23:24 +09:00
parent 4e31d7df5d
commit 92bad87d84
42 changed files with 1675 additions and 23 deletions
+15
View File
@@ -29,6 +29,7 @@
"@oh-my-opencode/ast-grep-mcp": "workspace:*", "@oh-my-opencode/ast-grep-mcp": "workspace:*",
"@oh-my-opencode/boulder-state": "workspace:*", "@oh-my-opencode/boulder-state": "workspace:*",
"@oh-my-opencode/comment-checker-core": "workspace:*", "@oh-my-opencode/comment-checker-core": "workspace:*",
"@oh-my-opencode/git-bash-mcp": "workspace:*",
"@oh-my-opencode/hashline-core": "workspace:*", "@oh-my-opencode/hashline-core": "workspace:*",
"@oh-my-opencode/model-core": "workspace:*", "@oh-my-opencode/model-core": "workspace:*",
"@oh-my-opencode/omo-codex": "workspace:*", "@oh-my-opencode/omo-codex": "workspace:*",
@@ -97,6 +98,16 @@
"name": "@oh-my-opencode/comment-checker-core", "name": "@oh-my-opencode/comment-checker-core",
"version": "0.1.0", "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": { "packages/hashline-core": {
"name": "@oh-my-opencode/hashline-core", "name": "@oh-my-opencode/hashline-core",
"version": "0.1.0", "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/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/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"], "@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/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/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=="], "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
+9 -4
View File
@@ -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`. 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 ```powershell
winget install --id Git.Git -e --source winget 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" $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. > **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. 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 ```powershell
winget install --id Git.Git -e --source winget 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" $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 ### Step 2: Run the installer
Run with the platform flag and the subscription flags you collected in Step 0: 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/<version>/` - **Plugin cache:** `~/.codex/plugins/cache/sisyphuslabs/omo/<version>/`
- **Codex marketplace snapshot:** `~/.codex/.tmp/marketplaces/sisyphuslabs/` (stable local copy used by bundled agent TOML links) - **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 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 - **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 | | `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 | | `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 | | `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` | | `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 | | `ulw-loop` | TypeScript | Durable orchestration via `.omo/ulw-loop/` | Multi-goal orchestration with evidence audit trail |
+7
View File
@@ -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_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_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_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_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` | | `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 ### Provider-Specific
#### Google Auth #### Google Auth
+7 -3
View File
@@ -9,6 +9,7 @@
"packages/rules-engine", "packages/rules-engine",
"packages/ast-grep-core", "packages/ast-grep-core",
"packages/ast-grep-mcp", "packages/ast-grep-mcp",
"packages/git-bash-mcp",
"packages/utils", "packages/utils",
"packages/model-core", "packages/model-core",
"packages/prompts-core", "packages/prompts-core",
@@ -35,6 +36,7 @@
".agents/skills", ".agents/skills",
"packages/lsp-tools-mcp/dist", "packages/lsp-tools-mcp/dist",
"packages/ast-grep-mcp/dist", "packages/ast-grep-mcp/dist",
"packages/git-bash-mcp/dist",
"packages/shared-skills/package.json", "packages/shared-skills/package.json",
"packages/shared-skills/index.mjs", "packages/shared-skills/index.mjs",
"packages/shared-skills/skills", "packages/shared-skills/skills",
@@ -52,7 +54,7 @@
"./schema.json": "./dist/oh-my-opencode.schema.json" "./schema.json": "./dist/oh-my-opencode.schema.json"
}, },
"scripts": { "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: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:node-require-shim": "bun run script/patch-node-require-shim.ts",
"build:all": "bun run build && bun run build:binaries", "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", "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", "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": "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", "typecheck:script": "tsgo --noEmit -p script/tsconfig.json",
"test": "bun test", "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: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", "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": [ "keywords": [
"opencode", "opencode",
@@ -113,6 +116,7 @@
"devDependencies": { "devDependencies": {
"@oh-my-opencode/ast-grep-core": "workspace:*", "@oh-my-opencode/ast-grep-core": "workspace:*",
"@oh-my-opencode/ast-grep-mcp": "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/agents-md-core": "workspace:*",
"@oh-my-opencode/boulder-state": "workspace:*", "@oh-my-opencode/boulder-state": "workspace:*",
"@oh-my-opencode/comment-checker-core": "workspace:*", "@oh-my-opencode/comment-checker-core": "workspace:*",
+23
View File
@@ -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"
}
}
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env node
import { argv, stderr } from "node:process";
import { runMcpStdioServer } from "./mcp";
async function main(): Promise<void> {
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;
});
@@ -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;
}
}
+6
View File
@@ -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";
+151
View File
@@ -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<ReturnType<typeof handleGitBashMcpRequest>>): 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<ReturnType<typeof handleGitBashMcpRequest>>): 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<ReturnType<typeof handleGitBashMcpRequest>>): boolean | undefined {
return booleanField(resultFromResponse(response), "isError");
}
function resultFromResponse(response: JsonRpcResponse | undefined): Record<string, unknown> | undefined {
if (response === undefined || "error" in response) return undefined;
return response.result;
}
function booleanField(record: Record<string, unknown> | undefined, key: string): boolean | undefined {
const value = record?.[key];
return typeof value === "boolean" ? value : undefined;
}
+225
View File
@@ -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<string, unknown>;
}
| {
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<string, unknown>;
}
export async function handleGitBashMcpRequest(input: unknown, options: GitBashMcpOptions = {}): Promise<JsonRpcResponse | undefined> {
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<void> {
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<string, unknown>, options: GitBashMcpOptions): Promise<JsonRpcResponse> {
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<string, unknown>, options: GitBashMcpOptions): Promise<JsonRpcResponse> {
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<string, unknown>): 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, unknown>): 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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+50
View File
@@ -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 });
});
});
+55
View File
@@ -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<GitBashRunResult>;
export async function runGitBashCommand(input: GitBashRunInput): Promise<GitBashRunResult> {
return await new Promise<GitBashRunResult>((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 });
});
});
}
+14
View File
@@ -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/**/*"]
}
+5 -2
View File
@@ -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`. - `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. - `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. - `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`. - `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. - `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. - `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/<version>/`, 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`. The installer copies the built plugin into `~/.codex/plugins/cache/sisyphuslabs/omo/<version>/`, 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 ```powershell
winget install --id Git.Git -e --source winget 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" $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`. To install both editions in one command, use `--platform=both`.
+5
View File
@@ -5,6 +5,11 @@
"args": ["../../ast-grep-mcp/dist/cli.js", "mcp"], "args": ["../../ast-grep-mcp/dist/cli.js", "mcp"],
"cwd": "." "cwd": "."
}, },
"git_bash": {
"command": "node",
"args": ["../../git-bash-mcp/dist/cli.js", "mcp"],
"cwd": "."
},
"lsp": { "lsp": {
"command": "node", "command": "node",
"args": ["../../lsp-tools-mcp/dist/cli.js", "mcp"], "args": ["../../lsp-tools-mcp/dist/cli.js", "mcp"],
@@ -0,0 +1,29 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "^Bash$",
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook pre-tool-use",
"timeout": 5,
"statusMessage": "LazyCodex(0.1.0): Recommending Git Bash Mcp"
}
]
}
],
"PostCompact": [
{
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook post-compact",
"timeout": 5,
"statusMessage": "LazyCodex(0.1.0): Resetting Git Bash Mcp Reminder"
}
]
}
]
}
}
@@ -0,0 +1,23 @@
{
"name": "@sisyphuslabs/codex-git-bash-hook",
"version": "0.1.0",
"description": "Codex hook component that reminds Windows sessions to prefer the OMO git_bash MCP.",
"type": "module",
"private": true,
"bin": {
"omo-git-bash-hook": "./dist/cli.js"
},
"files": ["dist", "hooks"],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "bun test test/*.test.ts",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^25.7.0",
"typescript": "^6.0.3"
},
"engines": {
"node": ">=20.0.0"
}
}
@@ -0,0 +1,33 @@
#!/usr/bin/env node
import { runGitBashHookCli } from "./codex-hook.js";
const TOP_LEVEL_HELP =
"Usage:\n omo-git-bash-hook hook pre-tool-use\n omo-git-bash-hook hook post-compact\n omo-git-bash-hook help | --help | -h\n";
async function main(): Promise<number> {
const argv = process.argv.slice(2);
const command = argv[0];
if (command === undefined || command === "help" || command === "--help" || command === "-h") {
process.stdout.write(TOP_LEVEL_HELP);
return 0;
}
if (command === "hook" && argv[1] === "pre-tool-use") {
await runGitBashHookCli(process.stdin, process.stdout, "pre-tool-use");
return 0;
}
if (command === "hook" && argv[1] === "post-compact") {
await runGitBashHookCli(process.stdin, process.stdout, "post-compact");
return 0;
}
process.stderr.write(`[omo-git-bash-hook] unknown command: ${argv.join(" ")}\n${TOP_LEVEL_HELP}`);
return 1;
}
main()
.then((code) => {
process.exit(code);
})
.catch((error: unknown) => {
process.stderr.write(`[omo-git-bash-hook] ${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});
@@ -0,0 +1,180 @@
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
export interface PreToolUsePayload {
readonly cwd: string;
readonly hook_event_name: "PreToolUse";
readonly model: string;
readonly permission_mode: string;
readonly session_id: string;
readonly tool_input: unknown;
readonly tool_name: string;
readonly tool_use_id: string;
readonly transcript_path: string | null;
readonly turn_id: string;
}
export interface GitBashHookOptions {
readonly env?: NodeJS.ProcessEnv;
readonly platform?: NodeJS.Platform | string;
readonly pluginDataRoot?: string;
}
export interface PostCompactPayload {
readonly hook_event_name: "PostCompact";
readonly session_id: string;
readonly transcript_path?: string | null;
readonly trigger?: string;
}
interface PreToolUseHookOutput {
readonly hookSpecificOutput: {
readonly hookEventName: "PreToolUse";
readonly additionalContext: string;
};
}
const BASH_TOOL_NAME = "Bash";
const REMINDER =
"On Windows, prefer the OMO git_bash MCP for shell commands before using built-in exec_command. Use exec_command only when git_bash is unavailable or for non-shell operations.";
export function parsePreToolUsePayload(raw: string): PreToolUsePayload | null {
if (raw.trim().length === 0) return null;
try {
const parsed: unknown = JSON.parse(raw);
return isPreToolUsePayload(parsed) ? parsed : null;
} catch (error) {
if (error instanceof SyntaxError) return null;
return null;
}
}
export function parsePostCompactPayload(raw: string): PostCompactPayload | null {
if (raw.trim().length === 0) return null;
try {
const parsed: unknown = JSON.parse(raw);
return isPostCompactPayload(parsed) ? parsed : null;
} catch (error) {
if (error instanceof SyntaxError) return null;
return null;
}
}
export function applyGitBashPreToolUseReminder(payload: PreToolUsePayload, options: GitBashHookOptions = {}): string {
if (payload.hook_event_name !== "PreToolUse") return "";
if (payload.tool_name !== BASH_TOOL_NAME) return "";
if (!isWindowsHost(options)) return "";
const markerPath = reminderMarkerPath(payload.session_id, options.pluginDataRoot);
if (hasReminderMarker(markerPath)) return "";
mkdirSync(dirname(markerPath), { recursive: true });
writeFileSync(markerPath, `${new Date().toISOString()}\n`);
const output: PreToolUseHookOutput = {
hookSpecificOutput: {
hookEventName: "PreToolUse",
additionalContext: REMINDER,
},
};
return `${JSON.stringify(output)}\n`;
}
export function applyGitBashPostCompactReset(payload: PostCompactPayload, options: GitBashHookOptions = {}): string {
if (payload.hook_event_name !== "PostCompact") return "";
rmSync(reminderMarkerPath(payload.session_id, options.pluginDataRoot), { force: true });
return "";
}
export async function runGitBashHookCli(
stdin: NodeJS.ReadableStream,
stdout: NodeJS.WritableStream,
eventName: "pre-tool-use" | "post-compact" = "pre-tool-use",
options: GitBashHookOptions = {},
): Promise<void> {
try {
const raw = await readAll(stdin);
const output =
eventName === "post-compact" ? postCompactOutput(raw, options) : preToolUseOutput(raw, options);
if (output.length > 0) stdout.write(output);
} catch (error) {
if (error instanceof Error) return;
return;
}
}
function preToolUseOutput(raw: string, options: GitBashHookOptions): string {
const payload = parsePreToolUsePayload(raw);
if (payload === null) return "";
return applyGitBashPreToolUseReminder(payload, options);
}
function postCompactOutput(raw: string, options: GitBashHookOptions): string {
const payload = parsePostCompactPayload(raw);
if (payload === null) return "";
return applyGitBashPostCompactReset(payload, options);
}
function isWindowsHost(options: GitBashHookOptions): boolean {
const platform = options.platform ?? process.platform;
if (platform === "win32") return true;
const env = options.env ?? process.env;
return env["OS"] === "Windows_NT" || env["ComSpec"] !== undefined || env["SystemRoot"] !== undefined;
}
function hasReminderMarker(path: string): boolean {
return existsSync(path);
}
function reminderMarkerPath(sessionId: string, pluginDataRoot?: string): string {
const root = pluginDataRoot ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".codex", "omo-git-bash");
return join(root, "git-bash-reminder", `${safePathSegment(sessionId)}.seen`);
}
function safePathSegment(value: string): string {
return value.replace(/[^A-Za-z0-9._-]/g, "_");
}
function isPreToolUsePayload(value: unknown): value is PreToolUsePayload {
if (!isRecord(value)) return false;
return (
value["hook_event_name"] === "PreToolUse" &&
typeof value["cwd"] === "string" &&
typeof value["model"] === "string" &&
typeof value["permission_mode"] === "string" &&
typeof value["session_id"] === "string" &&
typeof value["tool_name"] === "string" &&
typeof value["tool_use_id"] === "string" &&
(value["transcript_path"] === null || typeof value["transcript_path"] === "string") &&
typeof value["turn_id"] === "string" &&
Object.hasOwn(value, "tool_input")
);
}
function isPostCompactPayload(value: unknown): value is PostCompactPayload {
if (!isRecord(value)) return false;
return (
value["hook_event_name"] === "PostCompact" &&
typeof value["session_id"] === "string" &&
(value["transcript_path"] === undefined ||
value["transcript_path"] === null ||
typeof value["transcript_path"] === "string") &&
(value["trigger"] === undefined || typeof value["trigger"] === "string")
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readAll(stdin: NodeJS.ReadableStream): Promise<string> {
return new Promise((resolve, reject) => {
let data = "";
stdin.setEncoding("utf8");
stdin.on("data", (chunk: unknown) => {
data += chunk instanceof Buffer ? chunk.toString() : String(chunk);
});
stdin.once("error", reject);
stdin.once("end", () => resolve(data));
});
}
@@ -0,0 +1,10 @@
export {
applyGitBashPostCompactReset,
applyGitBashPreToolUseReminder,
parsePostCompactPayload,
parsePreToolUsePayload,
runGitBashHookCli,
type GitBashHookOptions,
type PostCompactPayload,
type PreToolUsePayload,
} from "./codex-hook.js";
@@ -0,0 +1,195 @@
import { afterEach, describe, expect, it } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable, Writable } from "node:stream";
import {
applyGitBashPostCompactReset,
applyGitBashPreToolUseReminder,
runGitBashHookCli,
type PostCompactPayload,
type PreToolUsePayload,
} from "../src/codex-hook.js";
const temporaryDirectories: string[] = [];
function createTemporaryDirectory(prefix: string): string {
const directory = mkdtempSync(join(tmpdir(), prefix));
temporaryDirectories.push(directory);
return directory;
}
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function preToolPayload(toolName: string, sessionId = "session-1"): PreToolUsePayload {
return {
cwd: "/repo",
hook_event_name: "PreToolUse",
model: "gpt-5.5",
permission_mode: "default",
session_id: sessionId,
tool_input: { command: "pwd" },
tool_name: toolName,
tool_use_id: "call-1",
transcript_path: null,
turn_id: "turn-1",
};
}
function postCompactPayload(sessionId = "session-1"): PostCompactPayload {
return {
hook_event_name: "PostCompact",
session_id: sessionId,
transcript_path: null,
trigger: "manual",
};
}
function windowsEnv(): NodeJS.ProcessEnv {
return { OS: "Windows_NT", ComSpec: "C:\\Windows\\System32\\cmd.exe" };
}
function captureStdout(): { readonly stdout: Writable; readonly read: () => string } {
let captured = "";
const stdout = new Writable({
write(chunk: unknown, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
captured += chunk instanceof Buffer ? chunk.toString() : String(chunk);
callback();
},
});
return { stdout, read: () => captured };
}
describe("applyGitBashPreToolUseReminder", () => {
it("#given first Windows Bash call #when hook runs #then emits non-blocking git_bash guidance", () => {
// given
const pluginDataRoot = createTemporaryDirectory("omo-git-bash-hook-");
// when
const output = applyGitBashPreToolUseReminder(preToolPayload("Bash"), {
env: windowsEnv(),
platform: "linux",
pluginDataRoot,
});
// then
const parsed = JSON.parse(output);
expect(parsed.hookSpecificOutput).toEqual({
hookEventName: "PreToolUse",
additionalContext:
"On Windows, prefer the OMO git_bash MCP for shell commands before using built-in exec_command. Use exec_command only when git_bash is unavailable or for non-shell operations.",
});
});
it("#given second Windows Bash call in same session #when hook runs #then it stays silent", () => {
// given
const pluginDataRoot = createTemporaryDirectory("omo-git-bash-hook-");
const payload = preToolPayload("Bash");
// when
const first = applyGitBashPreToolUseReminder(payload, { env: windowsEnv(), platform: "linux", pluginDataRoot });
const second = applyGitBashPreToolUseReminder(payload, { env: windowsEnv(), platform: "linux", pluginDataRoot });
// then
expect(first).toContain("git_bash");
expect(second).toBe("");
});
it("#given non-Windows Bash call #when hook runs #then it stays silent", () => {
// given
const pluginDataRoot = createTemporaryDirectory("omo-git-bash-hook-");
// when
const output = applyGitBashPreToolUseReminder(preToolPayload("Bash"), {
env: {},
platform: "darwin",
pluginDataRoot,
});
// then
expect(output).toBe("");
});
it("#given non-Bash tool call #when hook runs #then it stays silent", () => {
// given
const pluginDataRoot = createTemporaryDirectory("omo-git-bash-hook-");
// when
const output = applyGitBashPreToolUseReminder(preToolPayload("exec_command"), {
env: windowsEnv(),
platform: "linux",
pluginDataRoot,
});
// then
expect(output).toBe("");
});
});
describe("applyGitBashPostCompactReset", () => {
it("#given reminder already emitted #when PostCompact runs #then next Windows Bash call emits reminder again", () => {
// given
const pluginDataRoot = createTemporaryDirectory("omo-git-bash-hook-");
const payload = preToolPayload("Bash");
const first = applyGitBashPreToolUseReminder(payload, { env: windowsEnv(), platform: "linux", pluginDataRoot });
const second = applyGitBashPreToolUseReminder(payload, { env: windowsEnv(), platform: "linux", pluginDataRoot });
// when
applyGitBashPostCompactReset(postCompactPayload(), { pluginDataRoot });
const afterCompact = applyGitBashPreToolUseReminder(payload, {
env: windowsEnv(),
platform: "linux",
pluginDataRoot,
});
// then
expect(first).toContain("git_bash");
expect(second).toBe("");
expect(afterCompact).toContain("git_bash");
});
});
describe("runGitBashHookCli", () => {
it("#given Codex PreToolUse stdin on Windows #when CLI hook runs #then it writes reminder JSON", async () => {
// given
const pluginDataRoot = createTemporaryDirectory("omo-git-bash-hook-");
const stdin = Readable.from([JSON.stringify(preToolPayload("Bash"))]);
const capture = captureStdout();
// when
await runGitBashHookCli(stdin, capture.stdout, "pre-tool-use", {
env: windowsEnv(),
platform: "linux",
pluginDataRoot,
});
// then
expect(capture.read()).toContain("git_bash MCP");
});
it("#given PostCompact stdin #when CLI hook runs #then it resets the one-shot reminder", async () => {
// given
const pluginDataRoot = createTemporaryDirectory("omo-git-bash-hook-");
const payload = preToolPayload("Bash");
applyGitBashPreToolUseReminder(payload, { env: windowsEnv(), platform: "linux", pluginDataRoot });
const resetStdin = Readable.from([JSON.stringify(postCompactPayload())]);
const capture = captureStdout();
// when
await runGitBashHookCli(resetStdin, capture.stdout, "post-compact", { pluginDataRoot });
const afterCompact = applyGitBashPreToolUseReminder(payload, {
env: windowsEnv(),
platform: "linux",
pluginDataRoot,
});
// then
expect(capture.read()).toBe("");
expect(afterCompact).toContain("git_bash");
});
});
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": false,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"noEmit": false,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["test/**/*"]
}
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": true,
"verbatimModuleSyntax": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["node", "bun-types"],
"noEmit": true
},
"include": ["src/**/*", "test/**/*"]
}
@@ -55,6 +55,17 @@
} }
], ],
"PreToolUse": [ "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$", "matcher": "^create_goal$",
"hooks": [ "hooks": [
@@ -98,6 +109,17 @@
} }
], ],
"PostCompact": [ "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", "matcher": "manual|auto",
"hooks": [ "hooks": [
+19
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"workspaces": [ "workspaces": [
"components/comment-checker", "components/comment-checker",
"components/git-bash",
"components/rules", "components/rules",
"components/lsp", "components/lsp",
"components/telemetry", "components/telemetry",
@@ -61,6 +62,20 @@
"@code-yeongyu/comment-checker": "^0.8.0" "@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": { "components/lsp": {
"name": "@code-yeongyu/codex-lsp", "name": "@code-yeongyu/codex-lsp",
"version": "0.2.0", "version": "0.2.0",
@@ -765,6 +780,10 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sisyphuslabs/codex-git-bash-hook": {
"resolved": "components/git-bash",
"link": true
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+1
View File
@@ -7,6 +7,7 @@
"private": true, "private": true,
"workspaces": [ "workspaces": [
"components/comment-checker", "components/comment-checker",
"components/git-bash",
"components/rules", "components/rules",
"components/lsp", "components/lsp",
"components/telemetry", "components/telemetry",
@@ -18,6 +18,11 @@ const runtimes = [
packageRoot: join(repoPackagesRoot, "ast-grep-mcp"), packageRoot: join(repoPackagesRoot, "ast-grep-mcp"),
requiredOutputs: ["dist/cli.js"], requiredOutputs: ["dist/cli.js"],
}, },
{
label: "git-bash-mcp",
packageRoot: join(repoPackagesRoot, "git-bash-mcp"),
requiredOutputs: ["dist/cli.js"],
},
]; ];
for (const runtime of runtimes) { for (const runtime of runtimes) {
@@ -25,13 +30,18 @@ for (const runtime of runtimes) {
} }
function buildRuntime(runtime) { function buildRuntime(runtime) {
if (hasBundledDist(runtime)) {
console.log(`Using bundled ${runtime.label} dist`);
return;
}
if (!existsSync(join(runtime.packageRoot, "package.json"))) { if (!existsSync(join(runtime.packageRoot, "package.json"))) {
assertBundledDist(runtime); assertBundledDist(runtime);
console.log(`Using bundled ${runtime.label} dist`); console.log(`Using bundled ${runtime.label} dist`);
return; return;
} }
const result = spawnSync("bun", ["run", "build"], { const result = spawnSync("npm", ["run", "build"], {
cwd: runtime.packageRoot, cwd: runtime.packageRoot,
stdio: "inherit", stdio: "inherit",
}); });
@@ -39,6 +49,10 @@ function buildRuntime(runtime) {
if (result.status !== 0) process.exit(result.status ?? 1); 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) { function assertBundledDist(runtime) {
const missingOutputs = runtime.requiredOutputs.filter((output) => !existsSync(join(runtime.packageRoot, output))); const missingOutputs = runtime.requiredOutputs.filter((output) => !existsSync(join(runtime.packageRoot, output)));
if (missingOutputs.length === 0) return; if (missingOutputs.length === 0) return;
@@ -14,7 +14,7 @@ for (const workspace of workspaces) {
if (typeof workspacePackageJson.scripts?.build !== "string") continue; if (typeof workspacePackageJson.scripts?.build !== "string") continue;
console.log(`Building ${workspace}`); console.log(`Building ${workspace}`);
const result = spawnSync("bun", ["run", "--cwd", workspace, "build"], { const result = spawnSync("npm", ["run", "--workspace", workspace, "build"], {
cwd: root, cwd: root,
stdio: "inherit", stdio: "inherit",
}); });
@@ -154,7 +154,7 @@ test("#given hook status messages #when inspected #then labels describe OMO resp
assert.deepEqual(genericStatusMessages, []); 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 // given
const hooks = await readJson("hooks/hooks.json"); const hooks = await readJson("hooks/hooks.json");
const text = JSON.stringify(hooks); 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; const preToolUseGroups = hooks.hooks.PreToolUse;
// then // 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, /components\/ulw-loop\/dist\/cli\.js/);
assert.match(text, /hook pre-tool-use/); 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 () => { 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 // when
const lspServer = mcp.mcpServers.lsp; const lspServer = mcp.mcpServers.lsp;
const astGrepServer = mcp.mcpServers.ast_grep; const astGrepServer = mcp.mcpServers.ast_grep;
const gitBashServer = mcp.mcpServers.git_bash;
const codeMcpNames = Object.keys(mcp.mcpServers) const codeMcpNames = Object.keys(mcp.mcpServers)
.filter((name) => name === "lsp" || name === "ast_grep") .filter((name) => name === "lsp" || name === "ast_grep" || name === "git_bash")
.sort(); .sort();
const componentLocalMcpSources = lspSources.filter((name) => name.startsWith("lazy-mcp") || name === "lazy-lsp-mcp.ts"); const componentLocalMcpSources = lspSources.filter((name) => name.startsWith("lazy-mcp") || name === "lazy-lsp-mcp.ts");
// then // 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/lsp/packages/lsp-tools-mcp"), false);
assert.equal(packageJson.workspaces.includes("components/ast-grep/packages/ast-grep-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.deepEqual(packageJson.dependencies, { "@oh-my-opencode/shared-skills": "file:../../shared-skills" });
assert.match(bundledMcpBuildScript, /ast-grep-mcp/); assert.match(bundledMcpBuildScript, /ast-grep-mcp/);
assert.match(bundledMcpBuildScript, /git-bash-mcp/);
assert.doesNotMatch(packageJson.scripts.build, /--workspaces/); assert.doesNotMatch(packageJson.scripts.build, /--workspaces/);
assert.equal(lspServer.command, "node"); assert.equal(lspServer.command, "node");
assert.deepEqual(lspServer.args, ["../../lsp-tools-mcp/dist/cli.js", "mcp"]); 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.equal(astGrepServer.command, "node");
assert.deepEqual(astGrepServer.args, ["../../ast-grep-mcp/dist/cli.js", "mcp"]); assert.deepEqual(astGrepServer.args, ["../../ast-grep-mcp/dist/cli.js", "mcp"]);
assert.equal(astGrepServer.cwd, "."); 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, []); assert.deepEqual(componentLocalMcpSources, []);
}); });
@@ -203,12 +212,17 @@ test("#given package-level MCP CLIs #when package metadata is inspected #then bi
// given // given
const lspPackageJson = await readJson("../../lsp-tools-mcp/package.json"); const lspPackageJson = await readJson("../../lsp-tools-mcp/package.json");
const astGrepPackageJson = await readJson("../../ast-grep-mcp/package.json"); const astGrepPackageJson = await readJson("../../ast-grep-mcp/package.json");
const gitBashPackageJson = await readJson("../../git-bash-mcp/package.json");
// when // 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 // 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) { for (const name of binNames) {
assert.match(name, /^omo-/); assert.match(name, /^omo-/);
} }
@@ -252,6 +266,7 @@ test("#given component directories #when scanned #then only intentional resource
// then // then
assert.deepEqual(componentNames, [ assert.deepEqual(componentNames, [
"comment-checker", "comment-checker",
"git-bash",
"lsp", "lsp",
"rules", "rules",
"start-work-continuation", "start-work-continuation",
@@ -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/);
});
@@ -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, /description = "read-only explorer"/);
assert.match(config, /config_file = "\.\/agents\/explorer\.toml"/); 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/);
}
});
@@ -133,6 +133,7 @@ export async function installMarketplaceLocally(options = {}) {
marketplaceName: marketplace.name, marketplaceName: marketplace.name,
marketplaceSource: { sourceType: "local", source: marketplaceRoot }, marketplaceSource: { sourceType: "local", source: marketplaceRoot },
pluginNames, pluginNames,
platform,
trustedHookStates, trustedHookStates,
agentConfigs: [...agentConfigs.values()].sort((left, right) => left.name.localeCompare(right.name)), agentConfigs: [...agentConfigs.values()].sort((left, right) => left.name.localeCompare(right.name)),
}); });
@@ -12,6 +12,7 @@ test("#given external MCP package runtime #when installing cached plugin #then r
const codexHome = await makeTempDir(); const codexHome = await makeTempDir();
const sourceRoot = join(repoRoot, "packages", "omo-codex", "plugin"); const sourceRoot = join(repoRoot, "packages", "omo-codex", "plugin");
const astGrepPackageRoot = join(repoRoot, "packages", "ast-grep-mcp"); const astGrepPackageRoot = join(repoRoot, "packages", "ast-grep-mcp");
const gitBashPackageRoot = join(repoRoot, "packages", "git-bash-mcp");
const lspPackageRoot = join(repoRoot, "packages", "lsp-tools-mcp"); const lspPackageRoot = join(repoRoot, "packages", "lsp-tools-mcp");
await writeJson(join(astGrepPackageRoot, "package.json"), { await writeJson(join(astGrepPackageRoot, "package.json"), {
@@ -26,6 +27,12 @@ test("#given external MCP package runtime #when installing cached plugin #then r
type: "module", type: "module",
bin: { "omo-lsp": "./dist/cli.js" }, 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"), { await writeJson(join(sourceRoot, "package.json"), {
name: "@example/omo", name: "@example/omo",
version: "0.1.0", 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"], args: ["../../ast-grep-mcp/dist/cli.js", "mcp"],
cwd: ".", cwd: ".",
}, },
git_bash: {
command: "node",
args: ["../../git-bash-mcp/dist/cli.js", "mcp"],
cwd: ".",
},
lsp: { lsp: {
command: "node", command: "node",
args: ["../../lsp-tools-mcp/dist/cli.js", "mcp"], 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(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", "cli.js"), { executable: true });
await writeJson(join(lspPackageRoot, "dist", "lsp", "manager.js"), { copied: 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 cachedMcp = JSON.parse(await readFile(join(result.path, ".mcp.json"), "utf8"));
const copiedAstGrepCli = join(result.path, "mcp", "ast_grep", "dist", "cli.js"); 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"); const copiedCli = join(result.path, "mcp", "lsp", "dist", "cli.js");
assert.deepEqual(cachedMcp.mcpServers.ast_grep.args, [copiedAstGrepCli, "mcp"]); 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.deepEqual(cachedMcp.mcpServers.lsp.args, [copiedCli, "mcp"]);
assert.equal(Object.hasOwn(cachedMcp.mcpServers.ast_grep, "cwd"), false); 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(Object.hasOwn(cachedMcp.mcpServers.lsp, "cwd"), false);
assert.equal((await stat(copiedAstGrepCli)).isFile(), true); 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(copiedCli)).isFile(), true);
assert.equal((await stat(join(result.path, "mcp", "lsp", "dist", "lsp", "manager.js"))).isFile(), true); assert.equal((await stat(join(result.path, "mcp", "lsp", "dist", "lsp", "manager.js"))).isFile(), true);
}); });
+17 -1
View File
@@ -30,6 +30,7 @@ export async function updateCodexConfig({
marketplaceName, marketplaceName,
marketplaceSource = defaultMarketplaceSource(marketplaceName, repoRoot), marketplaceSource = defaultMarketplaceSource(marketplaceName, repoRoot),
pluginNames, pluginNames,
platform = process.platform,
trustedHookStates = [], trustedHookStates = [],
agentConfigs = [], agentConfigs = [],
}) { }) {
@@ -53,6 +54,7 @@ export async function updateCodexConfig({
for (const pluginName of pluginNames) { for (const pluginName of pluginNames) {
config = ensurePluginEnabled(config, `${pluginName}@${marketplaceName}`); config = ensurePluginEnabled(config, `${pluginName}@${marketplaceName}`);
} }
config = ensureOmoGitBashMcpPolicy(config, { marketplaceName, pluginNames, platform });
for (const state of trustedHookStates) { for (const state of trustedHookStates) {
config = ensureHookTrusted(config, state.key, state.trustedHash); config = ensureHookTrusted(config, state.key, state.trustedHash);
} }
@@ -150,6 +152,19 @@ function ensurePluginEnabled(config, pluginKey) {
return replaceOrInsertSetting(config, section, "enabled", "true"); 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) { function ensureHookTrusted(config, key, trustedHash) {
const header = `hooks.state.${JSON.stringify(key)}`; const header = `hooks.state.${JSON.stringify(key)}`;
const section = findTomlSection(config, header); const section = findTomlSection(config, header);
@@ -237,7 +252,8 @@ function parseJsonString(value) {
try { try {
const parsed = JSON.parse(value); const parsed = JSON.parse(value);
return typeof parsed === "string" ? parsed : null; return typeof parsed === "string" ? parsed : null;
} catch { } catch (error) {
if (error instanceof Error) return null;
return null; return null;
} }
} }
@@ -128,3 +128,46 @@ test("#given non-Windows platform #when preparing #then winget is never called",
assert.deepEqual(runCalls, []); assert.deepEqual(runCalls, []);
assert.deepEqual(result, { found: true, path: null, source: "not-required" }); 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);
});
@@ -17,6 +17,13 @@ const BUNDLED_MCP_RUNTIMES = [
destinationArg: "./components/ast-grep-mcp/dist/cli.js", destinationArg: "./components/ast-grep-mcp/dist/cli.js",
destinationDistFromPlugin: "components/ast-grep-mcp/dist", 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", label: "LSP MCP",
sourceArg: "../../lsp-tools-mcp/dist/cli.js", sourceArg: "../../lsp-tools-mcp/dist/cli.js",
+5 -1
View File
@@ -39,6 +39,7 @@ describe("codex-cache", () => {
mcpServers: { mcpServers: {
ast_grep: { cwd: ".", args: ["../../ast-grep-mcp/dist/cli.js", "mcp"] }, ast_grep: { cwd: ".", args: ["../../ast-grep-mcp/dist/cli.js", "mcp"] },
custom: { args: ["/usr/local/bin/custom-mcp", "--stdio"] }, 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"] }, lsp: { cwd: ".", args: ["../../lsp-tools-mcp/dist/cli.js", "mcp"] },
}, },
}), }),
@@ -52,13 +53,16 @@ describe("codex-cache", () => {
mcpServers: { mcpServers: {
ast_grep: { cwd?: string; args: string[] } ast_grep: { cwd?: string; args: string[] }
custom: { args: string[] } custom: { args: string[] }
git_bash: { cwd?: string; args: string[] }
lsp: { 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.cwd).toBeUndefined()
expect(rewritten.mcpServers.ast_grep.args[0]).toBe(join(cacheRoot, "components", "ast-grep-mcp", "dist", "cli.js")) 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.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.cwd).toBeUndefined()
expect(rewritten.mcpServers.lsp.args[0]).toBe(join(cacheRoot, "components", "lsp-tools-mcp", "dist", "cli.js")) expect(rewritten.mcpServers.lsp.args[0]).toBe(join(cacheRoot, "components", "lsp-tools-mcp", "dist", "cli.js"))
}) })
@@ -396,4 +396,67 @@ describe("codex-config-toml", () => {
expect(content).not.toContain("stale-explorer") expect(content).not.toContain("stale-explorer")
expect(content).not.toContain("ref = undefined") 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")
}
})
}) })
+21 -1
View File
@@ -4,7 +4,7 @@ import { ensureContext7McpServer } from "./codex-config-mcp"
import { ensureAutonomousPermissions } from "./codex-config-permissions" import { ensureAutonomousPermissions } from "./codex-config-permissions"
import { ensureCodexMultiAgentV2Config } from "./codex-multi-agent-v2-config" import { ensureCodexMultiAgentV2Config } from "./codex-multi-agent-v2-config"
import { appendBlock, findTomlSection, replaceOrInsertSetting } from "./toml-section-editor" 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 SISYPHUS_LEGACY_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"] as const
const MANAGED_CODEX_AGENT_NAMES = [ const MANAGED_CODEX_AGENT_NAMES = [
@@ -22,6 +22,7 @@ export async function updateCodexConfig(input: {
readonly marketplaceName: string readonly marketplaceName: string
readonly marketplaceSource: CodexMarketplaceSource readonly marketplaceSource: CodexMarketplaceSource
readonly pluginNames: readonly string[] readonly pluginNames: readonly string[]
readonly platform?: CodexInstallPlatform
readonly trustedHookStates?: readonly TrustedHookState[] readonly trustedHookStates?: readonly TrustedHookState[]
readonly agentConfigs?: readonly CodexAgentConfig[] readonly agentConfigs?: readonly CodexAgentConfig[]
readonly autonomousPermissions?: boolean readonly autonomousPermissions?: boolean
@@ -51,6 +52,7 @@ export async function updateCodexConfig(input: {
for (const pluginName of input.pluginNames) { for (const pluginName of input.pluginNames) {
config = ensurePluginEnabled(config, `${pluginName}@${input.marketplaceName}`) config = ensurePluginEnabled(config, `${pluginName}@${input.marketplaceName}`)
} }
config = ensureOmoGitBashMcpPolicy(config, input)
for (const state of input.trustedHookStates ?? []) { for (const state of input.trustedHookStates ?? []) {
config = ensureHookTrusted(config, state.key, state.trustedHash) config = ensureHookTrusted(config, state.key, state.trustedHash)
} }
@@ -142,6 +144,24 @@ function ensurePluginEnabled(config: string, pluginKey: string): string {
return replaceOrInsertSetting(config, section, "enabled", "true") 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 { function ensureHookTrusted(config: string, key: string, trustedHash: string): string {
const header = `hooks.state.${JSON.stringify(key)}` const header = `hooks.state.${JSON.stringify(key)}`
const section = findTomlSection(config, header) const section = findTomlSection(config, header)
+49
View File
@@ -185,4 +185,53 @@ describe("git-bash", () => {
expect(runCalls).toEqual([]) expect(runCalls).toEqual([])
expect(result).toEqual({ found: true, path: null, source: "not-required" }) 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)
})
}) })
+68 -3
View File
@@ -10,6 +10,7 @@ import { findRepoRoot, findRepoRootFromImporter, resolveCodexInstallerBinDir, ru
const EXPECTED_OMO_COMPONENT_BINS = [ const EXPECTED_OMO_COMPONENT_BINS = [
{ name: "omo", target: join("components", "ulw-loop", "dist", "cli.js") }, { name: "omo", target: join("components", "ulw-loop", "dist", "cli.js") },
{ name: "omo-comment-checker", target: join("components", "comment-checker", "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-lsp", target: join("components", "lsp", "dist", "cli.js") },
{ name: "omo-rules", target: join("components", "rules", "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") }, { 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).toContain("ulw-loop")
expect(skillNames).not.toContain("planing-prometheustic") expect(skillNames).not.toContain("planing-prometheustic")
const mcpManifest = JSON.parse(await readFile(join(pluginPath ?? "", ".mcp.json"), "utf8")) as { 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(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((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]).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]).not.toContain("components/lsp/packages")
expect(mcpManifest.mcpServers.lsp.args[0]?.startsWith(pluginPath ?? "")).toBe(true) expect(mcpManifest.mcpServers.lsp.args[0]?.startsWith(pluginPath ?? "")).toBe(true)
@@ -175,7 +178,64 @@ describe("install-codex", () => {
legacyCacheMissing = error instanceof Error legacyCacheMissing = error instanceof Error
} }
expect(legacyCacheMissing).toBe(true) 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 () => { test("#given codex installer #when installing omo #then links omo-prefixed component CLIs to existing cached runtimes", async () => {
// given // given
@@ -267,6 +327,7 @@ describe("install-codex", () => {
const snapshotMcpManifest: { const snapshotMcpManifest: {
readonly mcpServers: { readonly mcpServers: {
readonly ast_grep: { readonly args: readonly string[] } readonly ast_grep: { readonly args: readonly string[] }
readonly git_bash: { readonly args: readonly string[] }
readonly lsp: { readonly args: readonly string[] } readonly lsp: { readonly args: readonly string[] }
} }
} = JSON.parse(await readFile(join(snapshotPluginPath, ".mcp.json"), "utf8")) } = 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"), join(snapshotPluginPath, "components", "ast-grep-mcp", "dist", "cli.js"),
) )
expect((await stat(snapshotMcpManifest.mcpServers.ast_grep.args[0] ?? "")).isFile()).toBe(true) 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( expect(snapshotMcpManifest.mcpServers.lsp.args[0]).toBe(
join(snapshotPluginPath, "components", "lsp-tools-mcp", "dist", "cli.js"), 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("../../lsp-tools-mcp")
expect(snapshotMcpManifest.mcpServers.lsp.args[0]).not.toContain("components/lsp/packages") expect(snapshotMcpManifest.mcpServers.lsp.args[0]).not.toContain("components/lsp/packages")
expect((await stat(snapshotMcpManifest.mcpServers.lsp.args[0] ?? "")).isFile()).toBe(true) 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 () => { test("#given autonomous permissions requested #when installing omo #then writes Codex autonomy settings", async () => {
// given // given
+1
View File
@@ -129,6 +129,7 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom
marketplaceName: marketplace.name, marketplaceName: marketplace.name,
marketplaceSource: codexMarketplaceSource(marketplaceRoot), marketplaceSource: codexMarketplaceSource(marketplaceRoot),
pluginNames: marketplace.plugins.map((plugin) => plugin.name), pluginNames: marketplace.plugins.map((plugin) => plugin.name),
platform,
trustedHookStates, trustedHookStates,
agentConfigs: [...agentConfigs.values()].sort((left, right) => left.name.localeCompare(right.name)), agentConfigs: [...agentConfigs.values()].sort((left, right) => left.name.localeCompare(right.name)),
autonomousPermissions: options.autonomousPermissions === true, autonomousPermissions: options.autonomousPermissions === true,