From 2352b32f17258ada1ce97c4f0986e4e6058fb7f9 Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:46:19 +0200 Subject: [PATCH 1/3] feat(emit): add OpenClaw as a first-class emit target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenClaw becomes the config compiler's tenth tool, wired through the existing compiler/registry patterns rather than a side script. Instructions need no new file. OpenClaw appends the execution folder's AGENTS.md after its configured agent-workspace files as project context, so the canonical source reaches it exactly the way it reaches Codex, Cursor and Copilot. The new src/emit/openclaw.js reports that relationship and nothing more. MCP is registered explicitly, never silently. OpenClaw keeps its server registry in the user's global ~/.openclaw/openclaw.json under mcp.servers, which Forge will not write to. So `forge sync` emits an OpenClaw-shaped fragment to .openclaw/mcp.json and the report row carries the exact enabling command (`openclaw mcp add forge-cortex --command forge --arg cortex-mcp`) plus a "not auto-loaded" note, so nothing reads as live that isn't. .openclaw/mcp.json is an ordinary managed MCP target: idempotent, per-target ownership (a divergent same-name server the user wrote is preserved until --adopt), and reversible through `forge integrations remove`. To reach a nested bucket, JSON targets now address their server map by dotted key path; the resolver creates missing objects only on write and refuses to restructure a document where any step already holds a non-object. `openclaw` is selectable and auto-detected by `forge tools`. Forge installs nothing into OpenClaw's hook system. There are no ambient guards there, only AGENTS.md text and the MCP tools — docs say so rather than implying Claude Code parity. Docs, tool-count claims, and the changelog move in the same pass; `forge docs check` passes. Co-Authored-By: Claude Opus 5 (1M context) --- .codex-plugin/plugin.json | 2 +- ARCHITECTURE.md | 34 ++- CHANGELOG.md | 24 ++ CONTRIBUTING.md | 2 +- ONBOARDING.md | 21 +- README.md | 14 +- docs/GUIDE.md | 39 +++- docs/cognitive-substrate/README.md | 11 +- mintlify/cli/config.mdx | 4 +- mintlify/concepts/config-compiler.mdx | 12 +- mintlify/guides/zero-config-onboarding.mdx | 4 +- mintlify/introduction.mdx | 8 +- mintlify/quickstart.mdx | 5 +- src/emit/mcp.js | 83 ++++++- src/emit/openclaw.js | 32 +++ src/repo_config.js | 3 + src/sync.js | 16 +- templates/project-layer/AGENTS.md | 2 +- test/openclaw.test.js | 247 +++++++++++++++++++++ 19 files changed, 523 insertions(+), 40 deletions(-) create mode 100644 src/emit/openclaw.js create mode 100644 test/openclaw.test.js diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 4bef723..a3027b5 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -15,7 +15,7 @@ "interface": { "displayName": "Forge", "shortDescription": "Cognitive substrate and one config for every AI coding tool.", - "longDescription": "Forge adds a pre-action cognitive substrate for coding agents: assumption gating, transparent model routing, impact prediction, scope decomposition, memory/learning context, and verification checklists. It also emits shared rules and MCP config for Codex, Claude Code, Cursor, Gemini, Aider, Copilot, Windsurf/Devin, Zed, Continue, and Roo.", + "longDescription": "Forge adds a pre-action cognitive substrate for coding agents: assumption gating, transparent model routing, impact prediction, scope decomposition, memory/learning context, and verification checklists. It also emits shared rules and MCP config for Codex, Claude Code, Cursor, Gemini, Aider, Copilot, Windsurf/Devin, Zed, Continue, OpenClaw, and Roo.", "developerName": "CodeWithJuber", "category": "Productivity", "capabilities": ["MCP", "Skills", "Code Review", "Verification"], diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 171eb68..ae535bb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -29,7 +29,7 @@ for the full list. Brand stored as **one token** (the `brand` key in `brand.json`); rebrand = 1 edit. - **Distributable id = `forgekit`** (npm package + marketplace id) — fixed even if the brand token changes, so a rename never breaks install. -- **Scope = full multi-tool day 1** — nine tools plus MCP, from one canonical source. +- **Scope = full multi-tool day 1** — ten tools plus MCP, from one canonical source. - **Install = all three channels** (plugin + hardened installer + npm CLI), all three pointing at the _same_ tree ("one tree, three front doors"). - **Own `lean` + `atlas`** — as _thin layers over proven primitives_, not @@ -443,7 +443,7 @@ guards through `${CLAUDE_PROJECT_DIR}`. ## Verified cross-tool emit matrix -_(All rows confirmed against vendor docs.)_ Forge emits config for **nine tools**, plus +_(All rows confirmed against vendor docs.)_ Forge emits config for **ten tools**, plus an **MCP server** for Roo Code and VS Code. | Tool | Native target | How Forge emits | @@ -457,10 +457,34 @@ an **MCP server** for Roo Code and VS Code. | **Windsurf/Devin** | `AGENTS.md` auto-discovered; caps 6k/12k chars | Root `AGENTS.md` under caps; detect `.windsurf` vs `.devin` at init | | **Zed** | first match of a precedence list incl. `AGENTS.md` | Emit `AGENTS.md` + doctor flags any earlier-precedence legacy file shadowing it | | **Continue** | `.continue/rules/*.md` + `.continue/mcpServers/*.yaml` | Emit a rules file plus the Forge MCP server config | +| **OpenClaw** | execution-folder `AGENTS.md` as project context; MCP registry is global | Rely on root `AGENTS.md`; write an OpenClaw-shaped `.openclaw/mcp.json` the operator applies with one `openclaw mcp add` | Roo Code and VS Code receive the Forge MCP server via `forge init` (`.roo/mcp.json`, `.vscode/mcp.json`) rather than a rules file. +### OpenClaw: what is automatic and what is not + +OpenClaw appends the execution folder's `AGENTS.md` after its configured agent-workspace +files as project context, so the canonical rules reach it with **no** extra instruction +file — the same deal as Codex, Cursor and Copilot. Only `AGENTS.md` travels this way: +OpenClaw deliberately does not load `SOUL.md`, `IDENTITY.md`, `USER.md`, `MEMORY.md` or +`BOOTSTRAP.md` from the execution folder, so anything Forge wants OpenClaw to read has to +be inside the canonical body. + +MCP is deliberately **not** automatic. OpenClaw's server registry is `mcp.servers` in the +user's global `~/.openclaw/openclaw.json`; Forge never writes to another tool's global +config. Instead `forge sync` emits a repo-local, OpenClaw-shaped fragment at +`.openclaw/mcp.json` and reports the exact command that registers it: + +```bash +openclaw mcp add forge-cortex --command forge --arg cortex-mcp +openclaw mcp doctor forge-cortex --probe # prove it starts and lists tools +``` + +Forge installs **nothing** into OpenClaw's hook system. On OpenClaw the substrate reaches +the model through `AGENTS.md` text and the `forge-cortex` MCP tools — there are no ambient +pre-action guards the way there are on Claude Code. + ## Repo layout — one tree, three front doors ``` @@ -552,8 +576,8 @@ from the tree it describes. ```mermaid %%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart LR - test["test
100 files"] - src["src
94 files"] + test["test
103 files"] + src["src
97 files"] landing["landing
61 files"] research["research
35 files"] bench["bench
2 files"] @@ -561,7 +585,7 @@ flowchart LR scripts["scripts
2 files"] docs["docs
1 file"] examples["examples
1 file"] - test -- 195 --> src + test -- 202 --> src bench -- 7 --> src examples -- 4 --> src test -- 2 --> scripts diff --git a/CHANGELOG.md b/CHANGELOG.md index c4dd9ea..93b69b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **OpenClaw is a first-class emit target — the compiler's tenth tool.** Instructions need + no new file: OpenClaw appends the execution folder's `AGENTS.md` after its configured + agent-workspace files as project context, so the canonical source reaches it the same way + it reaches Codex, Cursor and Copilot. MCP is registered explicitly rather than silently: + OpenClaw keeps its server registry in the user's global `~/.openclaw/openclaw.json`, which + Forge will not write to, so `forge sync` emits an OpenClaw-shaped fragment to + `.openclaw/mcp.json` and reports the exact enabling command + (`openclaw mcp add forge-cortex --command forge --arg cortex-mcp`). `.openclaw/mcp.json` + is an ordinary managed MCP target: idempotent, per-target ownership (a same-name server + you wrote yourself is preserved until `--adopt`), and reversible via + `forge integrations remove`. `openclaw` is now selectable and auto-detected by + `forge tools`. Forge installs **nothing** into OpenClaw's hook system — there are no + ambient guards there, only `AGENTS.md` text and the MCP tools. + +### Changed + +- **MCP targets address their server bucket by dotted key path.** `emit/mcp.js` resolved a + single top-level key (`mcpServers`, `servers`, `context_servers`); OpenClaw nests its + registry under `mcp.servers`. The resolver now walks a path, creating missing objects only + on write, and refuses to restructure a file where any step already holds a non-object — + that shape is the user's and is reported, never rewritten. + ## [0.32.1] - 2026-08-22 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de94c9c..25cc35a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ small and dependency-free is the whole point, so please read this before opening - **Node.js ESM only.** All code is ES modules (`"type": "module"`). No CommonJS. - **Supported Node versions:** 20 and 22 (the `>=20` engines floor; Node 18 is EOL). - **Cross-tool first.** New behavior should work across the tools forgekit targets - (Claude Code, Codex, Cursor, Gemini, Aider, …), emitted from one source — not + (Claude Code, Codex, Cursor, Gemini, Aider, OpenClaw, …), emitted from one source — not Claude-only. Say so in the PR if a piece is unavoidably tool-specific. ## Getting started diff --git a/ONBOARDING.md b/ONBOARDING.md index 590e50c..59e372b 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -5,7 +5,7 @@ context window, wiped every call — so it has no memory of what your team learn foresight about what an edit breaks, and no enforced guardrails. forgekit is the **cognitive substrate** that supplies exactly those three things, and it delivers them as native config to Claude Code, Codex, Cursor, Gemini, Aider, Copilot, Windsurf, Zed, -and Continue at once. Author the brain once; every tool reads it. +Continue, and OpenClaw at once. Author the brain once; every tool reads it. This page is the fast path: install, configure a repo, do a task, and watch the ledger start paying off on day two. @@ -51,9 +51,22 @@ cd ~/your-project forge init # emits AGENTS.md, CLAUDE.md, .gemini/settings.json, .aider.conf.yml … ``` -Now Claude Code, Codex, Cursor, Gemini, Aider, Copilot, Windsurf, Zed, and Continue all -read the **same** rules — each from its own native file (plus MCP server config for Roo -Code and VS Code). +Now Claude Code, Codex, Cursor, Gemini, Aider, Copilot, Windsurf, Zed, Continue, and +OpenClaw all read the **same** rules — each from its own native file (plus MCP server +config for Roo Code and VS Code). + +On OpenClaw the rules arrive automatically (it reads the execution folder's `AGENTS.md` +as project context), but the MCP server is a deliberate one-command step, because +OpenClaw's registry lives in your global `~/.openclaw/openclaw.json` and Forge does not +write there: + +```bash +openclaw mcp add forge-cortex --command forge --arg cortex-mcp +openclaw mcp doctor forge-cortex --probe +``` + +`forge sync` writes the same definition to `.openclaw/mcp.json` so you can review or +merge it instead of retyping the flags. Change a rule later by editing `source/rules.json` (or dropping a per-repo `.forge/rules.json`), then: diff --git a/README.md b/README.md index ef18ec1..e5712f4 100644 --- a/README.md +++ b/README.md @@ -22,16 +22,20 @@ delivers them into every tool you use. > content-addressed memory (we call it "proof-carrying memory" / PCM — see the honesty note > below), heuristic impact foresight, and guardrail hooks (automatic on Claude Code; > instructions and MCP tools elsewhere) — authored once and delivered as native config to -> Claude Code, Codex, Cursor, Gemini, Aider, Copilot, Windsurf, Zed, and Continue (plus MCP -> config for Roo and VS Code). Guardrails reduce risk; they are not a security sandbox. +> Claude Code, Codex, Cursor, Gemini, Aider, Copilot, Windsurf, Zed, Continue, and OpenClaw +> (plus MCP config for Roo and VS Code). Guardrails reduce risk; they are not a security +> sandbox. > **Status: beta — read before you rely on it.** > > - The core (`init`, `sync`, `substrate`, `impact`, `ledger`, guards) is tested and in daily > use; some flags may change before `1.0`. > - **Claude Code is the deepest-tested integration** (full plugin, ambient `UserPromptSubmit` -> guards). The other eight tools receive native config plus MCP tools, but have had less -> real-world exercise. +> guards). The other nine tools receive native config plus MCP tools, but have had less +> real-world exercise. On OpenClaw specifically, rules arrive via `AGENTS.md` project +> context and the MCP registration is a one-command manual step — there are no ambient +> hooks (see +> [OpenClaw in ARCHITECTURE](ARCHITECTURE.md#openclaw-what-is-automatic-and-what-is-not)). > - **Impact/blast-radius analysis is heuristic** — a regex-approximate, conservative code > graph, not a sound call graph. Treat its output as advisory. > - **"Proof-carrying memory" is a name, not a formal proof.** Claims are content-addressed and @@ -128,7 +132,7 @@ The day-to-day value first — the substrate gives a frozen model what it can't (`forge docs sync` sweeps the diff for stale prose, `forge handoff` writes the bounded session snapshot the next session resumes from, `forge decide` records choices so no session re-decides them). -- **One config for 9 tools.** Author your rules once; Forge emits each tool's native config, +- **One config for 10 tools.** Author your rules once; Forge emits each tool's native config, plus MCP for Roo and VS Code. Zero runtime dependencies — one Node CLI, plain files in git, no server. diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 526b6a6..b6fb454 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -704,10 +704,10 @@ tools` fixes that without changing what `sync` emits. - `forge tools` — show the detected/primary tool (from `.forge/config.json`, else auto-detected from which agent folder exists — `CLAUDE.md`, `.cursor/`, `.gemini/`, - `.codex/`, `.zed/`, `.vscode/`, `.aider.conf.yml`, `.continue/`, `.windsurf/`, `.roo/`) - and which targets are currently gitignored. + `.codex/`, `.zed/`, `.vscode/`, `.aider.conf.yml`, `.continue/`, `.windsurf/`, `.roo/`, + `.openclaw/`) and which targets are currently gitignored. - `forge tools ` — record `` (`claude` · `cursor` · `gemini` · `codex` · - `zed` · `vscode` · `aider` · `continue` · `windsurf` · `roo`) as this repo's primary tool in + `zed` · `vscode` · `aider` · `continue` · `windsurf` · `roo` · `openclaw`) as this repo's primary tool in `.forge/config.json`, then write a **marked, reversible** block into `.gitignore` (`# forge:gitignore:begin … # forge:gitignore:end`) that ignores every OTHER tool's emitted artifacts. Your own `.gitignore` lines are never touched, and the shared @@ -1298,6 +1298,39 @@ emitted `.mcp.json`): Forge never pretends it can force a hook into a tool that has none — **ambient on Claude Code, agent-invoked everywhere else.** +### OpenClaw — rules automatic, MCP one command + +OpenClaw appends the execution folder's `AGENTS.md` after its configured agent-workspace +files as project context, so `forge sync` needs no OpenClaw-specific instruction file: +the canonical rules arrive on their own. Only `AGENTS.md` travels that way — OpenClaw does +not read `SOUL.md`, `IDENTITY.md`, `USER.md`, `MEMORY.md` or `BOOTSTRAP.md` from the +execution folder — so keep anything OpenClaw must see inside the canonical body. + +The MCP server is **not** wired automatically, on purpose. OpenClaw keeps its registry in +your global `~/.openclaw/openclaw.json` under `mcp.servers`, and Forge does not write to +another tool's global config. Instead `forge sync` emits the OpenClaw-shaped definition to +`.openclaw/mcp.json`: + +```json +{ "mcp": { "servers": { "forge-cortex": { "command": "forge", "args": ["cortex-mcp"] } } } } +``` + +Register it once, then prove it: + +```bash +openclaw mcp add forge-cortex --command forge --arg cortex-mcp +openclaw mcp doctor forge-cortex --probe +``` + +`.openclaw/mcp.json` is an ordinary managed MCP target: re-running `sync` is idempotent, a +same-name server you wrote yourself is preserved (claim it with +`forge integrations add --adopt`), and `forge integrations remove ` reverses +the add there exactly as it does for every other tool. Removing the entry from the file +does **not** unregister it inside OpenClaw — use `openclaw mcp unset forge-cortex` for that. + +Forge installs nothing into OpenClaw's hook system: on OpenClaw the substrate is +`AGENTS.md` text plus the MCP tools above, with no ambient pre-action guard. + --- ## Reading substrate output diff --git a/docs/cognitive-substrate/README.md b/docs/cognitive-substrate/README.md index d1622b5..62e94ae 100644 --- a/docs/cognitive-substrate/README.md +++ b/docs/cognitive-substrate/README.md @@ -25,8 +25,8 @@ forge atlas # build the code graph (needed for blast-radius checks) ``` `forge init` configures Claude Code, Codex, Cursor, Gemini, Aider, Copilot, Windsurf, Zed, -and Continue (plus MCP config for Roo and VS Code). On Claude Code the check then runs on -every prompt automatically. +Continue, and OpenClaw (plus MCP config for Roo and VS Code). On Claude Code the check then +runs on every prompt automatically. --- @@ -157,6 +157,13 @@ can call directly. Details + the exact rule wording: Forge never pretends it can force a hook into a tool that has none — it's ambient on Claude Code, and agent-invoked everywhere else. +**OpenClaw** is agent-invoked in exactly this sense. It picks up the rules on its own (the +execution folder's `AGENTS.md` becomes project context), but its MCP registry is your global +`~/.openclaw/openclaw.json`, which Forge will not write to. `forge sync` leaves the +definition in `.openclaw/mcp.json`; you register it once with +`openclaw mcp add forge-cortex --command forge --arg cortex-mcp`. Forge installs nothing +into OpenClaw's own hook system. + --- ## Use it in a script diff --git a/mintlify/cli/config.mdx b/mintlify/cli/config.mdx index ab45d14..7100ba7 100644 --- a/mintlify/cli/config.mdx +++ b/mintlify/cli/config.mdx @@ -96,8 +96,8 @@ forge tools ## `forge integrations` Opt-in third-party MCP servers (for example `context7`), managed non-destructively across -every tool Forge emits config for — Claude Code, Codex, Cursor, Gemini, Continue, and the -rest. +every tool Forge emits config for — Claude Code, Codex, Cursor, Gemini, Continue, OpenClaw, +and the rest. Third-party MCP servers are **not** installed by default. Nothing lands on disk until you run `forge integrations add --yes`. diff --git a/mintlify/concepts/config-compiler.mdx b/mintlify/concepts/config-compiler.mdx index 6a5efec..66c6eaa 100644 --- a/mintlify/concepts/config-compiler.mdx +++ b/mintlify/concepts/config-compiler.mdx @@ -77,7 +77,7 @@ compaction. ## The verified cross-tool emit matrix -Forge emits config for **nine tools**, plus an MCP server for Roo Code and VS Code. Each +Forge emits config for **ten tools**, plus an MCP server for Roo Code and VS Code. Each row is confirmed against vendor docs. | Tool | Native target | How Forge emits | @@ -91,10 +91,20 @@ row is confirmed against vendor docs. | **Windsurf/Devin** | `AGENTS.md` auto-discovered (caps 6k/12k chars) | Root `AGENTS.md` under caps; detects `.windsurf` vs `.devin` | | **Zed** | first match of a precedence list incl. `AGENTS.md` | Emits `AGENTS.md`; doctor flags any shadowing legacy file | | **Continue** | `.continue/rules/*.md` + `.continue/mcpServers/*.yaml` | Emits a rules file plus the Forge MCP server config | +| **OpenClaw** | execution-folder `AGENTS.md`; MCP registry is global | Relies on root `AGENTS.md`; writes `.openclaw/mcp.json` to apply once | Roo Code and VS Code receive the Forge MCP server via `forge init` (`.roo/mcp.json`, `.vscode/mcp.json`) rather than a rules file. + + **OpenClaw MCP is a deliberate manual step.** OpenClaw stores servers in your global + `~/.openclaw/openclaw.json`, which Forge will not write to. `forge sync` leaves the + definition in `.openclaw/mcp.json`; register it with + `openclaw mcp add forge-cortex --command forge --arg cortex-mcp`, then verify with + `openclaw mcp doctor forge-cortex --probe`. Forge adds no OpenClaw hooks — rules reach + it through `AGENTS.md` project context and the MCP tools, nothing more. + + **Char caps are real.** Codex truncates at 32 KiB, Windsurf at 6k/12k. `forge sync` enforces a source size budget so a config never silently truncates. diff --git a/mintlify/guides/zero-config-onboarding.mdx b/mintlify/guides/zero-config-onboarding.mdx index 635d922..beeaa21 100644 --- a/mintlify/guides/zero-config-onboarding.mdx +++ b/mintlify/guides/zero-config-onboarding.mdx @@ -47,8 +47,8 @@ cd ~/your-project forge init # emits AGENTS.md, CLAUDE.md, .gemini/settings.json, .aider.conf.yml … ``` -Now Claude Code, Codex, Cursor, Gemini, Aider, Copilot, Windsurf, Zed, and Continue all -read the **same** rules — each from its own native file. Change a rule later by editing +Now Claude Code, Codex, Cursor, Gemini, Aider, Copilot, Windsurf, Zed, Continue, and +OpenClaw all read the **same** rules — each from its own native file. Change a rule later by editing `source/rules.json` (or dropping a per-repo `.forge/rules.json`), then run `forge sync`. ## 3. Use the cognitive substrate diff --git a/mintlify/introduction.mdx b/mintlify/introduction.mdx index 50cf6f2..cc17e1f 100644 --- a/mintlify/introduction.mdx +++ b/mintlify/introduction.mdx @@ -82,15 +82,15 @@ state between calls; Forge is the state. - **Work that finishes end to end.** A completion gate blocks "done" once per session when code moved but no doc or state artifact followed, with the repair checklist as the answer. -- **One config for 9 tools.** Author your rules once; Forge emits each tool's native +- **One config for 10 tools.** Author your rules once; Forge emits each tool's native config, plus MCP for Roo and VS Code. Zero runtime dependencies — one Node CLI, plain files in git, no server. ## Which tools does Forge feed? -Forge emits config for **nine tools**, plus an MCP server for Roo Code and VS Code: -Claude Code, Codex, Cursor, Gemini, Aider, Copilot, Windsurf/Devin, Zed, and Continue. -Each reads the same rules from its own native file. +Forge emits config for **ten tools**, plus an MCP server for Roo Code and VS Code: +Claude Code, Codex, Cursor, Gemini, Aider, Copilot, Windsurf/Devin, Zed, Continue, and +OpenClaw. Each reads the same rules from its own native file. The only MCP server wired by default is Forge's own (`src/cortex_mcp.js`) — the substrate checks and memory reads. Third-party MCP servers such as `context7` are **opt-in** and diff --git a/mintlify/quickstart.mdx b/mintlify/quickstart.mdx index 3cd3525..c6ecba9 100644 --- a/mintlify/quickstart.mdx +++ b/mintlify/quickstart.mdx @@ -38,8 +38,9 @@ including the symlink dev setup. ``` This emits `AGENTS.md`, `CLAUDE.md`, `.gemini/settings.json`, `.aider.conf.yml`, and the rest — plus the `.gitattributes` union-merge rule the ledger needs. Claude Code, - Codex, Cursor, Gemini, Aider, Copilot, Windsurf, Zed, and Continue now read the - **same** rules, each from its own native file. + Codex, Cursor, Gemini, Aider, Copilot, Windsurf, Zed, Continue, and OpenClaw now + read the **same** rules, each from its own native file. On OpenClaw the MCP server is + one extra command: `openclaw mcp add forge-cortex --command forge --arg cortex-mcp`. ```bash diff --git a/src/emit/mcp.js b/src/emit/mcp.js index e3b4e07..456e303 100644 --- a/src/emit/mcp.js +++ b/src/emit/mcp.js @@ -19,6 +19,16 @@ import { dirname, join } from "node:path"; import { BRAND } from "../brand.js"; import { hashContent, isManaged, readIfExists, writeManaged, yamlHeader } from "./_shared.js"; +/** The relative path of the OpenClaw MCP artifact. OpenClaw's own MCP registry lives in the + * USER's global config (`~/.openclaw/openclaw.json` → `mcp.servers`), which forge must never + * write to, so this is a repo-local, OpenClaw-SHAPED fragment: valid config to merge or the + * source of the one `openclaw mcp add` command that registers the server. OpenClaw does NOT + * auto-discover it — `openclawAddCommand` renders the explicit enable step, and the emitted + * row says so rather than implying the server is already live. */ +export const OPENCLAW_TARGET = ".openclaw/mcp.json"; + +// `key` is a DOTTED PATH into the document, not a single top-level key: OpenClaw nests its +// registry under `mcp.servers`, every other tool uses one flat key. `bucketAt` resolves both. const JSON_TARGETS = [ { tool: "Claude Code", file: ".mcp.json", key: "mcpServers" }, { tool: "Cursor", file: ".cursor/mcp.json", key: "mcpServers" }, @@ -26,6 +36,7 @@ const JSON_TARGETS = [ { tool: "Roo Code", file: ".roo/mcp.json", key: "mcpServers" }, { tool: "Zed", file: ".zed/settings.json", key: "context_servers" }, { tool: "VS Code / Copilot", file: ".vscode/mcp.json", key: "servers" }, + { tool: "OpenClaw", file: OPENCLAW_TARGET, key: "mcp.servers" }, ]; const CONTINUE_DIR = join(".continue", "mcpServers"); @@ -78,6 +89,28 @@ function validateServers(servers) { * leading indicators, newlines, etc. */ const yamlScalar = (s) => JSON.stringify(String(s)); +/** Quote one shell word only when it needs it, so the rendered enable command stays + * copy-pasteable and readable for the ordinary `forge` / `cortex-mcp` case. */ +const shellWord = (s) => + /^[A-Za-z0-9_@%+=:,./-]+$/.test(String(s)) + ? String(s) + : `'${String(s).replaceAll("'", `'\\''`)}'`; + +/** + * The exact, current-docs `openclaw mcp add` invocation that registers one stdio server in + * OpenClaw's own (global) registry. Forge renders this instead of writing OpenClaw's config: + * `mcp.servers` lives in the user's `~/.openclaw/openclaw.json`, which is not forge's to edit. + * @param {string} name + * @param {{command:string, args?:string[]}} def + * @returns {string} + */ +export function openclawAddCommand(name, def) { + const args = (def.args || []).map((a) => `--arg ${shellWord(a)}`); + return ["openclaw mcp add", shellWord(name), `--command ${shellWord(def.command)}`, ...args].join( + " ", + ); +} + /** Per-server Continue file name. Servers already namespaced `forge-*` keep their name. * Injectivity across a managed set is enforced by validateServers (`foo`/`forge-foo`). */ export const continueFileFor = (name) => @@ -87,6 +120,27 @@ export const continueFileFor = (name) => // JSON targets — entry-level ownership (RA-21, ME-08). // --------------------------------------------------------------------------- +/** + * Resolve the server bucket named by a dotted `keyPath` (`"mcpServers"`, `"mcp.servers"`). + * With `create`, missing intermediate objects are added. Returns null when ANY step already + * holds a non-object (a string, array, null): that is the user's document shape and forge + * restructuring it would destroy data — the caller reports and leaves the file alone. + * @returns {Record|null} + */ +function bucketAt(obj, keyPath, { create = false } = {}) { + let cur = obj; + for (const k of keyPath.split(".")) { + if (!cur || typeof cur !== "object" || Array.isArray(cur)) return null; + const v = cur[k]; + if (v === undefined) { + if (!create) return null; + cur[k] = {}; + } else if (!v || typeof v !== "object" || Array.isArray(v)) return null; + cur = cur[k]; + } + return cur; +} + function mergeJson(path, key, servers, owns) { let obj = {}; if (existsSync(path)) { @@ -96,7 +150,10 @@ function mergeJson(path, key, servers, owns) { return { action: "skipped", note: "invalid JSON — left as-is" }; } } - const bucket = obj[key] || (obj[key] = {}); + if (!obj || typeof obj !== "object" || Array.isArray(obj)) + return { action: "skipped", note: "not a JSON object — left as-is" }; + const bucket = bucketAt(obj, key, { create: true }); + if (!bucket) return { action: "skipped", note: `\`${key}\` is not an object — left as-is` }; // Ownership rules: absent → write; present + owned → refresh on drift; present + // NOT owned → leave byte-identical and say so (the entry is the user's — a same-name // server they configured themselves must never be silently replaced). @@ -143,7 +200,7 @@ function removeFromJson(path, key, name) { } catch { return { action: "skipped", note: "invalid JSON — left as-is" }; } - const bucket = obj?.[key]; + const bucket = bucketAt(obj, key); if (!bucket || bucket[name] === undefined) return { action: "unchanged", note: "absent" }; delete bucket[name]; writeFileSync(path, `${JSON.stringify(obj, null, 2)}\n`); @@ -323,6 +380,19 @@ function migrateLegacyContinue(dir) { // Entry points. // --------------------------------------------------------------------------- +/** The report note for the OpenClaw target: what the file is, and the command that actually + * registers it. Multiple managed servers name the forge server's command plus a count, so + * the row stays one line without ever claiming a server is enabled that isn't. */ +function openclawEnableHint(servers) { + const names = Object.keys(servers); + if (!names.length) return "not auto-loaded by OpenClaw"; + const first = names.find((n) => n.startsWith("forge-")) ?? names[0]; + const rest = names.length - 1; + return `not auto-loaded — enable with \`${openclawAddCommand(first, servers[first])}\`${ + rest ? ` (+${rest} more in the file)` : "" + }`; +} + /** * Emit `servers` (the full managed set) into every target. `owns(target, name)` decides, * PER TARGET, whether forge may OVERWRITE an already-present same-name entry that drifted @@ -342,7 +412,10 @@ export function emitMcp({ targetRoot, servers, owns = () => true }) { tool: `${t.tool} MCP`, target: t.file, action: r.action, - note: r.note, + // OpenClaw is the one target whose file is NOT read by the tool: its registry is the + // user's global config. Never let the row read like the server is live — always carry + // the explicit enable command (RA-03's honesty rule applied to a manual-apply target). + note: t.file === OPENCLAW_TARGET ? `${r.note}; ${openclawEnableHint(servers)}` : r.note, }; }); const codex = emitCodexToml(join(targetRoot, CODEX_FILE), servers, (name) => @@ -392,7 +465,7 @@ export function removeMcp({ targetRoot, name, removeJsonEntry }) { let current; if (existsSync(path)) { try { - current = JSON.parse(readFileSync(path, "utf8"))?.[t.key]?.[name]; + current = bucketAt(JSON.parse(readFileSync(path, "utf8")), t.key)?.[name]; } catch { current = undefined; } @@ -465,7 +538,7 @@ export function foreignTargets(targetRoot, name, def) { const path = join(targetRoot, t.file); if (!existsSync(path)) continue; try { - const cur = JSON.parse(readFileSync(path, "utf8"))?.[t.key]?.[name]; + const cur = bucketAt(JSON.parse(readFileSync(path, "utf8")), t.key)?.[name]; if (cur !== undefined && JSON.stringify(cur) !== JSON.stringify(def)) out.add(t.file); } catch { // invalid JSON: mergeJson will skip the file entirely — not a claimable entry diff --git a/src/emit/openclaw.js b/src/emit/openclaw.js new file mode 100644 index 0000000..19a0012 --- /dev/null +++ b/src/emit/openclaw.js @@ -0,0 +1,32 @@ +// OpenClaw reads the EXECUTION FOLDER's `AGENTS.md` natively: when a session runs from a +// repo (or a managed worktree), that folder's AGENTS.md is appended after the configured +// agent-workspace files as project context (OpenClaw docs, concepts/system-prompt.md). So the +// canonical source reaches OpenClaw with no second instruction file — same deal as Codex, +// Cursor and Copilot. Only `AGENTS.md` travels: OpenClaw deliberately does NOT load +// `SOUL.md`, `IDENTITY.md`, `USER.md`, `MEMORY.md` or `BOOTSTRAP.md` from the execution +// folder, so anything Forge wants OpenClaw to see must be inside the canonical body. +// +// Two things this emitter must NOT claim: +// - Ambient hooks. OpenClaw has its own hook system; Forge installs nothing into it. The +// substrate reaches OpenClaw through AGENTS.md text and the MCP tools, nothing more. +// - Live MCP. OpenClaw's server registry is `mcp.servers` in the USER's global +// `~/.openclaw/openclaw.json`. Forge never writes there; the MCP emitter writes an +// OpenClaw-shaped fragment to `.openclaw/mcp.json` and reports the one command that +// registers it (see emit/mcp.js, OPENCLAW_TARGET). +// +// Legacy `AGENT.md` (singular) is not part of OpenClaw's project-context path, so unlike Zed +// there is no precedence list to police — the only real failure mode is a stale AGENTS.md, +// which the shared marker/hash already covers. +import { OPENCLAW_TARGET } from "./mcp.js"; + +export default { + tool: "OpenClaw", + emit(_ctx) { + return { + tool: this.tool, + target: "AGENTS.md", + action: "relies-on-agents", + note: `execution-folder AGENTS.md is project context (MCP: ${OPENCLAW_TARGET}, applied manually)`, + }; + }, +}; diff --git a/src/repo_config.js b/src/repo_config.js index abc6b42..54a42a9 100644 --- a/src/repo_config.js +++ b/src/repo_config.js @@ -39,6 +39,7 @@ const DETECT = [ { tool: "continue", marker: ".continue" }, { tool: "windsurf", marker: ".windsurf" }, { tool: "roo", marker: ".roo" }, + { tool: "openclaw", marker: ".openclaw" }, ]; /** Tool names accepted by `forge tools `. Kept in lockstep with the emit targets @@ -54,6 +55,7 @@ export const KNOWN_TOOLS = [ "continue", "windsurf", "roo", + "openclaw", ]; // Map a sync-report row's tool label to a canonical tool key. The shared source @@ -70,6 +72,7 @@ const TOOL_KEYS = [ ["continue", /^Continue/], ["windsurf", /^Windsurf/], ["roo", /^Roo/], + ["openclaw", /^OpenClaw/], ]; const forgeConfigPath = (root) => join(root, FORGE_CONFIG_REL); diff --git a/src/sync.js b/src/sync.js index 27e0085..c6ff8b0 100644 --- a/src/sync.js +++ b/src/sync.js @@ -14,12 +14,24 @@ import copilot from "./emit/copilot.js"; import cursor from "./emit/cursor.js"; import gemini from "./emit/gemini.js"; import { emitMcp } from "./emit/mcp.js"; +import openclaw from "./emit/openclaw.js"; import windsurf from "./emit/windsurf.js"; import zed from "./emit/zed.js"; import { managedMcpState } from "./integrations.js"; import { LEGACY_PROFILES, readForgeConfig } from "./repo_config.js"; -const MODULES = [codex, cursor, copilot, windsurf, zed, claude, gemini, aider, continueTool]; +const MODULES = [ + codex, + cursor, + copilot, + windsurf, + zed, + claude, + gemini, + aider, + continueTool, + openclaw, +]; // Soft budget: Codex hard-truncates at 32 KiB, Windsurf caps ~12k chars. Warn early. const SIZE_BUDGET_BYTES = 12 * 1024; @@ -150,7 +162,7 @@ export function sync({ targetRoot = process.cwd() } = {}) { const hash = shared.hashContent(canonical); const bytes = Buffer.byteLength(canonical); - // The shared AGENTS.md — read directly by Codex, Cursor, Copilot, Windsurf, Zed. + // The shared AGENTS.md — read directly by Codex, Cursor, Copilot, Windsurf, Zed, OpenClaw. // If the repo already has a hand-written (unmanaged) AGENTS.md, never destroy it // silently — back it up first so no rules are lost when adopting Forge. const agentsPath = join(targetRoot, "AGENTS.md"); diff --git a/templates/project-layer/AGENTS.md b/templates/project-layer/AGENTS.md index aa3d99e..a7970c2 100644 --- a/templates/project-layer/AGENTS.md +++ b/templates/project-layer/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — -Cross-tool rules (read by Codex, Cursor, Copilot, Gemini, Aider, Zed, and by +Cross-tool rules (read by Codex, Cursor, Copilot, Gemini, Aider, Zed, OpenClaw, and by Claude Code when CLAUDE.md points here). Keep tool-agnostic and thin. ## Stack diff --git a/test/openclaw.test.js b/test/openclaw.test.js new file mode 100644 index 0000000..1da4346 --- /dev/null +++ b/test/openclaw.test.js @@ -0,0 +1,247 @@ +// OpenClaw target — the compiler's tenth tool. What has to hold: +// 1. instructions travel via the shared AGENTS.md (OpenClaw reads the execution folder's +// AGENTS.md as project context) — no second instruction file, no invented hooks; +// 2. the MCP artifact is a VALID OpenClaw config fragment (`mcp.servers.`), not a +// guess at some other tool's schema; +// 3. it is idempotent, non-destructive, and reversible through the same +// integrations add/remove path every other target uses; +// 4. nothing forge emits for OpenClaw carries a secret, and nothing forge writes ever +// lands outside the target repo (the user's ~/.openclaw is off limits). +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { emitMcp, MCP_TARGET_FILES, OPENCLAW_TARGET, openclawAddCommand } from "../src/emit/mcp.js"; +import openclaw from "../src/emit/openclaw.js"; +import { addIntegration, removeIntegration } from "../src/integrations.js"; +import { detectPrimaryTool, KNOWN_TOOLS, rowToolKey } from "../src/repo_config.js"; +import { sync } from "../src/sync.js"; + +const fixture = () => mkdtempSync(join(tmpdir(), "forge-openclaw-")); +const readTarget = (root) => JSON.parse(readFileSync(join(root, OPENCLAW_TARGET), "utf8")); + +// -------------------------------------------------------------------------- +// Instructions: AGENTS.md, and only AGENTS.md. +// -------------------------------------------------------------------------- + +test("OpenClaw instructions ride the shared AGENTS.md — no second instruction file", () => { + const root = fixture(); + const res = sync({ targetRoot: root }); + const row = res.report.find((r) => r.tool === "OpenClaw"); + assert.ok(row, "sync reports an OpenClaw row"); + assert.equal(row.target, "AGENTS.md"); + assert.equal(row.action, "relies-on-agents"); + + // The canonical body actually reached AGENTS.md, and AGENTS.md is forge-managed. + const agents = readFileSync(join(root, "AGENTS.md"), "utf8"); + assert.match(agents, /forge:sync:[a-f0-9]{12}/); + assert.match(agents, /^# AGENTS\.md/m); + + // No OpenClaw-specific instruction file is invented — OPENCLAW.md would be a file + // OpenClaw never reads, and CLAUDE.md is Claude's, not OpenClaw's. + assert.ok(!existsSync(join(root, "OPENCLAW.md"))); +}); + +test("the OpenClaw row never claims ambient hooks or a live MCP connection", () => { + const note = openclaw.emit({ targetRoot: fixture() }).note; + assert.doesNotMatch(note, /hook/i, "forge installs nothing into OpenClaw's hook system"); + assert.match(note, /AGENTS\.md/); + assert.match(note, /manual/i, "the MCP step is stated as manual"); +}); + +// -------------------------------------------------------------------------- +// MCP artifact: real OpenClaw shape. +// -------------------------------------------------------------------------- + +test("sync writes a valid OpenClaw `mcp.servers` stdio fragment", () => { + const root = fixture(); + sync({ targetRoot: root }); + const doc = readTarget(root); + // Nested under mcp.servers — OpenClaw's documented config shape, not a flat mcpServers key. + assert.deepEqual(Object.keys(doc), ["mcp"]); + assert.deepEqual(Object.keys(doc.mcp), ["servers"]); + const server = doc.mcp.servers["forge-cortex"]; + assert.ok(server, "the forge server is registered under mcp.servers"); + // A stdio server needs a command; args must be a string array. + assert.equal(typeof server.command, "string"); + assert.ok(server.command.length > 0); + assert.ok(Array.isArray(server.args)); + for (const a of server.args) assert.equal(typeof a, "string"); + assert.deepEqual(server.args, ["cortex-mcp"]); + // No stray keys: anything forge does not understand must not be invented here. + assert.deepEqual(Object.keys(server).sort(), ["args", "command"]); +}); + +test("the reported enable command matches the emitted server definition", () => { + const root = fixture(); + const res = sync({ targetRoot: root }); + const row = res.report.find((r) => r.target === OPENCLAW_TARGET); + assert.ok(row); + const server = readTarget(root).mcp.servers["forge-cortex"]; + const expected = openclawAddCommand("forge-cortex", server); + assert.equal(expected, "openclaw mcp add forge-cortex --command forge --arg cortex-mcp"); + assert.ok(row.note.includes(expected), row.note); + // The row must not read as "done" — OpenClaw does not auto-load this file. + assert.match(row.note, /not auto-loaded/); +}); + +test("openclawAddCommand shell-quotes only what needs it", () => { + assert.equal( + openclawAddCommand("forge-cortex", { command: "node", args: ["./dist/x.js", "a b"] }), + "openclaw mcp add forge-cortex --command node --arg ./dist/x.js --arg 'a b'", + ); + assert.equal( + openclawAddCommand("srv", { command: "my cmd" }), + "openclaw mcp add srv --command 'my cmd'", + ); +}); + +// -------------------------------------------------------------------------- +// Idempotency + preservation. +// -------------------------------------------------------------------------- + +test("re-running sync leaves the OpenClaw artifact byte-identical", () => { + const root = fixture(); + sync({ targetRoot: root }); + const first = readFileSync(join(root, OPENCLAW_TARGET), "utf8"); + const second = sync({ targetRoot: root }); + assert.equal(readFileSync(join(root, OPENCLAW_TARGET), "utf8"), first, "bytes unchanged"); + assert.equal(second.report.find((r) => r.target === OPENCLAW_TARGET).action, "unchanged"); +}); + +test("a user's own OpenClaw config keys and same-name server survive emission", () => { + const root = fixture(); + mkdirSync(join(root, ".openclaw"), { recursive: true }); + writeFileSync( + join(root, OPENCLAW_TARGET), + `${JSON.stringify( + { + gateway: { publicOrigin: "https://gateway.example.com" }, + mcp: { + apps: { enabled: true }, + servers: { + "forge-cortex": { command: "my-own-forge", args: ["serve"] }, + mine: { command: "node", args: ["./mine.js"] }, + }, + }, + }, + null, + 2, + )}\n`, + ); + const res = sync({ targetRoot: root }); + const doc = readTarget(root); + // Unrelated config is untouched... + assert.deepEqual(doc.gateway, { publicOrigin: "https://gateway.example.com" }); + assert.deepEqual(doc.mcp.apps, { enabled: true }); + assert.deepEqual(doc.mcp.servers.mine, { command: "node", args: ["./mine.js"] }); + // ...and a DIVERGENT same-name entry forge did not write is preserved, not clobbered. + assert.deepEqual(doc.mcp.servers["forge-cortex"], { + command: "my-own-forge", + args: ["serve"], + }); + const row = res.report.find((r) => r.target === OPENCLAW_TARGET); + assert.equal(row.action, "skipped"); + assert.match(row.note, /user-owned/); +}); + +test("a non-object at mcp.servers is left exactly as the user wrote it", () => { + const root = fixture(); + mkdirSync(join(root, ".openclaw"), { recursive: true }); + const original = `${JSON.stringify({ mcp: { servers: "oops" } }, null, 2)}\n`; + writeFileSync(join(root, OPENCLAW_TARGET), original); + const res = sync({ targetRoot: root }); + assert.equal(readFileSync(join(root, OPENCLAW_TARGET), "utf8"), original, "bytes preserved"); + const row = res.report.find((r) => r.target === OPENCLAW_TARGET); + assert.equal(row.action, "skipped"); + assert.match(row.note, /not an object/); +}); + +// -------------------------------------------------------------------------- +// Reversibility, through the same registry path as every other target. +// -------------------------------------------------------------------------- + +test("OpenClaw is an ownable MCP target: integrations add then remove round-trips", () => { + const root = fixture(); + assert.ok(MCP_TARGET_FILES.includes(OPENCLAW_TARGET), "target is in the ownership domain"); + + const added = addIntegration("context7", { targetRoot: root, adopt: false }); + assert.equal(added.ok, true, added.reason); + assert.ok(readTarget(root).mcp.servers.context7, "context7 landed in the OpenClaw fragment"); + assert.ok(readTarget(root).mcp.servers["forge-cortex"], "the registry server is there too"); + + const removed = removeIntegration("context7", { targetRoot: root }); + assert.equal(removed.ok, true, removed.reason); + const doc = readTarget(root); + assert.equal(doc.mcp.servers.context7, undefined, "removal reverses the add"); + assert.ok(doc.mcp.servers["forge-cortex"], "the registry server is untouched by the removal"); + const row = removed.rows.find((r) => r.target === OPENCLAW_TARGET); + assert.equal(row.action, "written"); +}); + +test("emitMcp ownership is per-target: adopting elsewhere does not claim OpenClaw's entry", () => { + const root = fixture(); + mkdirSync(join(root, ".openclaw"), { recursive: true }); + writeFileSync( + join(root, OPENCLAW_TARGET), + `${JSON.stringify({ mcp: { servers: { srv: { command: "theirs" } } } }, null, 2)}\n`, + ); + writeFileSync( + join(root, ".mcp.json"), + `${JSON.stringify({ mcpServers: { srv: { command: "theirs" } } }, null, 2)}\n`, + ); + const servers = { srv: { command: "ours", args: [] } }; + // Adoption is recorded for Claude's file only. + emitMcp({ targetRoot: root, servers, owns: (target) => target === ".mcp.json" }); + assert.equal( + JSON.parse(readFileSync(join(root, ".mcp.json"), "utf8")).mcpServers.srv.command, + "ours", + "the adopted target is refreshed", + ); + assert.equal( + readTarget(root).mcp.servers.srv.command, + "theirs", + "OpenClaw's same-name entry is NOT claimed by another target's adoption", + ); +}); + +// -------------------------------------------------------------------------- +// Registry wiring + safety. +// -------------------------------------------------------------------------- + +test("openclaw is selectable and auto-detected as a primary tool", () => { + assert.ok(KNOWN_TOOLS.includes("openclaw")); + assert.equal(rowToolKey("OpenClaw MCP"), "openclaw"); + assert.equal(rowToolKey("OpenClaw"), "openclaw"); + const root = fixture(); + mkdirSync(join(root, ".openclaw")); + assert.equal(detectPrimaryTool(root), "openclaw"); +}); + +test("nothing forge emits for OpenClaw carries a credential", () => { + const root = fixture(); + // A token in the environment must not be picked up and written into any artifact. + const canary = "forge-openclaw-canary-token-value"; + process.env.FORGE_OPENCLAW_TEST_TOKEN = canary; + try { + sync({ targetRoot: root }); + } finally { + delete process.env.FORGE_OPENCLAW_TEST_TOKEN; + } + const text = readFileSync(join(root, OPENCLAW_TARGET), "utf8"); + assert.ok(!text.includes(canary)); + // No credential-shaped keys at all: the stdio server needs none, so none are emitted. + for (const key of ["env", "headers", "token", "password", "auth", "Authorization"]) + assert.ok(!text.includes(key), `${key} must not appear in ${OPENCLAW_TARGET}`); +}); + +test("emission stays inside the target repo — the user's ~/.openclaw is never a write target", () => { + // Every ownable MCP path is repo-relative: no absolute path, no `~`, no parent escape. + for (const target of MCP_TARGET_FILES) { + assert.ok(!target.startsWith("~"), target); + assert.ok(!target.startsWith("/") && !/^[A-Za-z]:/.test(target), target); + assert.ok(!target.split(/[\\/]/).includes(".."), target); + } + assert.equal(OPENCLAW_TARGET, ".openclaw/mcp.json"); +}); From f894c7aba24fb1a510b806a286853ff8b3458cdb Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:44:54 +0200 Subject: [PATCH 2/3] docs(openclaw): document compatible bundle install --- ARCHITECTURE.md | 19 ++++++++++++------- CHANGELOG.md | 7 +++++-- README.md | 5 +++-- docs/GUIDE.md | 29 ++++++++++++++++++++++------- test/openclaw.test.js | 29 ++++++++++++++++++++++++++++- 5 files changed, 70 insertions(+), 19 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ae535bb..8dde0b8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -471,19 +471,24 @@ OpenClaw deliberately does not load `SOUL.md`, `IDENTITY.md`, `USER.md`, `MEMORY `BOOTSTRAP.md` from the execution folder, so anything Forge wants OpenClaw to read has to be inside the canonical body. -MCP is deliberately **not** automatic. OpenClaw's server registry is `mcp.servers` in the -user's global `~/.openclaw/openclaw.json`; Forge never writes to another tool's global -config. Instead `forge sync` emits a repo-local, OpenClaw-shaped fragment at -`.openclaw/mcp.json` and reports the exact command that registers it: +For the **config compiler path**, MCP is deliberately not automatic. OpenClaw's server +registry is `mcp.servers` in the user's global `~/.openclaw/openclaw.json`; Forge never +writes to another tool's global config. Instead `forge sync` emits a repo-local, +OpenClaw-shaped fragment at `.openclaw/mcp.json` and reports the exact command that +registers it: ```bash openclaw mcp add forge-cortex --command forge --arg cortex-mcp openclaw mcp doctor forge-cortex --probe # prove it starts and lists tools ``` -Forge installs **nothing** into OpenClaw's hook system. On OpenClaw the substrate reaches -the model through `AGENTS.md` text and the `forge-cortex` MCP tools — there are no ambient -pre-action guards the way there are on Claude Code. +There is also a separate **bundle installation path**. The published package already ships +`.codex-plugin/plugin.json`, `global/tools`, and `.mcp.json`; OpenClaw auto-detects that +layout as a Codex bundle. Installing a trusted local directory or packed archive through +`openclaw plugins install` loads Forge's skills and bundle-scoped `forge-cortex` MCP server, +so the manual global registration above is unnecessary for that installation. This does not +turn Forge's Claude `hooks/hooks.json` automation into OpenClaw guards: only OpenClaw-style +hook packs execute. Forge therefore provides no ambient pre-action guard on OpenClaw. ## Repo layout — one tree, three front doors diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b69b7..3b4444e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). is an ordinary managed MCP target: idempotent, per-target ownership (a same-name server you wrote yourself is preserved until `--adopt`), and reversible via `forge integrations remove`. `openclaw` is now selectable and auto-detected by - `forge tools`. Forge installs **nothing** into OpenClaw's hook system — there are no - ambient guards there, only `AGENTS.md` text and the MCP tools. + `forge tools`. The packaged `.codex-plugin/plugin.json`, `global/tools`, and `.mcp.json` + also form an OpenClaw-compatible Codex bundle: installing a trusted checkout or packed + archive loads Forge's skills and bundle-scoped MCP server without the config-only path's + manual global registration. Forge installs **nothing** into OpenClaw's hook system — there + are no ambient guards there, only `AGENTS.md`/skill text and the MCP tools. ### Changed diff --git a/README.md b/README.md index e5712f4..b94df39 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,9 @@ delivers them into every tool you use. > - **Claude Code is the deepest-tested integration** (full plugin, ambient `UserPromptSubmit` > guards). The other nine tools receive native config plus MCP tools, but have had less > real-world exercise. On OpenClaw specifically, rules arrive via `AGENTS.md` project -> context and the MCP registration is a one-command manual step — there are no ambient -> hooks (see +> context; the config-only path uses a one-command MCP registration, while installing the +> package as a compatible Codex bundle loads its skills and bundle-scoped MCP server. +> Neither path provides ambient hooks (see > [OpenClaw in ARCHITECTURE](ARCHITECTURE.md#openclaw-what-is-automatic-and-what-is-not)). > - **Impact/blast-radius analysis is heuristic** — a regex-approximate, conservative code > graph, not a sound call graph. Treat its output as advisory. diff --git a/docs/GUIDE.md b/docs/GUIDE.md index b6fb454..606a21a 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -1298,7 +1298,7 @@ emitted `.mcp.json`): Forge never pretends it can force a hook into a tool that has none — **ambient on Claude Code, agent-invoked everywhere else.** -### OpenClaw — rules automatic, MCP one command +### OpenClaw — emitted config or installable bundle OpenClaw appends the execution folder's `AGENTS.md` after its configured agent-workspace files as project context, so `forge sync` needs no OpenClaw-specific instruction file: @@ -1306,10 +1306,10 @@ the canonical rules arrive on their own. Only `AGENTS.md` travels that way — O not read `SOUL.md`, `IDENTITY.md`, `USER.md`, `MEMORY.md` or `BOOTSTRAP.md` from the execution folder — so keep anything OpenClaw must see inside the canonical body. -The MCP server is **not** wired automatically, on purpose. OpenClaw keeps its registry in -your global `~/.openclaw/openclaw.json` under `mcp.servers`, and Forge does not write to -another tool's global config. Instead `forge sync` emits the OpenClaw-shaped definition to -`.openclaw/mcp.json`: +When you use only `forge sync`, the MCP server is **not** wired automatically, on purpose. +OpenClaw keeps its registry in your global `~/.openclaw/openclaw.json` under `mcp.servers`, +and Forge does not write to another tool's global config. Instead `forge sync` emits the +OpenClaw-shaped definition to `.openclaw/mcp.json`: ```json { "mcp": { "servers": { "forge-cortex": { "command": "forge", "args": ["cortex-mcp"] } } } } @@ -1328,8 +1328,23 @@ same-name server you wrote yourself is preserved (claim it with the add there exactly as it does for every other tool. Removing the entry from the file does **not** unregister it inside OpenClaw — use `openclaw mcp unset forge-cortex` for that. -Forge installs nothing into OpenClaw's hook system: on OpenClaw the substrate is -`AGENTS.md` text plus the MCP tools above, with no ambient pre-action guard. +Alternatively, install ForgeKit itself as a compatible bundle. The npm package includes its +Codex bundle manifest, skills, and MCP definition; OpenClaw maps those to native skills and +an embedded bundle-scoped MCP server: + +```bash +# Local development checkout +openclaw plugins install --link . --accept-capabilities + +# Or install a trusted archive produced by `npm pack` +openclaw plugins install ./codewithjuber-forgekit-.tgz --accept-capabilities +``` + +Confirm `Format: bundle`, `Bundle format: codex`, and the `forge-cortex` MCP server with +`openclaw plugins inspect forgekit`. This bundle path does not require the manual global +`openclaw mcp add` step above. It also does not activate Forge's Claude +`hooks/hooks.json`: OpenClaw executes only OpenClaw-style hook packs, so Forge still has no +ambient pre-action guard there. --- diff --git a/test/openclaw.test.js b/test/openclaw.test.js index 1da4346..8446671 100644 --- a/test/openclaw.test.js +++ b/test/openclaw.test.js @@ -5,7 +5,8 @@ // guess at some other tool's schema; // 3. it is idempotent, non-destructive, and reversible through the same // integrations add/remove path every other target uses; -// 4. nothing forge emits for OpenClaw carries a secret, and nothing forge writes ever +// 4. the published package remains an OpenClaw-compatible Codex bundle (skills + MCP); +// 5. nothing forge emits for OpenClaw carries a secret, and nothing forge writes ever // lands outside the target repo (the user's ~/.openclaw is off limits). import assert from "node:assert/strict"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; @@ -21,6 +22,32 @@ import { sync } from "../src/sync.js"; const fixture = () => mkdtempSync(join(tmpdir(), "forge-openclaw-")); const readTarget = (root) => JSON.parse(readFileSync(join(root, OPENCLAW_TARGET), "utf8")); +// -------------------------------------------------------------------------- +// Package bundle: OpenClaw auto-detects the existing Codex layout. +// -------------------------------------------------------------------------- + +test("the published package carries an OpenClaw-compatible Codex bundle", () => { + const root = process.cwd(); + const manifest = JSON.parse(readFileSync(join(root, ".codex-plugin", "plugin.json"), "utf8")); + const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + const mcp = JSON.parse(readFileSync(join(root, manifest.mcpServers), "utf8")); + + assert.equal(manifest.name, "forgekit"); + assert.equal(manifest.skills, "global/tools"); + assert.equal(manifest.mcpServers, ".mcp.json"); + assert.ok(existsSync(join(root, manifest.skills, "cognitive-substrate", "SKILL.md"))); + assert.deepEqual(mcp.mcpServers["forge-cortex"], { + command: "forge", + args: ["cortex-mcp"], + }); + + // OpenClaw installs the packed archive, not this checkout. Keep every bundle root in + // npm's allowlist so packaging cannot silently drop an otherwise valid bundle. + for (const path of [".codex-plugin", ".mcp.json", "global"]) { + assert.ok(packageJson.files.includes(path), `${path} must be included in npm packages`); + } +}); + // -------------------------------------------------------------------------- // Instructions: AGENTS.md, and only AGENTS.md. // -------------------------------------------------------------------------- From fe442bd4632531c253e6d33a316582707c701ecf Mon Sep 17 00:00:00 2001 From: Juber Shaikh <40266375+CodeWithJuber@users.noreply.github.com> Date: Sat, 5 Sep 2026 05:13:18 +0200 Subject: [PATCH 3/3] fix(hooks): launch bash guards portably on Windows --- .claude/settings.json | 111 +++++++++-- ARCHITECTURE.md | 8 +- CHANGELOG.md | 24 +++ ONBOARDING.md | 2 +- README.md | 3 +- biome.json | 2 +- docs/GUIDE.md | 18 +- global/guards/protect-paths.sh | 6 +- global/guards/run.mjs | 153 +++++++++++++++ global/settings.template.json | 126 +++++++++---- hooks/hooks.json | 104 ++++++++--- scripts/smoke-install.sh | 42 ++++- src/docs_check.js | 13 +- src/doctor.js | 42 ++++- src/init.js | 115 ++++++++++-- test/doctor_launcher.test.js | 61 ++++++ test/dogfood.test.js | 28 ++- test/hook_launcher.test.js | 332 +++++++++++++++++++++++++++++++++ test/hook_manifests.test.js | 73 ++++++++ test/init.test.js | 116 ++++++++++-- tsconfig.json | 2 +- 21 files changed, 1242 insertions(+), 139 deletions(-) create mode 100644 global/guards/run.mjs create mode 100644 test/doctor_launcher.test.js create mode 100644 test/hook_launcher.test.js create mode 100644 test/hook_manifests.test.js diff --git a/.claude/settings.json b/.claude/settings.json index c0dfef0..705b93f 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,8 +1,12 @@ { - "//": "forgekit dogfoods its own plugin during local dev. These hooks mirror hooks/hooks.json but resolve through ${CLAUDE_PROJECT_DIR} (this repo) instead of ${CLAUDE_PLUGIN_ROOT}, so the guards run without a marketplace install. Every guard is advisory/fail-open; the completion gate honors FORGE_STOPGATE=0.", + "//": "forgekit dogfoods its own plugin during local dev. These hooks mirror hooks/hooks.json but resolve through ${CLAUDE_PROJECT_DIR} (this repo) instead of ${CLAUDE_PLUGIN_ROOT}, so the guards run without a marketplace install. Exec form via global/guards/run.mjs: node finds bash (Git Bash on Windows, where bash is usually NOT on PATH) and passes stdin/stdout/exit code through untouched. Every guard is advisory/fail-open; the completion gate honors FORGE_STOPGATE=0.", "statusLine": { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR}\"/global/statusline.sh" + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/global/statusline.sh" + ] }, "hooks": { "SessionStart": [ @@ -11,15 +15,28 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR}\"/global/guards/recall-load.sh" + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/global/guards/recall-load.sh" + ] }, { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR}\"/global/guards/cortex.sh session-start" + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/global/guards/cortex.sh", + "session-start" + ] }, { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR}\"/.claude/hooks/session-start.sh" + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/.claude/hooks/session-start.sh" + ] } ] } @@ -29,11 +46,21 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR}\"/global/guards/cortex.sh prompt" + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/global/guards/cortex.sh", + "prompt" + ] }, { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR}\"/global/guards/cortex.sh preflight" + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/global/guards/cortex.sh", + "preflight" + ] } ] } @@ -44,15 +71,27 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR}\"/global/guards/protect-paths.sh" + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/global/guards/protect-paths.sh" + ] }, { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR}\"/global/guards/cost-budget.sh" + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/global/guards/cost-budget.sh" + ] }, { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR}\"/global/guards/doom-loop.sh" + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/global/guards/doom-loop.sh" + ] } ] }, @@ -61,7 +100,12 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR}\"/global/guards/cortex.sh pre-edit" + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/global/guards/cortex.sh", + "pre-edit" + ] } ] } @@ -72,11 +116,20 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR}\"/global/guards/format-on-edit.sh" + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/global/guards/format-on-edit.sh" + ] }, { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR}\"/global/guards/cortex.sh capture" + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/global/guards/cortex.sh", + "capture" + ] } ] }, @@ -85,11 +138,20 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR}\"/global/guards/secret-redact.sh" + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/global/guards/secret-redact.sh" + ] }, { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR}\"/global/guards/cortex.sh capture" + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/global/guards/cortex.sh", + "capture" + ] } ] } @@ -99,15 +161,28 @@ "hooks": [ { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR}\"/global/guards/completion-gate.sh" + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/global/guards/completion-gate.sh" + ] }, { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR}\"/global/guards/lean-guard.sh" + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/global/guards/lean-guard.sh" + ] }, { "type": "command", - "command": "bash \"${CLAUDE_PROJECT_DIR}\"/global/guards/cortex.sh stop" + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/global/guards/run.mjs", + "${CLAUDE_PROJECT_DIR}/global/guards/cortex.sh", + "stop" + ] } ] } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8dde0b8..f583f0a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -581,20 +581,22 @@ from the tree it describes. ```mermaid %%{init: {'theme':'base','themeVariables':{'primaryColor':'#201a15','primaryTextColor':'#f2ede7','primaryBorderColor':'#372c22','lineColor':'#f26430','secondaryColor':'#272019','tertiaryColor':'#171310','edgeLabelBackground':'#201a15','clusterBkg':'#171310','clusterBorder':'#4a3b2e','fontFamily':'ui-sans-serif, system-ui, sans-serif','fontSize':'14px'},'flowchart':{'curve':'basis','padding':10,'nodeSpacing':36,'rankSpacing':44}}}%% flowchart LR - test["test
103 files"] + test["test
106 files"] src["src
97 files"] landing["landing
61 files"] research["research
35 files"] + global["global
3 files"] bench["bench
2 files"] - global["global
2 files"] scripts["scripts
2 files"] docs["docs
1 file"] examples["examples
1 file"] - test -- 202 --> src + test -- 206 --> src bench -- 7 --> src examples -- 4 --> src test -- 2 --> scripts scripts --> src + src --> global test --> bench + test --> global ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b4444e..5b6f09e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,30 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). on write, and refuses to restructure a file where any step already holds a non-object — that shape is the user's and is reported, never rewritten. +### Fixed + +- **Claude Code hooks no longer fail on Windows with `spawn bash ENOENT`.** Every Forge hook + (the plugin's `hooks/hooks.json`, the `settings.template.json` that `forge init` merges, the + statusline) was exec form with `command: "bash"`. Exec-form hooks are spawned directly — no + shell, a plain `PATH` lookup — and a default Git for Windows install puts `git.exe` on `PATH` + (`Git\cmd`) but not `bash.exe` (`Git\bin`, `Git\usr\bin`), so SessionStart and every other + guard died before it ran. Hooks now spawn the zero-dependency launcher + `global/guards/run.mjs` (`node run.mjs .sh …`), which resolves bash — `FORGE_BASH`, + `CLAUDE_CODE_GIT_BASH_PATH`, the Git install that owns `git` on `PATH`, the standard install + dirs, then `PATH` (never WSL's System32 `bash.exe`) — and passes stdin, stdout and the exit + code through verbatim, so exit-2 blocks are unchanged. POSIX behaviour is identical (`bash` + from `PATH`). `forge init` heals a Forge-owned install left in the old `bash` spelling in + place — ownership manifest included, so uninstall still reverses it — while a hand-written + hook at a Forge path is left alone; `forge doctor` shows the resolved bash, flags stale hooks + (`--fix` re-merges) and requires the launcher as an install asset. Regression tests cover the + Windows default-install `PATH` shape, paths with spaces on both OSes, the no-bash failure + mode (exit 1 + hint, never a fabricated block) and the packed archive. +- **`protect-paths` no longer dies (exit 1, fail-open) on machines without `jq`.** Its grep + fallback ran under `set -euo pipefail`, so a payload missing `command` (every Write/Edit) or + `file_path` (every Bash call) aborted the guard before it could decide — invisible in CI, where + `jq` is preinstalled, but the norm on Windows. The fallback now yields an empty field exactly + like the `jq` branch, so `.env` writes and destructive `rm` are blocked without `jq`. + ## [0.32.1] - 2026-08-22 ### Fixed diff --git a/ONBOARDING.md b/ONBOARDING.md index 59e372b..384d378 100644 --- a/ONBOARDING.md +++ b/ONBOARDING.md @@ -185,7 +185,7 @@ Forge would rather ship an honest subset with a clear boundary than a vague clai - **Add a rule** → a bullet in `source/rules.json`, then `forge sync`. - **Add a tool (skill)** → `global/tools//SKILL.md` with `name` + `description` frontmatter. -- **Add a guard** → `global/guards/.sh` (source `_guardlib.sh` for fields + the lock), then wire it in `global/settings.template.json` and `hooks/hooks.json`. +- **Add a guard** → `global/guards/.sh` (source `_guardlib.sh` for fields + the lock), then wire it in `global/settings.template.json` and `hooks/hooks.json` as `node …/guards/run.mjs …/guards/.sh [mode]` — through the portable launcher (bash is not on `PATH` on Windows), never a bare `bash`. - **Rebrand** → edit `brand.json` (+ `package.json` bin, `.claude-plugin/plugin.json` name). Every command with worked examples and the full extension guide live in diff --git a/README.md b/README.md index b94df39..1bec4a4 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,8 @@ delivers them into every tool you use. > carry evidence references; confidence moves only when independent oracles (tests, CI, a > human) raise it. There is no theorem-prover in the loop. > - Some integrations shell out — `forge harden`, `forge scan`, and the git-native ledger -> assume **Bash, Git, and (for a few paths) `jq`** are available. +> assume **Bash, Git, and (for a few paths) `jq`** are available. Claude hooks on Windows do +> not require `bash` on `PATH`: their Node launcher finds Git Bash and preserves guard exits. ## Start in 60 seconds diff --git a/biome.json b/biome.json index 1a74a63..90c726b 100644 --- a/biome.json +++ b/biome.json @@ -3,7 +3,7 @@ "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "ignoreUnknown": true, - "includes": ["src/**", "test/**", "scripts/**", "bench/**"] + "includes": ["src/**", "test/**", "scripts/**", "bench/**", "global/guards/run.mjs"] }, "formatter": { "enabled": true, diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 606a21a..133ff44 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -1209,6 +1209,17 @@ Forge substrate — pre-action advisory (advisory, never blocks): Nothing to wire — the plugin's [`hooks/hooks.json`](../hooks/hooks.json) installs the `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` guards for you. +**Windows.** Hooks are exec form (`command` + `args`), which Claude Code spawns directly with a +plain `PATH` lookup and no shell — and a default Git for Windows install puts `git` on `PATH` +but **not** `bash`. Every hook therefore runs through the portable launcher +`global/guards/run.mjs` (`node run.mjs .sh [mode]`): `node` is always present, and the +launcher finds Git Bash itself — `FORGE_BASH`, then `CLAUDE_CODE_GIT_BASH_PATH`, then the Git +install that owns the `git` on `PATH`, the standard install dirs, and finally a `bash.exe` on +`PATH` (never WSL's System32 launcher) — and passes stdin, stdout and the exit code through +untouched, so a guard's exit 2 still blocks. `forge doctor` shows which bash it resolved and +flags hooks left in the old `bash …` spelling; `forge doctor --fix` (or `forge init`) heals +Forge-owned ones in place. On macOS/Linux nothing changes: `bash` from `PATH`, as before. + Three more ambient layers ride the same hooks: **Session rehydration (SessionStart).** Besides lessons and the anchored goal, every @@ -1402,8 +1413,9 @@ picked up by the plugin and by `forge catalog`. Create `global/guards/.sh` (source `_guardlib.sh` for the shared fields + the re-entrancy lock), then wire it in `global/settings.template.json` **and** -[`hooks/hooks.json`](../hooks/hooks.json). Guards must be idempotent and fail-safe — -worst case they do nothing. +[`hooks/hooks.json`](../hooks/hooks.json) as `node …/guards/run.mjs …/guards/.sh [mode]` +— through the portable launcher, never a bare `bash` (not on `PATH` on Windows). Guards must +be idempotent and fail-safe — worst case they do nothing. ### Add a crew member (sub-agent) @@ -1474,6 +1486,8 @@ code reads but this table misses fails CI on the forge repo): | `FORGE_LEDGER_ONLY` | Ledger-only is the DEFAULT (the ledger is the sole store). `0` is the escape hatch — restores the legacy `lessons/*.md` + recall/brain file store while external tooling migrates | | `FORGE_EMBED` / `FORGE_EMBED_MODEL` / `FORGE_EMBED_TIMEOUT_MS` | optional embeddings tier (ADR-0005) | | `FORGE_HOME` | override `~/.forge` (recall store location) | +| `FORGE_BASH` | explicit bash for the hook guards (the `global/guards/run.mjs` launcher). Resolution order: this, `CLAUDE_CODE_GIT_BASH_PATH`, then on Windows the Git for Windows install that owns `git` on `PATH`, the standard install dirs, a non-WSL `bash.exe` on `PATH`; POSIX uses `bash` from `PATH` | +| `CLAUDE_CODE_GIT_BASH_PATH` | Claude Code's own Git Bash location — honored by the hook launcher when `FORGE_BASH` is unset | | `FORGE_ROOT` | repo root override for the MCP server | | `FORGE_AUTHOR` | identity stamped on ledger provenance (defaults to git identity) | | `FORGE_COST_CEILING` | daily spend (USD) the cost-budget guard warns at (default 10) | diff --git a/global/guards/protect-paths.sh b/global/guards/protect-paths.sh index 630bb6e..edde9d7 100755 --- a/global/guards/protect-paths.sh +++ b/global/guards/protect-paths.sh @@ -11,9 +11,9 @@ if command -v jq >/dev/null 2>&1; then fpath="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')" cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // empty')" else - tool="$(printf '%s' "$input" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')" - fpath="$(printf '%s' "$input" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')" - cmd="$(printf '%s' "$input" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')" + tool="$(printf '%s' "$input" | grep -o '"tool_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/' || true)" + fpath="$(printf '%s' "$input" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/' || true)" + cmd="$(printf '%s' "$input" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/' || true)" fi deny() { diff --git a/global/guards/run.mjs b/global/guards/run.mjs new file mode 100644 index 0000000..b7dd4dd --- /dev/null +++ b/global/guards/run.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +// Forge hook launcher — runs a bash guard from an exec-form Claude Code hook on every OS. +// +// Why this exists: exec-form hooks (`command` + `args`) are spawned directly — no shell, just a +// plain PATH lookup of `command`. Forge's hooks were `command: "bash"`. On Windows, Git for Windows +// puts `git.exe` on PATH (`Git\cmd`) but NOT `bash.exe` (`Git\bin`, `Git\usr\bin`), so every hook +// — SessionStart first — died with `spawn bash ENOENT` before a guard ever ran. `node` IS a real +// executable on PATH wherever this package is installed, so hooks now spawn +// `node run.mjs [args…]` and THIS file finds bash: $FORGE_BASH, Claude Code's own +// $CLAUDE_CODE_GIT_BASH_PATH, the Git for Windows install that owns the `git` on PATH, the standard +// install dirs, then PATH itself — skipping the WSL launcher in System32, which is not Git Bash. +// POSIX is unchanged: `bash` from PATH, exactly as before. +// +// It must never weaken a guard: stdin (the hook JSON), stdout (SessionStart context, +// `updatedToolOutput`) and stderr are inherited untouched, and bash's exit code is returned +// verbatim — a guard's exit 2 still blocks. With no bash anywhere it prints ONE actionable line and +// exits 1: the same visible, non-blocking hook error the ENOENT was, minus the mystery. Node +// built-ins only — a launcher that itself failed to load would be exactly the silent no-op the +// guards exist to prevent. +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { basename, win32 } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const NO_BASH_HINT = + "no bash found to run the hook guards — install Git for Windows (bash ships with it) or point " + + "FORGE_BASH (or CLAUDE_CODE_GIT_BASH_PATH) at your bash executable"; + +/** + * Windows dirs that ship a `bash.exe` which is NOT Git Bash (the WSL launcher in System32). + * @param {string} dir + * @param {NodeJS.ProcessEnv} env + */ +function isSystemDir(dir, env) { + /** @param {string} p */ + const norm = (p) => String(p).toLowerCase().replaceAll("/", "\\").replace(/\\+$/, ""); + const d = norm(dir); + return [env.SystemRoot, env.windir, "C:\\Windows"] + .filter(Boolean) + .some((r) => d === norm(r) || d.startsWith(`${norm(r)}\\`)); +} + +/** + * Locate the bash that runs the guards. `path` is what to spawn (`null` when nothing usable + * exists); `via` says how it was found (doctor prints it). Pure — env, platform and the + * filesystem probe are injectable, so the Windows logic is unit-tested on every OS. + * @param {{env?: NodeJS.ProcessEnv, platform?: string, exists?: (p: string) => boolean}} [opts] + * @returns {{ path: string | null, via: string }} + */ +export function resolveBash({ + env = process.env, + platform = process.platform, + exists = existsSync, +} = {}) { + // 1. Explicit overrides win on every OS: FORGE_BASH (ours), then CLAUDE_CODE_GIT_BASH_PATH — + // Claude Code's own setting for the same problem (portable Git, MSYS2, a custom prefix). + /** @type {[string, string | undefined][]} */ + const overrides = [ + ["FORGE_BASH", env.FORGE_BASH], + ["CLAUDE_CODE_GIT_BASH_PATH", env.CLAUDE_CODE_GIT_BASH_PATH], + ]; + for (const [via, p] of overrides) if (p && exists(p)) return { path: p, via }; + // 2. POSIX: bash from PATH, exactly as the hooks always did (spawn reports ENOENT if absent). + if (platform !== "win32") return { path: "bash", via: "PATH" }; + // 3. Windows — Windows path semantics regardless of the host running this logic (tests). + const P = win32; + const pathDirs = String(env.PATH ?? env.Path ?? "") + .split(P.delimiter) + .filter(Boolean); + const seen = new Set(); + /** @type {string[]} */ + const candidates = []; + /** @param {string} p */ + const add = (p) => { + const k = p.toLowerCase(); + if (!seen.has(k)) { + seen.add(k); + candidates.push(p); + } + }; + // 3a. The Git for Windows install that owns the `git` on PATH: a default install exposes + // `Git\cmd\git.exe` only; its siblings `Git\bin\bash.exe` / `Git\usr\bin\bash.exe` are the + // bash the guards need. Also covers `Git\bin` or `Git\mingw64\bin` being the PATH entry. + for (const dir of pathDirs) { + if (isSystemDir(dir, env) || !exists(P.join(dir, "git.exe"))) continue; + const root = P.dirname(dir); + for (const r of [root, P.dirname(root)]) { + add(P.join(r, "bin", "bash.exe")); + add(P.join(r, "usr", "bin", "bash.exe")); + } + } + // 3b. Standard install dirs: system-wide, per-user, scoop. + for (const base of [env.ProgramFiles, env.ProgramW6432, env["ProgramFiles(x86)"]]) { + if (base) add(P.join(base, "Git", "bin", "bash.exe")); + } + if (env.LOCALAPPDATA) add(P.join(env.LOCALAPPDATA, "Programs", "Git", "bin", "bash.exe")); + if (env.USERPROFILE) + add(P.join(env.USERPROFILE, "scoop", "apps", "git", "current", "bin", "bash.exe")); + for (const p of candidates) if (exists(p)) return { path: p, via: "git-for-windows" }; + // 3c. A bash.exe on PATH — but never the WSL launcher in System32. + for (const dir of pathDirs) { + if (isSystemDir(dir, env)) continue; + const p = P.join(dir, "bash.exe"); + if (exists(p)) return { path: p, via: "PATH" }; + } + return { path: null, via: "none" }; +} + +/** + * `${CLAUDE_PLUGIN_ROOT}` is substituted as a native `C:\…` string on Windows; Git Bash's + * `dirname`/`cd` are happiest with forward slashes. No-op on POSIX. + * @param {string} p + */ +const toPosix = (p) => String(p).replaceAll("\\", "/"); + +/** + * Spawn `bash