fix(codex): harden windows light install
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
"@oh-my-opencode/ast-grep-mcp": "workspace:*",
|
||||
"@oh-my-opencode/boulder-state": "workspace:*",
|
||||
"@oh-my-opencode/comment-checker-core": "workspace:*",
|
||||
"@oh-my-opencode/git-bash-mcp": "workspace:*",
|
||||
"@oh-my-opencode/hashline-core": "workspace:*",
|
||||
"@oh-my-opencode/model-core": "workspace:*",
|
||||
"@oh-my-opencode/omo-codex": "workspace:*",
|
||||
@@ -97,6 +98,16 @@
|
||||
"name": "@oh-my-opencode/comment-checker-core",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
"packages/git-bash-mcp": {
|
||||
"name": "@oh-my-opencode/git-bash-mcp",
|
||||
"version": "0.0.0",
|
||||
"bin": {
|
||||
"omo-git-bash": "dist/cli.js",
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "1.3.12",
|
||||
},
|
||||
},
|
||||
"packages/hashline-core": {
|
||||
"name": "@oh-my-opencode/hashline-core",
|
||||
"version": "0.1.0",
|
||||
@@ -231,6 +242,8 @@
|
||||
|
||||
"@oh-my-opencode/comment-checker-core": ["@oh-my-opencode/comment-checker-core@workspace:packages/comment-checker-core"],
|
||||
|
||||
"@oh-my-opencode/git-bash-mcp": ["@oh-my-opencode/git-bash-mcp@workspace:packages/git-bash-mcp"],
|
||||
|
||||
"@oh-my-opencode/hashline-core": ["@oh-my-opencode/hashline-core@workspace:packages/hashline-core"],
|
||||
|
||||
"@oh-my-opencode/model-core": ["@oh-my-opencode/model-core@workspace:packages/model-core"],
|
||||
@@ -541,6 +554,8 @@
|
||||
|
||||
"@oh-my-opencode/ast-grep-mcp/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
|
||||
|
||||
"@oh-my-opencode/git-bash-mcp/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
|
||||
|
||||
"@opencode-ai/plugin/effect": ["effect@4.0.0-beta.66", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw=="],
|
||||
|
||||
"@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
|
||||
@@ -42,7 +42,7 @@ bunx lazycodex install --no-tui --codex-autonomous
|
||||
|
||||
It writes only to `~/.codex/`. No OpenCode interaction, no provider flags. Codex config will register marketplace `sisyphuslabs` from the local built cache under `~/.codex/plugins/cache/sisyphuslabs` and enable plugin `omo@sisyphuslabs`.
|
||||
|
||||
On native Windows Codex installs, install Git Bash first:
|
||||
On native Windows Codex installs, the installer prepares Git Bash before writing Codex config. If Git Bash is missing and `winget` is available, it tries the same best-effort command shown here, then checks again:
|
||||
|
||||
```powershell
|
||||
winget install --id Git.Git -e --source winget
|
||||
@@ -59,7 +59,9 @@ setx OMO_CODEX_GIT_BASH_PATH "C:\Program Files\Git\bin\bash.exe"
|
||||
$env:OMO_CODEX_GIT_BASH_PATH = "C:\Program Files\Git\bin\bash.exe"
|
||||
```
|
||||
|
||||
Codex may still start Windows shell calls through its own defaults. The Light edition does not write a global Codex shell config; instead it verifies Git Bash is available and injects Windows guidance telling Codex to use Git Bash for shell commands.
|
||||
Set `OMO_CODEX_SKIP_GIT_BASH_AUTO_INSTALL=1` before running the installer if you want to skip the best-effort `winget install --id Git.Git -e --source winget` attempt and handle Git Bash manually.
|
||||
|
||||
Codex may still start Windows shell calls through its own defaults. The Light edition does not write a global Codex shell config; instead it verifies Git Bash is available, enables the Windows-only `git_bash` MCP policy, and injects guidance before the first shell-like call. After compaction, the reminder resets so the next shell-like call gets the same `git_bash` recommendation.
|
||||
|
||||
> **Clean install note for oh-my-codex / omx users.** Before installing the Light edition into a Codex home that previously used [`oh-my-codex`](https://github.com/Yeachan-Heo/oh-my-codex), uninstall it first with `omx uninstall`, then re-run this installer. Both projects write Codex marketplace plugins, lifecycle hooks, and the `ultrawork`/`ulw` keyword into the same `~/.codex`, so a clean Codex home avoids stale shared `config.toml` keys and duplicate hooks.
|
||||
>
|
||||
@@ -194,7 +196,7 @@ fi
|
||||
|
||||
The installer expects `~/.codex/` to be writable. Codex CLI's first run creates this directory; if it does not exist yet, install Codex CLI and run it once before continuing.
|
||||
|
||||
On native Windows Codex installs, Git Bash is also required:
|
||||
On native Windows Codex installs, Git Bash is also prepared:
|
||||
|
||||
```powershell
|
||||
winget install --id Git.Git -e --source winget
|
||||
@@ -211,6 +213,8 @@ setx OMO_CODEX_GIT_BASH_PATH "C:\Program Files\Git\bin\bash.exe"
|
||||
$env:OMO_CODEX_GIT_BASH_PATH = "C:\Program Files\Git\bin\bash.exe"
|
||||
```
|
||||
|
||||
Set `OMO_CODEX_SKIP_GIT_BASH_AUTO_INSTALL=1` to disable the best-effort `winget install --id Git.Git -e --source winget` attempt.
|
||||
|
||||
### Step 2: Run the installer
|
||||
|
||||
Run with the platform flag and the subscription flags you collected in Step 0:
|
||||
@@ -614,7 +618,7 @@ Skip this section if `--platform=opencode`. Otherwise, the user installed the **
|
||||
|
||||
- **Plugin cache:** `~/.codex/plugins/cache/sisyphuslabs/omo/<version>/`
|
||||
- **Codex marketplace snapshot:** `~/.codex/.tmp/marketplaces/sisyphuslabs/` (stable local copy used by bundled agent TOML links)
|
||||
- **Component binaries:** `~/.local/bin/omo`, `omo-comment-checker`, `omo-lsp`, `omo-rules`, `omo-start-work-continuation`, `omo-telemetry`, `omo-ultrawork` (or the same names under `$CODEX_LOCAL_BIN_DIR` if set)
|
||||
- **Component binaries:** `~/.local/bin/omo`, `omo-comment-checker`, `omo-git-bash-hook`, `omo-lsp`, `omo-rules`, `omo-start-work-continuation`, `omo-telemetry`, `omo-ultrawork` (or the same names under `$CODEX_LOCAL_BIN_DIR` if set)
|
||||
- **Codex agent roles:** `~/.codex/agents/{codex-ultrawork-reviewer,explorer,librarian,metis,momus,plan}.toml` linked or copied from the stable marketplace snapshot, so they keep resolving when Codex prunes old plugin-cache versions
|
||||
- **Codex config edits:** `~/.codex/config.toml` gained `[features] plugins = true`, `[features] plugin_hooks = true`, `[marketplaces.sisyphuslabs]` pointing at `~/.codex/plugins/cache/sisyphuslabs`, `[plugins."omo@sisyphuslabs"]`, SHA256-pinned `[hooks.state."omo@sisyphuslabs:..."]` entries, and optionally autonomous permission settings if accepted
|
||||
|
||||
@@ -624,6 +628,7 @@ Skip this section if `--platform=opencode`. Otherwise, the user installed the **
|
||||
|-----------|----------|-------------|--------------|
|
||||
| `rules` | TypeScript | `SessionStart`, `UserPromptSubmit`, `PostToolUse`, `PostCompact` | Injects `AGENTS.md`, `CLAUDE.md`, and `.omo/rules/**` into Codex's context |
|
||||
| `comment-checker` | TypeScript | `PostToolUse` (`apply_patch`, `edit`, `write`) | Blocks AI-slop comment patterns in generated code |
|
||||
| `git-bash` | TypeScript + MCP | `PreToolUse` (`Bash`), `PostCompact`, MCP server | On Windows, exposes `git_bash`; reminds Codex before the first shell-like call and again after compaction |
|
||||
| `lsp` | TypeScript + MCP | MCP server + post-edit hooks | Exposes LSP diagnostics, navigation, symbols, rename via MCP |
|
||||
| `ultrawork` | TypeScript | `UserPromptSubmit` keyword detector | Detects `ulw`/`ultrawork` keyword; the installer links bundled Codex agent TOMLs into `$CODEX_HOME/agents` |
|
||||
| `ulw-loop` | TypeScript | Durable orchestration via `.omo/ulw-loop/` | Multi-goal orchestration with evidence audit trail |
|
||||
|
||||
@@ -1010,9 +1010,16 @@ When enabled, OmO registers the hash-anchored `edit` tool and activates the `has
|
||||
| `OMO_CODEX_DISABLE_POSTHOG` | Set to `1` or `true` to disable PostHog telemetry for the `omo-codex` adapter only. Does not affect oh-my-opencode telemetry |
|
||||
| `OMO_CODEX_SEND_ANONYMOUS_TELEMETRY` | Set to `0`, `false`, or `no` to disable anonymous telemetry for `omo-codex` only |
|
||||
| `OMO_CODEX_GIT_BASH_PATH` | Native Windows Codex installs only. Absolute path to Git Bash, for example `C:\Program Files\Git\bin\bash.exe`, when `where bash` cannot find it |
|
||||
| `OMO_CODEX_SKIP_GIT_BASH_AUTO_INSTALL` | Set to `1` to skip the best-effort `winget install --id Git.Git -e --source winget` attempt during native Windows Codex installs |
|
||||
| `POSTHOG_API_KEY` | Optional override for the built-in PostHog project API key |
|
||||
| `POSTHOG_HOST` | Override the PostHog ingestion host. Defaults to `https://us.i.posthog.com` |
|
||||
|
||||
### Codex Light Git Bash MCP
|
||||
|
||||
Native Windows Codex installs bundle a `git_bash` MCP server and write `[plugins."omo@sisyphuslabs".mcp_servers.git_bash] enabled = true`. Non-Windows installs keep the bundled manifest entry but write `enabled = false`, so the plugin detail can still show the server while policy prevents exposure.
|
||||
|
||||
The installer prepares Git Bash with normal detection, `OMO_CODEX_GIT_BASH_PATH`, and a best-effort `winget install --id Git.Git -e --source winget` retry unless `OMO_CODEX_SKIP_GIT_BASH_AUTO_INSTALL=1` is set. The Light plugin also emits a fixed reminder before the first Codex shell-like `Bash` hook call in a Windows session, and resets that reminder after `PostCompact` so the first post-compaction shell call recommends `git_bash` again.
|
||||
|
||||
### Provider-Specific
|
||||
|
||||
#### Google Auth
|
||||
|
||||
+7
-3
@@ -9,6 +9,7 @@
|
||||
"packages/rules-engine",
|
||||
"packages/ast-grep-core",
|
||||
"packages/ast-grep-mcp",
|
||||
"packages/git-bash-mcp",
|
||||
"packages/utils",
|
||||
"packages/model-core",
|
||||
"packages/prompts-core",
|
||||
@@ -35,6 +36,7 @@
|
||||
".agents/skills",
|
||||
"packages/lsp-tools-mcp/dist",
|
||||
"packages/ast-grep-mcp/dist",
|
||||
"packages/git-bash-mcp/dist",
|
||||
"packages/shared-skills/package.json",
|
||||
"packages/shared-skills/index.mjs",
|
||||
"packages/shared-skills/skills",
|
||||
@@ -52,7 +54,7 @@
|
||||
"./schema.json": "./dist/oh-my-opencode.schema.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run build:ast-grep-mcp && bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi --external zod && bun run build:node-require-shim && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema",
|
||||
"build": "bun run build:ast-grep-mcp && bun run build:git-bash-mcp && bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi --external zod && bun run build:node-require-shim && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema",
|
||||
"build:lsp-tools-mcp": "npm --prefix packages/lsp-tools-mcp ci && npm --prefix packages/lsp-tools-mcp run build",
|
||||
"build:node-require-shim": "bun run script/patch-node-require-shim.ts",
|
||||
"build:all": "bun run build && bun run build:binaries",
|
||||
@@ -65,12 +67,13 @@
|
||||
"prepublishOnly": "bun run clean && bun run build:lsp-tools-mcp && bun run build",
|
||||
"test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail",
|
||||
"typecheck": "tsgo --noEmit && bun run typecheck:packages",
|
||||
"typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/prompts-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/hashline-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json && tsgo --noEmit -p packages/omo-codex/tsconfig.json",
|
||||
"typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/git-bash-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/prompts-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/hashline-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json && tsgo --noEmit -p packages/omo-codex/tsconfig.json",
|
||||
"typecheck:script": "tsgo --noEmit -p script/tsconfig.json",
|
||||
"test": "bun test",
|
||||
"test:codex": "bun run build:ast-grep-mcp && bun run build:lsp-tools-mcp && npm --prefix packages/omo-codex/plugin ci && bun run --cwd packages/omo-codex/plugin build && bun test src/cli/cli-installer.platform.test.ts src/cli/install-codex/codex-cache.test.ts src/cli/install-codex/codex-config-agent-cleanup.test.ts src/cli/install-codex/codex-config-toml.test.ts src/cli/install-codex/install-codex.test.ts src/cli/install-codex/link-cached-plugin-agents.test.ts packages/omo-codex/src/**/*.test.ts packages/utils/src/jsonc-parser.test.ts packages/utils/src/frontmatter.test.ts packages/hashline-core/src/hash-computation.test.ts packages/hashline-core/src/smoke-untested-modules.test.ts packages/rules-engine/src/index.test.ts packages/rules-engine/src/security-boundary.test.ts packages/agents-md-core/src/injector.test.ts packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts && node --test packages/omo-codex/plugin/test/*.test.mjs packages/omo-codex/scripts/install-cache-copy.test.mjs packages/omo-codex/scripts/install-config.test.mjs packages/omo-codex/scripts/install-local.test.mjs packages/omo-codex/scripts/install-mcp-runtime.test.mjs packages/omo-codex/scripts/install-agent-links.test.mjs packages/omo-codex/scripts/install-bin-links.test.mjs packages/omo-codex/scripts/sync-telemetry-component.test.mjs",
|
||||
"test:windows-codex": "bun run test:codex",
|
||||
"build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build"
|
||||
"build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build",
|
||||
"build:git-bash-mcp": "bun run --cwd packages/git-bash-mcp build"
|
||||
},
|
||||
"keywords": [
|
||||
"opencode",
|
||||
@@ -113,6 +116,7 @@
|
||||
"devDependencies": {
|
||||
"@oh-my-opencode/ast-grep-core": "workspace:*",
|
||||
"@oh-my-opencode/ast-grep-mcp": "workspace:*",
|
||||
"@oh-my-opencode/git-bash-mcp": "workspace:*",
|
||||
"@oh-my-opencode/agents-md-core": "workspace:*",
|
||||
"@oh-my-opencode/boulder-state": "workspace:*",
|
||||
"@oh-my-opencode/comment-checker-core": "workspace:*",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -17,6 +17,7 @@ Codex harness adapter for **oh-my-openagent**. Brings the OMO experience (rules
|
||||
- `rules` (TypeScript) - injects `AGENTS.md` / `CLAUDE.md` / `.omo/rules/**` into context via `SessionStart`, `UserPromptSubmit`, `PostToolUse`, `PostCompact`.
|
||||
- `comment-checker` (TypeScript) - runs `@code-yeongyu/comment-checker` after `apply_patch` / `edit` / `write` tool use.
|
||||
- `lsp` (TypeScript + LSP MCP) - exposes LSP diagnostics, navigation, symbols, rename via MCP + post-edit hooks.
|
||||
- `git-bash` (TypeScript + Git Bash MCP) - exposes the Windows-only `git_bash` MCP and reminds Codex on the first shell-like call, including the first one after compaction.
|
||||
- `ultrawork` (TypeScript) - keyword detector (`ulw` / `ultrawork`) that injects the full ultrawork directive; bundled agent TOML files are installed into `CODEX_HOME/agents`.
|
||||
- `ulw-loop` (TypeScript) - durable multi-goal orchestration backed by `.omo/ulw-loop/` evidence audit.
|
||||
- `start-work-continuation` (TypeScript) - `Stop` / `SubagentStop` continuation hook for `.omo/boulder.json` start-work plans.
|
||||
@@ -39,7 +40,7 @@ To install **both** the Ultimate edition (OpenCode plugin) and the Light edition
|
||||
|
||||
The installer copies the built plugin into `~/.codex/plugins/cache/sisyphuslabs/omo/<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
|
||||
winget install --id Git.Git -e --source winget
|
||||
@@ -56,7 +57,9 @@ setx OMO_CODEX_GIT_BASH_PATH "C:\Program Files\Git\bin\bash.exe"
|
||||
$env:OMO_CODEX_GIT_BASH_PATH = "C:\Program Files\Git\bin\bash.exe"
|
||||
```
|
||||
|
||||
The installer does not write a global Codex shell config. The rules component injects conditional Windows guidance telling Codex to use Git Bash for shell commands.
|
||||
Set `OMO_CODEX_SKIP_GIT_BASH_AUTO_INSTALL=1` to skip the best-effort `winget install --id Git.Git -e --source winget` attempt and keep the explicit install guidance path.
|
||||
|
||||
The installer does not write a global Codex shell config. On Windows it enables the plugin MCP policy for `git_bash`; on non-Windows it keeps the manifest bundled but writes `enabled = false` for that MCP server. The Git Bash hook injects fixed guidance before the first Codex shell-like `Bash` hook call in a session, and again before the first shell-like call after `PostCompact`, recommending `git_bash` before built-in `exec_command`.
|
||||
|
||||
To install both editions in one command, use `--platform=both`.
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
"args": ["../../ast-grep-mcp/dist/cli.js", "mcp"],
|
||||
"cwd": "."
|
||||
},
|
||||
"git_bash": {
|
||||
"command": "node",
|
||||
"args": ["../../git-bash-mcp/dist/cli.js", "mcp"],
|
||||
"cwd": "."
|
||||
},
|
||||
"lsp": {
|
||||
"command": "node",
|
||||
"args": ["../../lsp-tools-mcp/dist/cli.js", "mcp"],
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
"matcher": "^Bash$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${PLUGIN_ROOT}/components/git-bash/dist/cli.js\" hook pre-tool-use",
|
||||
"timeout": 5,
|
||||
"statusMessage": "LazyCodex(0.1.0): Recommending Git Bash Mcp"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "^create_goal$",
|
||||
"hooks": [
|
||||
@@ -98,6 +109,17 @@
|
||||
}
|
||||
],
|
||||
"PostCompact": [
|
||||
{
|
||||
"matcher": "manual|auto",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${PLUGIN_ROOT}/components/git-bash/dist/cli.js\" hook post-compact",
|
||||
"timeout": 5,
|
||||
"statusMessage": "LazyCodex(0.1.0): Resetting Git Bash Mcp Reminder"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "manual|auto",
|
||||
"hooks": [
|
||||
|
||||
+19
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"workspaces": [
|
||||
"components/comment-checker",
|
||||
"components/git-bash",
|
||||
"components/rules",
|
||||
"components/lsp",
|
||||
"components/telemetry",
|
||||
@@ -61,6 +62,20 @@
|
||||
"@code-yeongyu/comment-checker": "^0.8.0"
|
||||
}
|
||||
},
|
||||
"components/git-bash": {
|
||||
"name": "@sisyphuslabs/codex-git-bash-hook",
|
||||
"version": "0.1.0",
|
||||
"bin": {
|
||||
"omo-git-bash-hook": "dist/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.7.0",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"components/lsp": {
|
||||
"name": "@code-yeongyu/codex-lsp",
|
||||
"version": "0.2.0",
|
||||
@@ -765,6 +780,10 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sisyphuslabs/codex-git-bash-hook": {
|
||||
"resolved": "components/git-bash",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"components/comment-checker",
|
||||
"components/git-bash",
|
||||
"components/rules",
|
||||
"components/lsp",
|
||||
"components/telemetry",
|
||||
|
||||
@@ -18,6 +18,11 @@ const runtimes = [
|
||||
packageRoot: join(repoPackagesRoot, "ast-grep-mcp"),
|
||||
requiredOutputs: ["dist/cli.js"],
|
||||
},
|
||||
{
|
||||
label: "git-bash-mcp",
|
||||
packageRoot: join(repoPackagesRoot, "git-bash-mcp"),
|
||||
requiredOutputs: ["dist/cli.js"],
|
||||
},
|
||||
];
|
||||
|
||||
for (const runtime of runtimes) {
|
||||
@@ -25,13 +30,18 @@ for (const runtime of runtimes) {
|
||||
}
|
||||
|
||||
function buildRuntime(runtime) {
|
||||
if (hasBundledDist(runtime)) {
|
||||
console.log(`Using bundled ${runtime.label} dist`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existsSync(join(runtime.packageRoot, "package.json"))) {
|
||||
assertBundledDist(runtime);
|
||||
console.log(`Using bundled ${runtime.label} dist`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = spawnSync("bun", ["run", "build"], {
|
||||
const result = spawnSync("npm", ["run", "build"], {
|
||||
cwd: runtime.packageRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
@@ -39,6 +49,10 @@ function buildRuntime(runtime) {
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
function hasBundledDist(runtime) {
|
||||
return runtime.requiredOutputs.every((output) => existsSync(join(runtime.packageRoot, output)));
|
||||
}
|
||||
|
||||
function assertBundledDist(runtime) {
|
||||
const missingOutputs = runtime.requiredOutputs.filter((output) => !existsSync(join(runtime.packageRoot, output)));
|
||||
if (missingOutputs.length === 0) return;
|
||||
|
||||
@@ -14,7 +14,7 @@ for (const workspace of workspaces) {
|
||||
if (typeof workspacePackageJson.scripts?.build !== "string") continue;
|
||||
|
||||
console.log(`Building ${workspace}`);
|
||||
const result = spawnSync("bun", ["run", "--cwd", workspace, "build"], {
|
||||
const result = spawnSync("npm", ["run", "--workspace", workspace, "build"], {
|
||||
cwd: root,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
@@ -154,7 +154,7 @@ test("#given hook status messages #when inspected #then labels describe OMO resp
|
||||
assert.deepEqual(genericStatusMessages, []);
|
||||
});
|
||||
|
||||
test("#given aggregate OMO plugin is enabled #when hooks are inspected #then ulw-loop guards budgeted create_goal calls", async () => {
|
||||
test("#given aggregate OMO plugin is enabled #when hooks are inspected #then shell guidance and ulw-loop guard are registered", async () => {
|
||||
// given
|
||||
const hooks = await readJson("hooks/hooks.json");
|
||||
const text = JSON.stringify(hooks);
|
||||
@@ -163,9 +163,13 @@ test("#given aggregate OMO plugin is enabled #when hooks are inspected #then ulw
|
||||
const preToolUseGroups = hooks.hooks.PreToolUse;
|
||||
|
||||
// then
|
||||
assert.match(text, /components\/git-bash\/dist\/cli\.js/);
|
||||
assert.match(text, /Recommending Git Bash Mcp/);
|
||||
assert.match(text, /hook post-compact/);
|
||||
assert.match(text, /Resetting Git Bash Mcp Reminder/);
|
||||
assert.match(text, /components\/ulw-loop\/dist\/cli\.js/);
|
||||
assert.match(text, /hook pre-tool-use/);
|
||||
assert.deepEqual(preToolUseGroups.map((group) => group.matcher), ["^create_goal$"]);
|
||||
assert.deepEqual(preToolUseGroups.map((group) => group.matcher), ["^Bash$", "^create_goal$"]);
|
||||
});
|
||||
|
||||
test("#given aggregate MCP config #when inspected #then code MCPs reference package runtimes without package names", async () => {
|
||||
@@ -178,17 +182,19 @@ test("#given aggregate MCP config #when inspected #then code MCPs reference pack
|
||||
// when
|
||||
const lspServer = mcp.mcpServers.lsp;
|
||||
const astGrepServer = mcp.mcpServers.ast_grep;
|
||||
const gitBashServer = mcp.mcpServers.git_bash;
|
||||
const codeMcpNames = Object.keys(mcp.mcpServers)
|
||||
.filter((name) => name === "lsp" || name === "ast_grep")
|
||||
.filter((name) => name === "lsp" || name === "ast_grep" || name === "git_bash")
|
||||
.sort();
|
||||
const componentLocalMcpSources = lspSources.filter((name) => name.startsWith("lazy-mcp") || name === "lazy-lsp-mcp.ts");
|
||||
|
||||
// then
|
||||
assert.deepEqual(codeMcpNames, ["ast_grep", "lsp"]);
|
||||
assert.deepEqual(codeMcpNames, ["ast_grep", "git_bash", "lsp"]);
|
||||
assert.equal(packageJson.workspaces.includes("components/lsp/packages/lsp-tools-mcp"), false);
|
||||
assert.equal(packageJson.workspaces.includes("components/ast-grep/packages/ast-grep-mcp"), false);
|
||||
assert.deepEqual(packageJson.dependencies, { "@oh-my-opencode/shared-skills": "file:../../shared-skills" });
|
||||
assert.match(bundledMcpBuildScript, /ast-grep-mcp/);
|
||||
assert.match(bundledMcpBuildScript, /git-bash-mcp/);
|
||||
assert.doesNotMatch(packageJson.scripts.build, /--workspaces/);
|
||||
assert.equal(lspServer.command, "node");
|
||||
assert.deepEqual(lspServer.args, ["../../lsp-tools-mcp/dist/cli.js", "mcp"]);
|
||||
@@ -196,6 +202,9 @@ test("#given aggregate MCP config #when inspected #then code MCPs reference pack
|
||||
assert.equal(astGrepServer.command, "node");
|
||||
assert.deepEqual(astGrepServer.args, ["../../ast-grep-mcp/dist/cli.js", "mcp"]);
|
||||
assert.equal(astGrepServer.cwd, ".");
|
||||
assert.equal(gitBashServer.command, "node");
|
||||
assert.deepEqual(gitBashServer.args, ["../../git-bash-mcp/dist/cli.js", "mcp"]);
|
||||
assert.equal(gitBashServer.cwd, ".");
|
||||
assert.deepEqual(componentLocalMcpSources, []);
|
||||
});
|
||||
|
||||
@@ -203,12 +212,17 @@ test("#given package-level MCP CLIs #when package metadata is inspected #then bi
|
||||
// given
|
||||
const lspPackageJson = await readJson("../../lsp-tools-mcp/package.json");
|
||||
const astGrepPackageJson = await readJson("../../ast-grep-mcp/package.json");
|
||||
const gitBashPackageJson = await readJson("../../git-bash-mcp/package.json");
|
||||
|
||||
// when
|
||||
const binNames = [...Object.keys(lspPackageJson.bin ?? {}), ...Object.keys(astGrepPackageJson.bin ?? {})].sort();
|
||||
const binNames = [
|
||||
...Object.keys(lspPackageJson.bin ?? {}),
|
||||
...Object.keys(astGrepPackageJson.bin ?? {}),
|
||||
...Object.keys(gitBashPackageJson.bin ?? {}),
|
||||
].sort();
|
||||
|
||||
// then
|
||||
assert.deepEqual(binNames, ["omo-ast-grep", "omo-lsp"]);
|
||||
assert.deepEqual(binNames, ["omo-ast-grep", "omo-git-bash", "omo-lsp"]);
|
||||
for (const name of binNames) {
|
||||
assert.match(name, /^omo-/);
|
||||
}
|
||||
@@ -252,6 +266,7 @@ test("#given component directories #when scanned #then only intentional resource
|
||||
// then
|
||||
assert.deepEqual(componentNames, [
|
||||
"comment-checker",
|
||||
"git-bash",
|
||||
"lsp",
|
||||
"rules",
|
||||
"start-work-continuation",
|
||||
|
||||
@@ -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, /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,
|
||||
marketplaceSource: { sourceType: "local", source: marketplaceRoot },
|
||||
pluginNames,
|
||||
platform,
|
||||
trustedHookStates,
|
||||
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 sourceRoot = join(repoRoot, "packages", "omo-codex", "plugin");
|
||||
const astGrepPackageRoot = join(repoRoot, "packages", "ast-grep-mcp");
|
||||
const gitBashPackageRoot = join(repoRoot, "packages", "git-bash-mcp");
|
||||
const lspPackageRoot = join(repoRoot, "packages", "lsp-tools-mcp");
|
||||
|
||||
await writeJson(join(astGrepPackageRoot, "package.json"), {
|
||||
@@ -26,6 +27,12 @@ test("#given external MCP package runtime #when installing cached plugin #then r
|
||||
type: "module",
|
||||
bin: { "omo-lsp": "./dist/cli.js" },
|
||||
});
|
||||
await writeJson(join(gitBashPackageRoot, "package.json"), {
|
||||
name: "@example/git-bash-mcp",
|
||||
version: "0.1.0",
|
||||
type: "module",
|
||||
bin: { "omo-git-bash": "./dist/cli.js" },
|
||||
});
|
||||
await writeJson(join(sourceRoot, "package.json"), {
|
||||
name: "@example/omo",
|
||||
version: "0.1.0",
|
||||
@@ -37,6 +44,11 @@ test("#given external MCP package runtime #when installing cached plugin #then r
|
||||
args: ["../../ast-grep-mcp/dist/cli.js", "mcp"],
|
||||
cwd: ".",
|
||||
},
|
||||
git_bash: {
|
||||
command: "node",
|
||||
args: ["../../git-bash-mcp/dist/cli.js", "mcp"],
|
||||
cwd: ".",
|
||||
},
|
||||
lsp: {
|
||||
command: "node",
|
||||
args: ["../../lsp-tools-mcp/dist/cli.js", "mcp"],
|
||||
@@ -45,6 +57,7 @@ test("#given external MCP package runtime #when installing cached plugin #then r
|
||||
},
|
||||
});
|
||||
await writeJson(join(astGrepPackageRoot, "dist", "cli.js"), { executable: true });
|
||||
await writeJson(join(gitBashPackageRoot, "dist", "cli.js"), { executable: true });
|
||||
await writeJson(join(lspPackageRoot, "dist", "cli.js"), { executable: true });
|
||||
await writeJson(join(lspPackageRoot, "dist", "lsp", "manager.js"), { copied: true });
|
||||
|
||||
@@ -59,13 +72,17 @@ test("#given external MCP package runtime #when installing cached plugin #then r
|
||||
|
||||
const cachedMcp = JSON.parse(await readFile(join(result.path, ".mcp.json"), "utf8"));
|
||||
const copiedAstGrepCli = join(result.path, "mcp", "ast_grep", "dist", "cli.js");
|
||||
const copiedGitBashCli = join(result.path, "mcp", "git_bash", "dist", "cli.js");
|
||||
const copiedCli = join(result.path, "mcp", "lsp", "dist", "cli.js");
|
||||
|
||||
assert.deepEqual(cachedMcp.mcpServers.ast_grep.args, [copiedAstGrepCli, "mcp"]);
|
||||
assert.deepEqual(cachedMcp.mcpServers.git_bash.args, [copiedGitBashCli, "mcp"]);
|
||||
assert.deepEqual(cachedMcp.mcpServers.lsp.args, [copiedCli, "mcp"]);
|
||||
assert.equal(Object.hasOwn(cachedMcp.mcpServers.ast_grep, "cwd"), false);
|
||||
assert.equal(Object.hasOwn(cachedMcp.mcpServers.git_bash, "cwd"), false);
|
||||
assert.equal(Object.hasOwn(cachedMcp.mcpServers.lsp, "cwd"), false);
|
||||
assert.equal((await stat(copiedAstGrepCli)).isFile(), true);
|
||||
assert.equal((await stat(copiedGitBashCli)).isFile(), true);
|
||||
assert.equal((await stat(copiedCli)).isFile(), true);
|
||||
assert.equal((await stat(join(result.path, "mcp", "lsp", "dist", "lsp", "manager.js"))).isFile(), true);
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ export async function updateCodexConfig({
|
||||
marketplaceName,
|
||||
marketplaceSource = defaultMarketplaceSource(marketplaceName, repoRoot),
|
||||
pluginNames,
|
||||
platform = process.platform,
|
||||
trustedHookStates = [],
|
||||
agentConfigs = [],
|
||||
}) {
|
||||
@@ -53,6 +54,7 @@ export async function updateCodexConfig({
|
||||
for (const pluginName of pluginNames) {
|
||||
config = ensurePluginEnabled(config, `${pluginName}@${marketplaceName}`);
|
||||
}
|
||||
config = ensureOmoGitBashMcpPolicy(config, { marketplaceName, pluginNames, platform });
|
||||
for (const state of trustedHookStates) {
|
||||
config = ensureHookTrusted(config, state.key, state.trustedHash);
|
||||
}
|
||||
@@ -150,6 +152,19 @@ function ensurePluginEnabled(config, pluginKey) {
|
||||
return replaceOrInsertSetting(config, section, "enabled", "true");
|
||||
}
|
||||
|
||||
function ensurePluginMcpEnabled(config, pluginKey, serverName, enabled) {
|
||||
const header = `plugins.${JSON.stringify(pluginKey)}.mcp_servers.${serverName}`;
|
||||
const section = findTomlSection(config, header);
|
||||
const enabledValue = enabled ? "true" : "false";
|
||||
if (!section) return appendBlock(config, `[${header}]\nenabled = ${enabledValue}\n`);
|
||||
return replaceOrInsertSetting(config, section, "enabled", enabledValue);
|
||||
}
|
||||
|
||||
function ensureOmoGitBashMcpPolicy(config, { marketplaceName, pluginNames, platform }) {
|
||||
if (marketplaceName !== "sisyphuslabs" || !pluginNames.includes("omo")) return config;
|
||||
return ensurePluginMcpEnabled(config, "omo@sisyphuslabs", "git_bash", platform === "win32");
|
||||
}
|
||||
|
||||
function ensureHookTrusted(config, key, trustedHash) {
|
||||
const header = `hooks.state.${JSON.stringify(key)}`;
|
||||
const section = findTomlSection(config, header);
|
||||
@@ -237,7 +252,8 @@ function parseJsonString(value) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return typeof parsed === "string" ? parsed : null;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,3 +128,46 @@ test("#given non-Windows platform #when preparing #then winget is never called",
|
||||
assert.deepEqual(runCalls, []);
|
||||
assert.deepEqual(result, { found: true, path: null, source: "not-required" });
|
||||
});
|
||||
|
||||
test("#given Windows without Git Bash and winget fails #when preparing #then original install hint is preserved", async () => {
|
||||
const missingResolution = {
|
||||
found: false,
|
||||
checkedPaths: [programFilesGitBash, programFilesX86GitBash],
|
||||
installHint: "install hint",
|
||||
};
|
||||
|
||||
const result = await prepareGitBashForInstall({
|
||||
platform: "win32",
|
||||
env: {},
|
||||
cwd: "C:\\repo",
|
||||
resolveGitBash: () => missingResolution,
|
||||
runCommand: async () => {
|
||||
throw new Error("winget unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, missingResolution);
|
||||
});
|
||||
|
||||
test("#given Windows without Git Bash and winget succeeds but bash is still missing #when preparing #then install hint remains", async () => {
|
||||
const missingResolution = {
|
||||
found: false,
|
||||
checkedPaths: [programFilesGitBash, programFilesX86GitBash],
|
||||
installHint: "install hint",
|
||||
};
|
||||
let resolveCallCount = 0;
|
||||
|
||||
const result = await prepareGitBashForInstall({
|
||||
platform: "win32",
|
||||
env: {},
|
||||
cwd: "C:\\repo",
|
||||
resolveGitBash: () => {
|
||||
resolveCallCount += 1;
|
||||
return missingResolution;
|
||||
},
|
||||
runCommand: async () => {},
|
||||
});
|
||||
|
||||
assert.equal(resolveCallCount, 2);
|
||||
assert.deepEqual(result, missingResolution);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,13 @@ const BUNDLED_MCP_RUNTIMES = [
|
||||
destinationArg: "./components/ast-grep-mcp/dist/cli.js",
|
||||
destinationDistFromPlugin: "components/ast-grep-mcp/dist",
|
||||
},
|
||||
{
|
||||
label: "Git Bash MCP",
|
||||
sourceArg: "../../git-bash-mcp/dist/cli.js",
|
||||
sourceDistFromPlugin: "../../git-bash-mcp/dist",
|
||||
destinationArg: "./components/git-bash-mcp/dist/cli.js",
|
||||
destinationDistFromPlugin: "components/git-bash-mcp/dist",
|
||||
},
|
||||
{
|
||||
label: "LSP MCP",
|
||||
sourceArg: "../../lsp-tools-mcp/dist/cli.js",
|
||||
|
||||
@@ -39,6 +39,7 @@ describe("codex-cache", () => {
|
||||
mcpServers: {
|
||||
ast_grep: { cwd: ".", args: ["../../ast-grep-mcp/dist/cli.js", "mcp"] },
|
||||
custom: { args: ["/usr/local/bin/custom-mcp", "--stdio"] },
|
||||
git_bash: { cwd: ".", args: ["../../git-bash-mcp/dist/cli.js", "mcp"] },
|
||||
lsp: { cwd: ".", args: ["../../lsp-tools-mcp/dist/cli.js", "mcp"] },
|
||||
},
|
||||
}),
|
||||
@@ -52,13 +53,16 @@ describe("codex-cache", () => {
|
||||
mcpServers: {
|
||||
ast_grep: { cwd?: string; args: string[] }
|
||||
custom: { args: string[] }
|
||||
git_bash: { cwd?: string; args: string[] }
|
||||
lsp: { cwd?: string; args: string[] }
|
||||
}
|
||||
}
|
||||
expect(Object.keys(rewritten.mcpServers).sort()).toEqual(["ast_grep", "custom", "lsp"])
|
||||
expect(Object.keys(rewritten.mcpServers).sort()).toEqual(["ast_grep", "custom", "git_bash", "lsp"])
|
||||
expect(rewritten.mcpServers.ast_grep.cwd).toBeUndefined()
|
||||
expect(rewritten.mcpServers.ast_grep.args[0]).toBe(join(cacheRoot, "components", "ast-grep-mcp", "dist", "cli.js"))
|
||||
expect(rewritten.mcpServers.custom.args).toEqual(["/usr/local/bin/custom-mcp", "--stdio"])
|
||||
expect(rewritten.mcpServers.git_bash.cwd).toBeUndefined()
|
||||
expect(rewritten.mcpServers.git_bash.args[0]).toBe(join(cacheRoot, "components", "git-bash-mcp", "dist", "cli.js"))
|
||||
expect(rewritten.mcpServers.lsp.cwd).toBeUndefined()
|
||||
expect(rewritten.mcpServers.lsp.args[0]).toBe(join(cacheRoot, "components", "lsp-tools-mcp", "dist", "cli.js"))
|
||||
})
|
||||
|
||||
@@ -396,4 +396,67 @@ describe("codex-config-toml", () => {
|
||||
expect(content).not.toContain("stale-explorer")
|
||||
expect(content).not.toContain("ref = undefined")
|
||||
})
|
||||
|
||||
test("#given windows platform #when updating sisyphuslabs plugin config #then enables git_bash plugin mcp policy", async () => {
|
||||
// given
|
||||
const root = await mkdtemp(join(tmpdir(), "omo-codex-config-git-bash-win32-"))
|
||||
const configPath = join(root, "config.toml")
|
||||
await writeFile(
|
||||
configPath,
|
||||
[
|
||||
'[plugins."omo@sisyphuslabs"]',
|
||||
"enabled = true",
|
||||
"",
|
||||
'[plugins."omo@sisyphuslabs".mcp_servers.lsp]',
|
||||
"enabled = true",
|
||||
"",
|
||||
'[hooks.state."omo@sisyphuslabs:hooks/hooks.json:post_tool_use:0:0"]',
|
||||
'trusted_hash = "sha256:keep"',
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
// when
|
||||
await updateCodexConfig({
|
||||
configPath,
|
||||
repoRoot: "/repo/packages/omo-codex",
|
||||
marketplaceName: "sisyphuslabs",
|
||||
marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex/cache/sisyphuslabs" },
|
||||
pluginNames: ["omo"],
|
||||
platform: "win32",
|
||||
trustedHookStates: [{ key: "omo@sisyphuslabs:hooks/hooks.json:post_tool_use:0:0", trustedHash: "sha256:keep" }],
|
||||
})
|
||||
|
||||
// then
|
||||
const content = await readFile(configPath, "utf8")
|
||||
expect(content).toContain('[plugins."omo@sisyphuslabs".mcp_servers.lsp]')
|
||||
expect(content).toContain('[plugins."omo@sisyphuslabs".mcp_servers.git_bash]')
|
||||
expect(content).toContain("[hooks.state.\"omo@sisyphuslabs:hooks/hooks.json:post_tool_use:0:0\"]")
|
||||
expect(content).toMatch(/\[plugins\."omo@sisyphuslabs"\.mcp_servers\.git_bash\][\s\S]*?enabled = true/)
|
||||
})
|
||||
|
||||
test("#given non-windows platforms #when updating sisyphuslabs plugin config #then disables git_bash plugin mcp policy", async () => {
|
||||
for (const platform of ["linux", "darwin"] as const) {
|
||||
// given
|
||||
const root = await mkdtemp(join(tmpdir(), `omo-codex-config-git-bash-${platform}-`))
|
||||
const configPath = join(root, "config.toml")
|
||||
|
||||
// when
|
||||
await updateCodexConfig({
|
||||
configPath,
|
||||
repoRoot: "/repo/packages/omo-codex",
|
||||
marketplaceName: "sisyphuslabs",
|
||||
marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex/cache/sisyphuslabs" },
|
||||
pluginNames: ["omo"],
|
||||
platform,
|
||||
})
|
||||
|
||||
// then
|
||||
const content = await readFile(configPath, "utf8")
|
||||
expect(content).toContain('[plugins."omo@sisyphuslabs".mcp_servers.git_bash]')
|
||||
expect(content).toMatch(/\[plugins\."omo@sisyphuslabs"\.mcp_servers\.git_bash\][\s\S]*?enabled = false/)
|
||||
expect(content).toContain('[plugins."omo@sisyphuslabs"]')
|
||||
expect(content).toContain("enabled = true")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ensureContext7McpServer } from "./codex-config-mcp"
|
||||
import { ensureAutonomousPermissions } from "./codex-config-permissions"
|
||||
import { ensureCodexMultiAgentV2Config } from "./codex-multi-agent-v2-config"
|
||||
import { appendBlock, findTomlSection, replaceOrInsertSetting } from "./toml-section-editor"
|
||||
import type { CodexAgentConfig, CodexMarketplaceSource, TrustedHookState } from "./types"
|
||||
import type { CodexAgentConfig, CodexInstallPlatform, CodexMarketplaceSource, TrustedHookState } from "./types"
|
||||
|
||||
const SISYPHUS_LEGACY_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"] as const
|
||||
const MANAGED_CODEX_AGENT_NAMES = [
|
||||
@@ -22,6 +22,7 @@ export async function updateCodexConfig(input: {
|
||||
readonly marketplaceName: string
|
||||
readonly marketplaceSource: CodexMarketplaceSource
|
||||
readonly pluginNames: readonly string[]
|
||||
readonly platform?: CodexInstallPlatform
|
||||
readonly trustedHookStates?: readonly TrustedHookState[]
|
||||
readonly agentConfigs?: readonly CodexAgentConfig[]
|
||||
readonly autonomousPermissions?: boolean
|
||||
@@ -51,6 +52,7 @@ export async function updateCodexConfig(input: {
|
||||
for (const pluginName of input.pluginNames) {
|
||||
config = ensurePluginEnabled(config, `${pluginName}@${input.marketplaceName}`)
|
||||
}
|
||||
config = ensureOmoGitBashMcpPolicy(config, input)
|
||||
for (const state of input.trustedHookStates ?? []) {
|
||||
config = ensureHookTrusted(config, state.key, state.trustedHash)
|
||||
}
|
||||
@@ -142,6 +144,24 @@ function ensurePluginEnabled(config: string, pluginKey: string): string {
|
||||
return replaceOrInsertSetting(config, section, "enabled", "true")
|
||||
}
|
||||
|
||||
function ensurePluginMcpEnabled(config: string, pluginKey: string, serverName: string, enabled: boolean): string {
|
||||
const header = `plugins.${JSON.stringify(pluginKey)}.mcp_servers.${serverName}`
|
||||
const section = findTomlSection(config, header)
|
||||
const enabledValue = enabled ? "true" : "false"
|
||||
if (!section) return appendBlock(config, `[${header}]\nenabled = ${enabledValue}\n`)
|
||||
return replaceOrInsertSetting(config, section, "enabled", enabledValue)
|
||||
}
|
||||
|
||||
function ensureOmoGitBashMcpPolicy(config: string, input: {
|
||||
readonly marketplaceName: string
|
||||
readonly pluginNames: readonly string[]
|
||||
readonly platform?: CodexInstallPlatform
|
||||
}): string {
|
||||
if (input.marketplaceName !== "sisyphuslabs" || !input.pluginNames.includes("omo")) return config
|
||||
const enabled = (input.platform ?? process.platform) === "win32"
|
||||
return ensurePluginMcpEnabled(config, "omo@sisyphuslabs", "git_bash", enabled)
|
||||
}
|
||||
|
||||
function ensureHookTrusted(config: string, key: string, trustedHash: string): string {
|
||||
const header = `hooks.state.${JSON.stringify(key)}`
|
||||
const section = findTomlSection(config, header)
|
||||
|
||||
@@ -185,4 +185,53 @@ describe("git-bash", () => {
|
||||
expect(runCalls).toEqual([])
|
||||
expect(result).toEqual({ found: true, path: null, source: "not-required" })
|
||||
})
|
||||
|
||||
test("#given Windows without Git Bash and winget fails #when preparing #then original install hint is preserved", async () => {
|
||||
// given
|
||||
const missingResolution = {
|
||||
found: false,
|
||||
checkedPaths: [PROGRAM_FILES_GIT_BASH, PROGRAM_FILES_X86_GIT_BASH],
|
||||
installHint: "install hint",
|
||||
} as const
|
||||
|
||||
// when
|
||||
const result = await prepareGitBashForInstall({
|
||||
platform: "win32",
|
||||
env: {},
|
||||
cwd: "C:\\repo",
|
||||
resolveGitBash: () => missingResolution,
|
||||
runCommand: async () => {
|
||||
throw new Error("winget unavailable")
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).toEqual(missingResolution)
|
||||
})
|
||||
|
||||
test("#given Windows without Git Bash and winget exits successfully but bash is still missing #when preparing #then installer still fails with install hint", async () => {
|
||||
// given
|
||||
const missingResolution = {
|
||||
found: false,
|
||||
checkedPaths: [PROGRAM_FILES_GIT_BASH, PROGRAM_FILES_X86_GIT_BASH],
|
||||
installHint: "install hint",
|
||||
} as const
|
||||
let resolveCallCount = 0
|
||||
|
||||
// when
|
||||
const result = await prepareGitBashForInstall({
|
||||
platform: "win32",
|
||||
env: {},
|
||||
cwd: "C:\\repo",
|
||||
resolveGitBash: () => {
|
||||
resolveCallCount += 1
|
||||
return missingResolution
|
||||
},
|
||||
runCommand: async () => undefined,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(resolveCallCount).toBe(2)
|
||||
expect(result).toEqual(missingResolution)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { findRepoRoot, findRepoRootFromImporter, resolveCodexInstallerBinDir, ru
|
||||
const EXPECTED_OMO_COMPONENT_BINS = [
|
||||
{ name: "omo", target: join("components", "ulw-loop", "dist", "cli.js") },
|
||||
{ name: "omo-comment-checker", target: join("components", "comment-checker", "dist", "cli.js") },
|
||||
{ name: "omo-git-bash-hook", target: join("components", "git-bash", "dist", "cli.js") },
|
||||
{ name: "omo-lsp", target: join("components", "lsp", "dist", "cli.js") },
|
||||
{ name: "omo-rules", target: join("components", "rules", "dist", "cli.js") },
|
||||
{ name: "omo-start-work-continuation", target: join("components", "start-work-continuation", "dist", "cli.js") },
|
||||
@@ -153,10 +154,12 @@ describe("install-codex", () => {
|
||||
expect(skillNames).toContain("ulw-loop")
|
||||
expect(skillNames).not.toContain("planing-prometheustic")
|
||||
const mcpManifest = JSON.parse(await readFile(join(pluginPath ?? "", ".mcp.json"), "utf8")) as {
|
||||
mcpServers: { ast_grep: { args: string[] }; lsp: { args: string[] } }
|
||||
mcpServers: { ast_grep: { args: string[] }; git_bash: { args: string[] }; lsp: { args: string[] } }
|
||||
}
|
||||
expect(mcpManifest.mcpServers.ast_grep.args[0]).toBe(join(pluginPath ?? "", "components", "ast-grep-mcp", "dist", "cli.js"))
|
||||
expect((await stat(mcpManifest.mcpServers.ast_grep.args[0] ?? "")).isFile()).toBe(true)
|
||||
expect(mcpManifest.mcpServers.git_bash.args[0]).toBe(join(pluginPath ?? "", "components", "git-bash-mcp", "dist", "cli.js"))
|
||||
expect((await stat(mcpManifest.mcpServers.git_bash.args[0] ?? "")).isFile()).toBe(true)
|
||||
expect(mcpManifest.mcpServers.lsp.args[0]).toBe(join(pluginPath ?? "", "components", "lsp-tools-mcp", "dist", "cli.js"))
|
||||
expect(mcpManifest.mcpServers.lsp.args[0]).not.toContain("components/lsp/packages")
|
||||
expect(mcpManifest.mcpServers.lsp.args[0]?.startsWith(pluginPath ?? "")).toBe(true)
|
||||
@@ -175,7 +178,64 @@ describe("install-codex", () => {
|
||||
legacyCacheMissing = error instanceof Error
|
||||
}
|
||||
expect(legacyCacheMissing).toBe(true)
|
||||
}, { timeout: 15_000 })
|
||||
})
|
||||
|
||||
test("#given simulated Windows Codex install #when installing omo #then enables git_bash MCP and trusts shell hooks", async () => {
|
||||
// given
|
||||
const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-home-git-bash-win-"))
|
||||
const binDir = await mkdtemp(join(tmpdir(), "omo-codex-bin-git-bash-win-"))
|
||||
const repoRoot = process.cwd()
|
||||
|
||||
// when
|
||||
const result = await runCodexInstaller({
|
||||
codexHome,
|
||||
binDir,
|
||||
repoRoot,
|
||||
platform: "win32",
|
||||
gitBashResolver: () => ({ found: true, path: "C:\\Program Files\\Git\\bin\\bash.exe", source: "program-files" }),
|
||||
runCommand: async () => undefined,
|
||||
})
|
||||
|
||||
// then
|
||||
const configContent = await readFile(join(codexHome, "config.toml"), "utf8")
|
||||
expect(configContent).toContain('[plugins."omo@sisyphuslabs".mcp_servers.git_bash]')
|
||||
expect(configContent).toContain("enabled = true")
|
||||
expect(configContent).toContain("pre_tool_use")
|
||||
expect(configContent).toContain("post_compact")
|
||||
expect(result.gitBashPath).toBe("C:\\Program Files\\Git\\bin\\bash.exe")
|
||||
const pluginPath = result.installed[0]?.path ?? ""
|
||||
const mcpManifest = JSON.parse(await readFile(join(pluginPath, ".mcp.json"), "utf8")) as {
|
||||
readonly mcpServers: { readonly git_bash: { readonly args: readonly string[] } }
|
||||
}
|
||||
expect(mcpManifest.mcpServers.git_bash.args[0]).toBe(join(pluginPath, "components", "git-bash-mcp", "dist", "cli.js"))
|
||||
expect((await stat(mcpManifest.mcpServers.git_bash.args[0] ?? "")).isFile()).toBe(true)
|
||||
})
|
||||
|
||||
test("#given simulated Linux Codex install #when installing omo #then keeps git_bash manifest but disables policy exposure", async () => {
|
||||
// given
|
||||
const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-home-git-bash-linux-"))
|
||||
const binDir = await mkdtemp(join(tmpdir(), "omo-codex-bin-git-bash-linux-"))
|
||||
const repoRoot = process.cwd()
|
||||
|
||||
// when
|
||||
const result = await runCodexInstaller({
|
||||
codexHome,
|
||||
binDir,
|
||||
repoRoot,
|
||||
platform: "linux",
|
||||
runCommand: async () => undefined,
|
||||
})
|
||||
|
||||
// then
|
||||
const configContent = await readFile(join(codexHome, "config.toml"), "utf8")
|
||||
expect(configContent).toContain('[plugins."omo@sisyphuslabs".mcp_servers.git_bash]')
|
||||
expect(configContent).toContain("enabled = false")
|
||||
const pluginPath = result.installed[0]?.path ?? ""
|
||||
const mcpManifest = JSON.parse(await readFile(join(pluginPath, ".mcp.json"), "utf8")) as {
|
||||
readonly mcpServers: { readonly git_bash: { readonly args: readonly string[] } }
|
||||
}
|
||||
expect(mcpManifest.mcpServers.git_bash.args[0]).toBe(join(pluginPath, "components", "git-bash-mcp", "dist", "cli.js"))
|
||||
})
|
||||
|
||||
test("#given codex installer #when installing omo #then links omo-prefixed component CLIs to existing cached runtimes", async () => {
|
||||
// given
|
||||
@@ -267,6 +327,7 @@ describe("install-codex", () => {
|
||||
const snapshotMcpManifest: {
|
||||
readonly mcpServers: {
|
||||
readonly ast_grep: { readonly args: readonly string[] }
|
||||
readonly git_bash: { readonly args: readonly string[] }
|
||||
readonly lsp: { readonly args: readonly string[] }
|
||||
}
|
||||
} = JSON.parse(await readFile(join(snapshotPluginPath, ".mcp.json"), "utf8"))
|
||||
@@ -274,13 +335,17 @@ describe("install-codex", () => {
|
||||
join(snapshotPluginPath, "components", "ast-grep-mcp", "dist", "cli.js"),
|
||||
)
|
||||
expect((await stat(snapshotMcpManifest.mcpServers.ast_grep.args[0] ?? "")).isFile()).toBe(true)
|
||||
expect(snapshotMcpManifest.mcpServers.git_bash.args[0]).toBe(
|
||||
join(snapshotPluginPath, "components", "git-bash-mcp", "dist", "cli.js"),
|
||||
)
|
||||
expect((await stat(snapshotMcpManifest.mcpServers.git_bash.args[0] ?? "")).isFile()).toBe(true)
|
||||
expect(snapshotMcpManifest.mcpServers.lsp.args[0]).toBe(
|
||||
join(snapshotPluginPath, "components", "lsp-tools-mcp", "dist", "cli.js"),
|
||||
)
|
||||
expect(snapshotMcpManifest.mcpServers.lsp.args[0]).not.toContain("../../lsp-tools-mcp")
|
||||
expect(snapshotMcpManifest.mcpServers.lsp.args[0]).not.toContain("components/lsp/packages")
|
||||
expect((await stat(snapshotMcpManifest.mcpServers.lsp.args[0] ?? "")).isFile()).toBe(true)
|
||||
}, { timeout: 15_000 })
|
||||
})
|
||||
|
||||
test("#given autonomous permissions requested #when installing omo #then writes Codex autonomy settings", async () => {
|
||||
// given
|
||||
|
||||
@@ -129,6 +129,7 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom
|
||||
marketplaceName: marketplace.name,
|
||||
marketplaceSource: codexMarketplaceSource(marketplaceRoot),
|
||||
pluginNames: marketplace.plugins.map((plugin) => plugin.name),
|
||||
platform,
|
||||
trustedHookStates,
|
||||
agentConfigs: [...agentConfigs.values()].sort((left, right) => left.name.localeCompare(right.name)),
|
||||
autonomousPermissions: options.autonomousPermissions === true,
|
||||
|
||||
Reference in New Issue
Block a user