diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..bf5f91008 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,107 @@ +# Agent Instructions + +This repository is bound to a **DKG context graph** (`dkg-code-project`) used for shared project memory across all AI coding agents working on it. Cursor, Claude Code, and any other MCP-aware agent should follow the same protocol so the graph converges rather than fragments. + +For Cursor-specific session-start guidance the same content lives in [`.cursor/rules/dkg-annotate.mdc`](.cursor/rules/dkg-annotate.mdc) with `alwaysApply: true`. This file is the canonical instructions and is read by Claude Code, Continue, OpenAI Codex CLI, and any other tool that honours `AGENTS.md`. + +## What this graph is + +- **Subgraphs**: `chat`, `tasks`, `decisions`, `code`, `github`, `meta` — each a distinct slice of project memory. +- **Capture hook** at `packages/mcp-dkg/hooks/capture-chat.mjs` writes every chat turn into `chat` and gossips it to all subscribed nodes within ~5s. Wired via `.cursor/hooks.json` and `~/.claude/settings.json`. +- **MCP server** at `packages/mcp-dkg` exposes ~14 read+write+annotation tools to any MCP-aware agent. +- **Project ontology** lives at `meta/project-ontology` — fetch via `dkg_get_ontology`. The formal Turtle/OWL artifact + a markdown agent guide. + +## The annotation protocol + +After **every substantive turn** (anything that reasoned, proposed, examined, or referenced something — basically every turn that wasn't a one-line acknowledgement), call **`dkg_annotate_turn`** exactly once. The shared chat sub-graph is project memory, not a "DKG-relevant search index" — over-eagerness is not a failure mode; under-coverage is. + +**Always pass `forSession`.** The session ID is in the `additionalContext` injected at session start ("Your current session ID: ``"). The tool queues the annotation as a pending entity; the capture hook applies it to your actual turn URI when it writes the next `chat:Turn` for the session. Race-free regardless of timing — works whether you call it during your response composition (before the hook fires) or after. Don't try to predict your own turn URI; it doesn't exist yet at the moment you call this tool. + +Minimum viable annotation: + +```jsonc +dkg_annotate_turn({ + forSession: "", + topics: [<2-3 short topic strings>], // chat:topic literals + mentions: [], // chat:mentions edges +}) +``` + +Add when the turn warrants: + +- `examines` — entities the turn analysed in detail (vs just citing in passing) +- `concludes` — `:Finding` entities the turn produced (claims worth preserving) +- `asks` — `:Question` entities left open +- `proposedDecisions` — sugar over `dkg_propose_decision`; freshly mints a Decision and links via `chat:proposes` +- `proposedTasks` — sugar over `dkg_add_task` +- `comments` — sugar over `dkg_comment` (against any existing entity) +- `vmPublishRequests` — sugar over `dkg_request_vm_publish` (writes a marker; **never** publishes on-chain) + +## Look-before-mint protocol (the convergence rule) + +This is the single most important rule. It's how parallel agents converge on the same URIs instead of fragmenting the graph. + +Before minting any new `urn:dkg::` URI: + +1. Compute the **normalised slug**: lowercase → ASCII-fold → strip stopwords (`the/a/an/of/for/and/or/to/in/on/with`) → hyphenate → ≤60 chars. +2. Call `dkg_search` with the **unnormalised label** (the daemon does its own fuzzy match). +3. If any returned entity's normalised slug matches yours → **REUSE** that URI. +4. Otherwise mint `urn:dkg::` per the patterns below. + +**Never fabricate URIs** for entities you didn't discover via `dkg_search`. If unsure, prefer minting fresh and let humans (or the future `dkg_propose_same_as` reconciliation flow) merge duplicates via `owl:sameAs`. + +## URI patterns + +``` +urn:dkg:concept: free-text concept (skos:Concept) +urn:dkg:topic: broad topical bucket +urn:dkg:question: open question +urn:dkg:finding: preserved claim/observation +urn:dkg:decision: architectural decision (coding-project) +urn:dkg:task: work item (coding-project) +urn:dkg:agent: agent identity (usually -) +urn:dkg:github:repo:/ GitHub repository +urn:dkg:github:pr:// +urn:dkg:code:file:/ +urn:dkg:code:package: +``` + +## Tool reference + +Read tools (read-only, no side effects): + +- `dkg_list_projects` — list every CG this node knows about +- `dkg_list_subgraphs` — show counts per sub-graph in a project +- `dkg_sparql` — arbitrary SELECT/CONSTRUCT/ASK; layer ∈ {wm, swm, union, vm} +- `dkg_get_entity` — describe one entity + 1-hop neighbourhood +- `dkg_search` — keyword search across labels + body text (use this in look-before-mint) +- `dkg_list_activity` — recent activity feed (decisions, tasks, turns) with attribution +- `dkg_get_agent` — agent profile + authored counts +- `dkg_get_chat` — captured turns filterable by session/agent/keyword/time +- `dkg_get_ontology` — the project's ontology + agent guide (call once per session) + +Write tools (auto-promoted to SWM; humans gate VM): + +- `dkg_annotate_turn` — **the main per-turn surface**; batches everything below +- `dkg_propose_decision`, `dkg_add_task`, `dkg_comment`, `dkg_request_vm_publish`, `dkg_set_session_privacy` — the underlying primitives, available standalone for explicit "file a decision" / "open a task" requests + +## Things to NOT do + +- **Don't fabricate URIs.** Every URI in `mentions` must come from `dkg_search` or be freshly minted via the look-before-mint protocol. +- **Don't skip turns to "save tokens".** One annotation call per turn is cheap (~few hundred ms). Coverage wins. +- **Don't publish to VM via MCP.** That's `dkg_request_vm_publish` (marker for human review), not `/api/shared-memory/publish`. The agent is never the gating actor for on-chain commitment. +- **Don't normalise slugs in your `dkg_search` query.** Pass the unnormalised label so the daemon's fuzzy match has the most signal; only normalise when comparing for reuse-vs-mint. + +## Cheat sheet + +``` +After every substantive turn: +1. dkg_search "" → reuse-or-mint URIs +2. dkg_annotate_turn({ + topics: [...], mentions: [...], + examines?, concludes?, asks?, + proposedDecisions?, proposedTasks?, comments? + }) +``` + +That's it. The graph grows; teammates' agents see your work in seconds; humans ratify on-chain when worthwhile. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6c6898ff7..3d1d0eb74 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,7 +11,8 @@ memory, exercises synchronous and asynchronous publish paths, queries the published marker, and reports timings plus failures. The repository ESBench workflow for the same feature stays local to benchmark tooling: it uses a deterministic layered DKG client to measure focused WM, SWM, VM, publish, and -read flows, then renders both the combined report and per-flow HTML pages. +read flows across generated `10kb`, `100kb`, `2mb`, and `200mb` payloads, then +renders both the combined report and per-flow HTML pages. ## Top-Level Components diff --git a/BENCHMARKING.md b/BENCHMARKING.md index 9d1b71ddc..d53a489e9 100644 --- a/BENCHMARKING.md +++ b/BENCHMARKING.md @@ -23,6 +23,10 @@ The ESBench suite measures focused memory-layer flows: - `upload payload to local working memory` - `lift local working memory to shared working memory` +Each flow runs against generated payload sizes of `10kb`, `100kb`, `2mb`, and +`200mb`. The labels use binary units: `1kb = 1024` bytes and `1mb = 1024 * 1024` +bytes. + Add new suites under `bench/**/*.bench.ts` as performance-sensitive paths become obvious. @@ -40,6 +44,12 @@ Run benchmarks and write the raw result to `bench/results/latest.json`: pnpm bench ``` +Run a quick subset while iterating on benchmark wiring: + +```bash +DKG_ESBENCH_PAYLOAD_SIZES=10kb pnpm bench +``` + The root ESBench config is `esbench.config.mjs`. It was verified against ESBench `0.8.1`, whose CLI runs suites with `esbench --config ` and generates reports from saved result files with `esbench report --config `. ## HTML Reports @@ -50,6 +60,10 @@ Generate a benchmark run plus an interactive HTML report: pnpm bench:html ``` +The default run includes the `200mb` generated payload scene and is intentionally +heavy. Use `DKG_ESBENCH_PAYLOAD_SIZES=10kb,100kb` for a faster local smoke run, +or pass one of `10kb`, `100kb`, `2mb`, `200mb` to isolate a single size. + The `bench:html` script sets both `ESBENCH_HTML=1` and `ESBENCH_PUBLISH_ASYNC_GET_HTML=1`, so the combined report and the focused publish/async/get pages are generated by the same ESBench run. @@ -68,7 +82,58 @@ Open the combined HTML file for the full table, or one of the per-flow pages when you only need a single DKG memory/publish/read path. The per-flow pages are generated from the same result object through `ESBENCH_PUBLISH_ASYNC_GET_HTML=1`; they contain benchmark results only and do -not embed local auth tokens or daemon paths. +not embed local auth tokens or daemon paths. The combined report and per-flow +pages include a fixed report navigation bar so the generated HTML files can be +opened directly from disk without losing the link between the pages. + +## CPU Profiles And Flame Graphs + +Generate the ESBench reports plus a V8 CPU profile and generated flame graph: + +```bash +pnpm bench:profile +``` + +Generate the per-flow method trace without CPU profiling: + +```bash +pnpm bench:analysis +``` + +Profile a single large generated payload when investigating the heavy path: + +```bash +DKG_ESBENCH_PAYLOAD_SIZES=200mb pnpm bench:profile +``` + +This writes: + +- `bench/results/profiles/publish-async-get-.esbench.json` +- `bench/results/profiles/publish-async-get-.esbench.html` +- `bench/results/profiles/publish-async-get-.cpuprofile` +- `bench/results/profiles/publish-async-get-.flamegraph.html` +- `bench/results/profiles/method-analysis.latest.html` +- `bench/results/profiles/method-analysis.latest.json` +- `bench/results/profiles/index.html` + +The generated flame graph is a local HTML view built from V8 sampled CPU stacks. +Width represents aggregated sampled CPU time. Use it to find where CPU time is +going inside publish, async publish, get, and memory-layer benchmark runs. +When `bench/results/latest.html` and the focused per-flow pages already exist, +`pnpm bench:profile` updates their navigation bars with `CPU profiles` and +`Method analysis` links. + +The method analysis report is the place to inspect which benchmark-layer +methods were invoked for each flow. It separates setup, measured, validation, +and cleanup phases and reports per-method wall-clock timing with context such as +payload size, root entity, marker, and quad count. Use it alongside the flame +graph: the method analysis explains the awaited DKG-layer sequence, while the +flame graph explains sampled CPU stacks inside the process. + +The raw `.cpuprofile` can also be opened in Chrome or Edge DevTools Performance, +or uploaded locally to Speedscope. CPU profiling adds overhead, so use normal +`pnpm bench` or `pnpm bench:html` output as the timing baseline and use +`pnpm bench:profile` for deeper attribution. ## Baseline Workflow diff --git a/README.md b/README.md index 0c23db225..ca70b53a4 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Pick the on-ramp that matches how you're already working: | You want… | Recipe | More | |---|---|---| -| **DKG V10 as memory for Cursor / Claude Code / Continue / Cline** | [MCP setup](#dkg-v10-as-agent-memory-mcp) | two commands | +| **DKG V10 as memory for Cursor / Claude Code / Claude Desktop / Windsurf / VSCode + Copilot / Cline** | [MCP setup](#dkg-v10-as-agent-memory-mcp) | two commands | | **DKG V10 wired into an OpenClaw agent** | [OpenClaw setup](#openclaw-adapter) | two commands | | **DKG V10 inside an ElizaOS agent** | [ElizaOS adapter](packages/adapter-elizaos/README.md) | adapter README | | **DKG V10 inside a Hermes agent** | [Hermes adapter](packages/adapter-hermes/README.md) | adapter README | @@ -91,64 +91,14 @@ Every on-ramp installs the same `@origintrail-official/dkg` umbrella package, ru ### DKG V10 as agent memory (MCP) -Two commands give Cursor, Claude Code, Continue, or Cline a verifiable shared memory layer: +Two commands wire DKG V10 into MCP-aware clients (Cursor, Claude Code, Claude Desktop, Windsurf, VSCode + GitHub Copilot Chat, Cline): ```bash -npm install -g @origintrail-official/dkg # installs CLI + bundled MCP server -dkg mcp setup # one-shot: init + start + fund + register + verify +npm install -g @origintrail-official/dkg +dkg mcp setup ``` -That's it. The first command installs the `dkg` umbrella CLI; the second runs a one-shot bundled flow that: - -1. Initializes `~/.dkg/config.json` if it doesn't exist (skipped silently when present) -2. Starts the DKG daemon as a background process (skipped if already running) -3. Funds the node's wallets via the testnet faucet (skip with `--no-fund` for CI) -4. Registers the MCP server with each detected client (Cursor, Claude Code) by writing a single canonical entry under `mcpServers.dkg`: - - ```json - { - "mcpServers": { - "dkg": { - "command": "dkg", - "args": ["mcp", "serve"] - } - } - } - ``` - -5. Verifies the daemon is healthy - -No tokens or URLs in the JSON — those live in `~/.dkg/config.yaml` and the daemon-written `~/.dkg/auth.token`. If no client config is detected, run `dkg mcp setup --print-only` to emit the JSON for manual paste. - -**Each step is idempotent and skippable.** Re-running `dkg mcp setup` on an already-set-up box is safe — every step short-circuits when its work is already done. Step-skip flags: `--no-start` (configure only, don't start the daemon), `--no-fund` (skip faucet — CI-friendly), `--no-verify` (skip the post-setup probe), `--dry-run` (preview what would happen), `--force` (refresh every detected client config regardless of state). First-init overrides: `--port `, `--name `. - -**First-run verification.** Restart your client so it discovers the MCP, then ask it: *"What tools does dkg expose?"* The `tools/list` response must include at least `dkg_assertion_create`, `dkg_assertion_write`, and `dkg_memory_search`. Then trigger the [round-trip](#round-trip-write-then-recall) below to prove the wiring works end to end. - -#### Round-trip: write, then recall - -The validated path agents follow when "remember this" actually has to mean *cryptographically anchored, queryable, survives the session*: - -1. **Install** — `npm install -g @origintrail-official/dkg` -2. **Set up** — `dkg mcp setup` (the bundled flow: initializes config, starts the daemon, funds wallets via testnet faucet, registers the MCP with detected clients, verifies daemon health) -3. **Confirm reachable** — `dkg status` returns a PeerId; `curl -s http://127.0.0.1:9200/health` is `200` -4. **Restart your client** — Cursor / Claude Code / Continue / Cline picks up the new MCP entry on next launch -5. **(no manual CG creation)** — `agent-context` is auto-created on first write by the storage layer; the round-trip below assumes it -6. **Write** — agent calls `dkg_assertion_create` with `name: "session-2026-05-04"`, then `dkg_assertion_write` with one or more quads. Both tools are idempotent / additive — re-runs are safe. -7. **Recall** — agent calls `dkg_memory_search` with a keyword from the write. The result includes `contextGraphId`, `layer` (`working-memory`, `shared-working-memory`, or `verified-memory`), and a `trustWeight` per hit; higher-trust layers collapse lower-trust hits for the same entity. The just-written triple comes back from the WM layer. -8. **(Optional) Promote to SWM** — `dkg_assertion_promote` advances the assertion's lifecycle and gossips it to peers subscribed to the same context graph. -9. **(Optional) Publish to VM** — `dkg_shared_memory_publish` finalizes Shared Working Memory on-chain (costs TRAC + gas, clears SWM). For a one-shot fresh-quads-to-VM helper, use `dkg_publish` instead — it writes to SWM and publishes in a single call but skips the WM staging area. - -That round-trip — write → search → optionally promote → optionally finalize — is the canonical pattern across every framework on this page. The MCP tools, OpenClaw adapter, and ElizaOS provider all hit the same daemon endpoints behind the scenes, so memories cross frameworks freely. - -#### Troubleshooting (MCP) - -- **`dkg mcp setup` says "no MCP-aware clients detected"** → install Cursor, Claude Code, Continue, or Cline (or run with `--print-only` to copy the JSON yourself). -- **`dkg mcp` says command not found** → the umbrella CLI isn't on PATH; verify with `which dkg`. `npm i -g @origintrail-official/dkg` does NOT propagate transitive bins, so `dkg-mcp` directly is also unavailable — always go through `dkg mcp serve`. -- **MCP not visible in client** → restart the client; on Cursor verify `~/.cursor/mcp.json` is syntactically valid; on Claude Code run `claude mcp list`. -- **HTTP 401 from MCP tools** → token mismatch. `dkg auth show` returns the expected value; confirm it matches `~/.dkg/auth.token`. On CI / containers / proxied environments where `dkg init` can't run, set the env-var fallbacks documented at `packages/mcp-dkg/src/config.ts`: `DKG_API` (daemon URL), `DKG_TOKEN` (bearer), `DKG_PROJECT` (default context graph), `DKG_AGENT_URI`. A stale exported `DKG_PROJECT` from a prior session can silently mis-route writes — unset it if you switch projects. -- **Daemon unreachable** → `dkg status`; if it errors, `dkg logs` and `cat ~/.dkg/daemon.log`. Stale pid → `cat ~/.dkg/daemon.pid` and kill it, then `dkg start` again. -- **Port 9200 already in use** → another node is running. `dkg stop` once, or override via `dkg init` and pick a different API port. -- **WSL2: daemon dies when the terminal closes** → wrap in `tmux` or install as a systemd user service. See the [WSL2 section in JOIN_TESTNET.md](docs/setup/JOIN_TESTNET.md) for the systemd unit file. +`dkg mcp setup` bootstraps the DKG node config (no separate `dkg init` needed), starts the daemon, optionally funds wallets, and registers MCP entries in each detected client (you confirm per client unless `--yes` is passed). See the [MCP integration guide](packages/mcp-dkg/README.md) for client-by-client paths, mode overrides (`--installed` / `--monorepo`), the manual JSON shape, the contributor monorepo dev workflow, and troubleshooting (including the WSL2 caveat for Windows-side MCP clients). ### OpenClaw adapter @@ -286,7 +236,7 @@ dkg auth status # show whether auth is enabled # Framework adapters & MCP wiring dkg openclaw setup # install & configure the OpenClaw adapter dkg hermes setup # install & configure the Hermes adapter -dkg mcp setup # register the MCP server with Cursor / Claude Code / Continue / Cline +dkg mcp setup # register the MCP server with Cursor / Claude Code / Claude Desktop / Windsurf / VSCode + Copilot / Cline dkg mcp serve # run the MCP server on stdio (invoked by the client; not run manually) # Community integrations (registry: OriginTrail/dkg-integrations) @@ -327,7 +277,7 @@ Use adapters for OpenClaw, ElizaOS, Hermes, or your own Node.js / TypeScript pro | Guide | Use it when | |---|---| -| [DKG V10 as agent memory (MCP)](#dkg-v10-as-agent-memory-mcp) | You want Cursor / Claude Code / Continue / Cline to use DKG as memory | +| [DKG V10 as agent memory (MCP)](#dkg-v10-as-agent-memory-mcp) | You want Cursor / Claude Code / Claude Desktop / Windsurf / VSCode + Copilot / Cline to use DKG as memory | | [`packages/mcp-dkg/README.md`](packages/mcp-dkg/README.md) | You want the full MCP tool surface and config reference | | [Join the Testnet](docs/setup/JOIN_TESTNET.md) | You want a full node setup and first publish/query flow | | [OpenClaw Setup](docs/setup/SETUP_OPENCLAW.md) | You want OpenClaw to use DKG as memory/tools | diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md index 060372180..964396ba1 100644 --- a/RELEASE_PROCESS.md +++ b/RELEASE_PROCESS.md @@ -36,7 +36,7 @@ Current process keeps these aligned: - `package.json` - `packages/cli/package.json` - `packages/evm-module/package.json` -- `packages/mcp-dkg/package.json` +- `packages/mcp-server/package.json` ## 4) Pre-release tagging workflow diff --git a/docs/PHASE2_ARCHITECTURE_PLAN.md b/docs/PHASE2_ARCHITECTURE_PLAN.md index 2324a8a65..dff141ba2 100644 --- a/docs/PHASE2_ARCHITECTURE_PLAN.md +++ b/docs/PHASE2_ARCHITECTURE_PLAN.md @@ -112,13 +112,13 @@ Acceptance criteria per route module: - Same wire format and status codes (snapshot the full `daemon.ts` behaviour with a CDC test before splitting; the existing playwright + node-ui tests should keep passing). - Auth resolution (`requestAgentAddress`) is performed by `http/auth.ts`, not the route module. - Phase events (`tracker.start/startPhase/completePhase/complete`) stay at the route boundary so the journal contract doesn't change. -- **Every existing legacy path stays wired.** The refactor is a pure file move, not an API break. Before merging any route split, grep the monorepo for the route string and confirm in-repo clients (`packages/mcp-dkg`, `packages/node-ui`) resolve against the new location. (The historical `mcp-server` package was a third in-repo client at plan-authoring time; it was removed in the V10 keeper consolidation 2026-05-04 — see the `pre-v10-tool-drop` tag.) The known legacy aliases that must survive the move (verified against `packages/cli/src/daemon.ts` at the time of writing) are: +- **Every existing legacy path stays wired.** The refactor is a pure file move, not an API break. Before merging any route split, grep the monorepo for the route string and confirm in-repo clients (`packages/mcp-server`, `packages/mcp-dkg`, `packages/node-ui`) resolve against the new location. The known legacy aliases that must survive the move (verified against `packages/cli/src/daemon.ts` at the time of writing) are: - `/api/subscribe` → V10 `/api/context-graph/subscribe` - `/api/paranet/create | list | rename | exists` → V10 `/api/context-graph/*` (see the paranet caveat below; `paranet/create` is a narrower legacy shim, not a pure alias) - `/api/workspace/write` → V10 `/api/shared-memory/write` (dual-wired at `daemon.ts:4646-4650`) - `/api/workspace/enshrine` → V10 `/api/shared-memory/publish` (dual-wired at `daemon.ts:4706-4710`) - A route split that omits any of these aliases silently breaks older CLI builds, the historical legacy MCP package (now removed; see `pre-v10-tool-drop` tag), and any user automation that hit the V9 surface. + A route split that omits any of these aliases silently breaks older CLI builds, older `mcp-server` releases, and any user automation that hit the V9 surface. Recommended PR ordering (smallest → largest, each is independently mergeable): @@ -219,7 +219,8 @@ End state: `dkg-agent.ts` ≤ 1.5 kLOC; no sub‑module > 1.2 kLOC. The implemen - `packages/publisher` (phase-sequences + publish/update regression) - `packages/cli` (daemon HTTP behaviour + CLI integration) - `packages/node-ui` (chat-memory, operations view) - - `packages/mcp-dkg` (MCP tool schema + integration — the MCP server is an in-repo client of `/api/query`, `/api/shared-memory/write`, `/api/shared-memory/publish`, `/api/context-graph/list`, and `/api/context-graph/create` as wired in its `client.ts`; a route move that breaks any of those calls would otherwise slip through the daemon-only tests. The list must be re‑grepped before any PR that touches routes — if this file falls out of sync with `client.ts`, the verification checklist stops catching MCP‑publish regressions. The historical legacy MCP package was a separate fourth client at plan-authoring time and has since been removed in the V10 keeper consolidation 2026-05-04; see `pre-v10-tool-drop` tag for its original wiring.) + - `packages/mcp-server` (MCP tool schema + integration — the MCP server is an in-repo client of `/api/query`, `/api/shared-memory/write`, `/api/shared-memory/publish`, `/api/context-graph/list`, and `/api/context-graph/create` as wired in `packages/mcp-server/src/connection.ts`; a route move that breaks any of those calls would otherwise slip through the daemon-only tests. The list must be re‑grepped before any PR that touches routes — if this file falls out of sync with `connection.ts`, the verification checklist stops catching MCP‑publish regressions) + - `packages/mcp-dkg` (the DKG-flavoured MCP bundle; same rationale) A repo‑wide `typecheck` script per package is itself a Phase‑2 follow‑up, not a prerequisite. diff --git a/docs/TWO-LAPTOP-DEMO.md b/docs/TWO-LAPTOP-DEMO.md new file mode 100644 index 000000000..95a19ca9e --- /dev/null +++ b/docs/TWO-LAPTOP-DEMO.md @@ -0,0 +1,170 @@ +# Two-laptop coding demo on testnet + +Two laptops, two operators, one project. Both wire Cursor to the same DKG context graph and code together — chat turns, decisions, and tasks are shared via the graph. + +This is a pre-npm walkthrough: today both laptops bootstrap the daemon from a `dkg-v9` checkout on the `feat/cursor-dkg-integration` branch. Once the npm package ships the same flow becomes `npm install -g @origintrail-official/dkg && dkg start`. + +## Prerequisites + +On both laptops: + +- **Node.js 22+** and **pnpm 10+** +- **Cursor** installed +- **Base Sepolia ETH** for the daemon's identity registration. Use the [DKG testnet faucet guide](setup/TESTNET_FAUCET.md). You'll need a few cents worth of testnet ETH per node. + +## 1. Bootstrap (both laptops) + +```bash +git clone https://github.com/OriginTrail/dkg-v9.git +cd dkg-v9 +git checkout feat/cursor-dkg-integration +pnpm install +pnpm build +pnpm dkg init # creates ~/.dkg with default testnet config +pnpm dkg start # starts the daemon +``` + +Watch for the line `Network config: DKG V10 Testnet (genesis v1)` in the log. The daemon is now reachable at `http://localhost:9200`. + +Open `http://localhost:9200/ui` in a browser. You should see the empty Node UI, the operator's identity address in the header, and "0 projects" on the dashboard. + +> Troubleshooting: if `pnpm dkg start` complains about `Insufficient TRAC` or `identity not registered`, you need to fund the agent address shown in `pnpm dkg show` from the faucet, then re-run. + +## 2. Laptop A: create the project + +In Laptop A's Node UI, click **+ Create Project**. Fill in: + +- **Project Name:** `Tic Tac Toe` +- **Description:** `Build a Tic Tac Toe game in TypeScript with React frontend and a minimax AI opponent` +- **Access:** `Curated` (recommended — Laptop A controls who joins) +- **Ontology:** `Choose a starter` → `Coding project` + +Click **Create Project**. The modal walks through: + +1. Registering the CG on Base Sepolia (~10–30s on testnet) +2. Installing the `coding-project` ontology into `meta/project-ontology` +3. Publishing the project manifest into `meta/project-manifest` +4. Transitioning into the **Wire workspace** step + +In the Wire workspace step: + +- **Workspace path:** e.g. `/Users//code/tic-tac-toe` (or whatever absolute path you want; the daemon creates the directory if it doesn't exist) +- **Agent slug for this machine:** something descriptive, e.g. `cursor-alice-laptop1` +- **Skip Claude Code wiring:** leave checked unless you actually use Claude Code + +Click **Preview install**. The modal shows the markdown diff: which files will be created, sizes, where the daemon-token reference lands, and the security boundaries that are enforced (path-locked allowlist, no script execution, no tokens in the manifest). Review, then click **Install**. + +You should see something like: + +``` +created /Users/alice/code/tic-tac-toe/.cursor/rules/dkg-annotate.mdc (4,210 bytes) +created /Users/alice/code/tic-tac-toe/.cursor/hooks.json (590 bytes) +created /Users/alice/code/tic-tac-toe/.cursor/mcp.json (310 bytes) +created /Users/alice/code/tic-tac-toe/.dkg/config.yaml (290 bytes) +created /Users/alice/code/tic-tac-toe/AGENTS.md (12,400 bytes) +``` + +Click **Done**. The modal closes and Laptop A's UI shows the new project tab. + +> Verify in the UI: click into the project, then `meta` sub-graph. You should see the manifest entity (`urn:dkg:project:.../manifest`), the ontology entity (`urn:dkg:project:.../ontology`), and the template entities for the Cursor rule, hooks, config, AGENTS.md. + +## 3. Laptop A: plan the project from Cursor + +Open the wired workspace in Cursor: + +```bash +cursor /Users/alice/code/tic-tac-toe +``` + +Start a new chat and prompt: + +> We're building a Tic Tac Toe game per the project description (TypeScript + React + minimax AI). Break it into 5–7 atomic tasks and create each one via `dkg_add_task`. Keep titles short and add a one-sentence description. + +The agent has the `coding-project` ontology and the `dkg_add_task` tool from session-start context. It should produce something like: + +- Set up Vite + React + TypeScript scaffold +- Build a 3x3 grid component with click-to-place +- Implement game-state reducer (current player, winner check, board state) +- Implement minimax AI for the computer player +- Wire up game-start / game-over UI states +- Write unit tests for the win-detection logic +- Polish UI (dark mode, animations, scoreboard) + +Each task creates a `tasks:Task` entity in the project's `tasks` sub-graph and is auto-promoted to SWM (gossipped to all subscribed nodes). + +> Verify: switch back to the Node UI, click into the project, then the `tasks` sub-graph. Each task should be there with its title, description, and a `prov:wasAttributedTo urn:dkg:agent:cursor-alice-laptop1` attribution. + +## 4. Laptop A: share the invite + +In the Node UI, open the project tab and click **Share Project**. Copy the invite code (it looks like `did:dkg:context-graph:0x.../tic-tac-toe` plus a `/ip4/.../p2p/12D3...` multiaddr on a second line) and send it to Laptop B over any channel — Signal, AirDrop, paper, whatever. + +If you chose **Curated** access, you'll receive Laptop B's join request as a notification once they paste the invite (next step). Approve it from the project's Participants view. + +## 5. Laptop B: join + wire + +In Laptop B's Node UI, click **+ Join Project** and paste the invite code. The modal walks through: + +1. Connecting to Laptop A's node (uses the multiaddr from the invite) +2. Subscribing to the project (and, if curated, sending a signed join request — wait for Laptop A to approve) +3. Catching up the project's existing knowledge: ontology, manifest, tasks, decisions, prior chat +4. Transitioning into the **Wire workspace** step + +In the Wire workspace step: + +- **Workspace path:** e.g. `/Users//code/tic-tac-toe` (a fresh local path on this machine) +- **Agent slug:** something distinct from Laptop A, e.g. `cursor-bob-laptop2` +- **Skip Claude Code:** as before + +Preview, install, done. The wired workspace on Laptop B is identical in structure to Laptop A's; only the `agentSlug` and `daemonApiUrl` placeholders differ. + +> Verify: in Laptop B's Node UI, the `tasks` sub-graph for this project shows the same 5–7 tasks Laptop A's agent created. The catchup pulled them across via gossip. + +## 6. Both laptops: code together + +Open the wired workspace in Cursor on each laptop and start a fresh chat. The session-start context the agent receives includes a bucketed plan, e.g.: + +``` +**Open tasks:** +- urn:dkg:tasks:set-up-vite-react — Set up Vite + React + TypeScript scaffold +- urn:dkg:tasks:build-grid-component — Build a 3x3 grid component with click-to-place +- urn:dkg:tasks:implement-game-state-reducer — Implement game-state reducer +- urn:dkg:tasks:implement-minimax-ai — Implement minimax AI for the computer player +- urn:dkg:tasks:wire-game-states — Wire up game-start / game-over UI states +- urn:dkg:tasks:test-win-detection — Write unit tests for the win-detection logic +- urn:dkg:tasks:polish-ui — Polish UI (dark mode, animations, scoreboard) + +**Concepts in scope:** +- urn:dkg:concepts:minimax — Minimax algorithm +``` + +A natural opening prompt on Laptop B: + +> I see we have these tasks. I'll start with `set-up-vite-react`. Use `dkg_annotate_turn` to record what I'm working on, and read any existing decisions on tech stack before scaffolding. + +While Laptop B works on scaffolding, Laptop A could simultaneously pick a different task ("I'll take `implement-minimax-ai`"). Both agents emit `dkg_annotate_turn` calls that record what each turn examined / proposed / concluded. As tasks complete, agents call `dkg_add_task` again with `status: done` (or use a finer-grained mutation tool if you have one). + +Switch back to either Node UI's **Activity feed** to watch chat turns, annotations, decisions, and task updates land in real-time from both laptops. + +## 7. Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `pnpm dkg start` says "identity not registered" | Agent address has no Base Sepolia ETH yet | Fund the address shown in `pnpm dkg show` from the [faucet](setup/TESTNET_FAUCET.md), restart | +| `JoinProjectModal` shows "Access Restricted" | Curated CG, you're not on the allowlist | Click **Send Join Request**, ask the curator (Laptop A) to approve from their Participants view | +| `WireWorkspacePanel` preview fails with "No manifest published" | Curator created the project before this branch landed, or manifest publish failed silently | Curator runs `pnpm exec node scripts/import-manifest.mjs --project=` from a wired workspace | +| Manifest install fails with "existing file is not valid JSON" | Operator already has a `.cursor/mcp.json` from another DKG project pointing at a different agent | Move the existing file aside, re-run install (the safety guard refuses to clobber an unparseable file) | +| Cursor agent doesn't see the tasks on Laptop B | Session-start hook didn't fire, or `.dkg/config.yaml` points at a different CG | Check `.dkg/capture-chat.log`. If empty, the hook isn't being invoked — verify `.cursor/hooks.json` exists and Cursor was restarted after wiring | +| Tasks created on Laptop A don't appear on Laptop B | Catchup completed before the tasks were published, OR libp2p connection dropped | On Laptop B, click **Sync** in the project view to re-pull. Verify both daemons show each other in `pnpm dkg peers` | +| `~/.claude/settings.json` got modified unexpectedly | Operator unchecked "Skip Claude Code wiring" | Restore `~/.claude/settings.json` from a backup; re-wire with skip-claude checked | + +## What's deferred + +These exist as code today but aren't part of the demo path. They become relevant once the project deepens: + +- **`pnpm exec dkg-mcp join `** — the same wire flow as a CLI, useful for headless / CI / sshd setups. Same daemon endpoints, no UI required. +- **`scripts/import-manifest.mjs`** — re-publish a manifest if the templates drift (e.g. the curator updated `AGENTS.md` and wants the new copy to gossip). +- **`dkg-mcp sync`** — drift detection for already-wired workspaces. Will be the recommended way to refresh a workspace when the manifest version changes. + +## Background + +Phase 8 of `feat/cursor-dkg-integration` (PR #224) added the `dkg:ProjectManifest` schema, publish/install helpers, three daemon endpoints (`/api/context-graph/{id}/manifest/{publish|plan-install|install}`), and the `WireWorkspacePanel` shared by both modals. The architecture sketch lives in [packages/mcp-dkg/src/manifest/schema.ts](../packages/mcp-dkg/src/manifest/schema.ts); the security model (path-lock + safety guards) lives in [packages/mcp-dkg/src/manifest/install.ts](../packages/mcp-dkg/src/manifest/install.ts). diff --git a/docs/onboarding/04-package-map.md b/docs/onboarding/04-package-map.md index 45e5d24e0..383aaa2ed 100644 --- a/docs/onboarding/04-package-map.md +++ b/docs/onboarding/04-package-map.md @@ -47,7 +47,7 @@ graph TD subgraph "Adapters & Integrations" elizaos["@origintrail-official/dkg-adapter-elizaos"] openclaw["@origintrail-official/dkg-adapter-openclaw"] - mcp["@origintrail-official/dkg-mcp"] + mcp["@origintrail-official/dkg-mcp-server"] end subgraph "Tooling & UI" @@ -179,12 +179,12 @@ An adapter for the AutoResearch framework that integrates DKG capabilities into **Depends on**: `core`, `agent`. -### @origintrail-official/dkg-mcp -`packages/mcp-dkg/` +### @origintrail-official/dkg-mcp-server +`packages/mcp-server/` -A Model Context Protocol (MCP) server that exposes the DKG to AI coding assistants (Claude Code, Cursor, etc.) through the canonical V10 tool surface (assertion CRUD lifecycle, SPARQL queries, trust-weighted memory search, publish to Verified Memory). Reachable via `dkg mcp serve` (umbrella CLI subcommand) once `@origintrail-official/dkg` is installed. Connects to a running DKG node's HTTP API. +A Model Context Protocol (MCP) server that exposes the DKG code graph to AI coding assistants (Claude Code, Cursor, etc.). Provides tools for finding modules, functions, classes, and packages by keyword, getting file summaries without reading source, and running raw SPARQL queries. Connects to a running DKG node's API. -**Depends on**: `@modelcontextprotocol/sdk`, `zod`, `yaml` (no workspace deps). +**Depends on**: `@modelcontextprotocol/sdk`, `zod` (no workspace deps). --- @@ -241,7 +241,7 @@ Attested Knowledge Assets (AKA) protocol implementation. Provides session manage | Swap the triple store backend | `packages/storage/src/adapters/` | | Build an ElizaOS agent with DKG | `packages/adapter-elizaos/` | | Build an OpenClaw agent with DKG | `packages/adapter-openclaw/` | -| Expose DKG to an AI coding assistant | `packages/mcp-dkg/` | +| Expose DKG to an AI coding assistant | `packages/mcp-server/` | | Add a metric to the node dashboard | `packages/node-ui/` | | Customize knowledge graph rendering | `packages/graph-viz/` | | Understand network behavior visually | `packages/network-sim/` | @@ -264,7 +264,7 @@ Attested Knowledge Assets (AKA) protocol implementation. Provides session manage | `@origintrail-official/dkg-agent` | `core`, `storage`, `chain`, `publisher`, `query` | | `@origintrail-official/dkg-adapter-elizaos` | `core`, `storage`, `agent` | | `@origintrail-official/dkg-adapter-openclaw` | `core`, `storage`, `agent` | -| `@origintrail-official/dkg-mcp` | (none -- connects via HTTP API) | +| `@origintrail-official/dkg-mcp-server` | (none -- connects via HTTP API) | | `@origintrail-official/dkg-graph-viz` | (none -- standalone) | | `@origintrail-official/dkg-network-sim` | (none -- standalone) | | `@origintrail-official/dkg-node-ui` | `core`, `graph-viz` | diff --git a/docs/plans/PLAN_REALTIME_SUBSCRIPTIONS.md b/docs/plans/PLAN_REALTIME_SUBSCRIPTIONS.md index d79924ef3..ace64c1a4 100644 --- a/docs/plans/PLAN_REALTIME_SUBSCRIPTIONS.md +++ b/docs/plans/PLAN_REALTIME_SUBSCRIPTIONS.md @@ -297,7 +297,7 @@ useEffect(() => { ### 3.2 MCP Server — event subscription tool -**File:** `packages/mcp-dkg/src/index.ts` (was the now-removed legacy mcp-server package at plan-authoring time; see `pre-v10-tool-drop` tag for the original wiring) +**File:** `packages/mcp-server/src/index.ts` Add an MCP tool `subscribe_events` that opens an SSE connection and delivers events to the LLM: diff --git a/packages/adapter-autoresearch/README.md b/packages/adapter-autoresearch/README.md index a1f2170c3..b73b67668 100644 --- a/packages/adapter-autoresearch/README.md +++ b/packages/adapter-autoresearch/README.md @@ -64,7 +64,7 @@ Results propagate via GossipSub to all paranet subscribers. Every agent sees eve ### Prerequisites - A running DKG V10 node (`dkg start`) -- The DKG MCP server built (`pnpm --filter @origintrail-official/dkg-mcp build`) +- The DKG MCP server built (`pnpm --filter @origintrail-official/dkg-mcp-server build`) - The adapter built (`pnpm --filter @origintrail-official/dkg-adapter-autoresearch build`) - A clone of [autoresearch](https://github.com/karpathy/autoresearch/) (or a Mac fork — see [Hardware](#hardware)) @@ -76,8 +76,8 @@ Set `DKG_ADAPTERS=autoresearch` when running the MCP server. In your Cursor/IDE { "mcpServers": { "dkg": { - "command": "dkg", - "args": ["mcp", "serve"], + "command": "node", + "args": ["/path/to/dkg/packages/mcp-server/dist/index.js"], "env": { "DKG_ADAPTERS": "autoresearch" } @@ -89,7 +89,7 @@ Set `DKG_ADAPTERS=autoresearch` when running the MCP server. In your Cursor/IDE Or from the command line: ```bash -DKG_ADAPTERS=autoresearch dkg mcp serve +DKG_ADAPTERS=autoresearch node packages/mcp-server/dist/index.js ``` This registers 6 additional MCP tools alongside the core DKG tools. @@ -398,4 +398,4 @@ The DKG adapter is hardware-agnostic — the ontology and tools work with any fo - `@modelcontextprotocol/sdk` — MCP tool registration - `zod` — input schema validation -Loaded as an optional dependency by `@origintrail-official/dkg-mcp`. +Loaded as an optional dependency by `@origintrail-official/dkg-mcp-server`. diff --git a/packages/adapter-openclaw/src/setup.ts b/packages/adapter-openclaw/src/setup.ts index d08559881..0b0ca799f 100644 --- a/packages/adapter-openclaw/src/setup.ts +++ b/packages/adapter-openclaw/src/setup.ts @@ -1612,7 +1612,7 @@ export async function runSetup(options: SetupOptions): Promise { } } catch { /* use pre-merge values */ } } else if (network) { - log(`[dry-run] Would write ~/.dkg/config.json (${network.networkName}, port ${apiPort})`); + log(`[dry-run] Would write ${join(dkgDir(), 'config.json')} (${network.networkName}, port ${apiPort})`); } // Step 4: Preflight ~/.openclaw/openclaw.json BEFORE the daemon spins up diff --git a/packages/cli/README.md b/packages/cli/README.md index ec6bdb283..87c3ec0cc 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -208,7 +208,7 @@ pnpm --filter @origintrail-official/dkg benchmark:publish-async-get -- \ --context-graph-id my-project \ --repeat 30 \ --warmups 3 \ - --payload-size 1024 \ + --payload-size 10kb \ --output-format json ``` @@ -218,6 +218,10 @@ Useful environment variables mirror the flags: `DKG_BENCH_CONTEXT_GRAPH_ID`, `DKG_BENCH_POLL_INTERVAL_MS`, `DKG_API_PORT`, `DKG_API_URL`, and `DKG_AUTH_TOKEN`. +`--payload-size` and `DKG_BENCH_PAYLOAD_SIZE` accept raw bytes or generated-size +labels such as `10kb`, `100kb`, `2mb`, and `200mb`. The repository ESBench suite +uses those four generated sizes by default. + The output includes per-operation timing records and summary rows for `syncPublish`, `asyncEnqueue`, `asyncCompletion`, and `get`. Each summary reports count, success count, failure count, min, max, mean, median/p50, and p95. Failure @@ -228,7 +232,9 @@ The repository-level ESBench workflow for this same benchmark feature is documented in `BENCHMARKING.md`. It uses a deterministic layered DKG client, not a live daemon, so the generated reports avoid auth tokens and local node paths. `pnpm bench:html` writes the combined ESBench report plus one focused HTML page -for each benchmark flow: +for each benchmark flow and payload size. The full default matrix includes the +`200mb` scene; set `DKG_ESBENCH_PAYLOAD_SIZES=10kb` or another comma-separated +subset while doing quick local smoke checks: - get/read retrieval - synchronous publish with finalization diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 16a8f5ade..29ea9297c 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1806,7 +1806,9 @@ mcpCmd .option('--dry-run', 'Preview steps without writing or starting anything') .option('--force', 'Refresh every detected client regardless of current registration state') .option('--print-only', 'Print the canonical JSON to stdout; skip every other step') - .option('--yes', 'Auto-confirm all registrations (default; reserved for future interactive prompts)') + .option('--yes', 'Auto-confirm per-client registrations (default false: prompt interactively in TTY mode; non-TTY auto-confirms — pass `--yes` in scripts for the safer scripted-environment posture)') + .option('--installed', 'Force installed-mode setup. Bootstrap home: `~/.dkg`. Registered binary: the running CLI (whichever invoked this command — typically the global `dkg`). Use this from a monorepo cwd when you want the global install instead of the local dist. Mutually exclusive with --monorepo.') + .option('--monorepo', 'Force monorepo-mode setup. Bootstrap home: `~/.dkg-dev`. Registered binary: the local `/packages/cli/dist/cli.js` script (located via cwd-first walk; falls back to the running CLI dir). Errors if no DKG monorepo root is detected. Switches BOTH bootstrap home AND the registered binary, unlike --installed which only switches the home. Mutually exclusive with --installed.') .action(async (opts) => { // Dynamic-import the openclaw-setup primitives for the bundled // init + daemon-start. Same import surface (and same package @@ -1835,11 +1837,13 @@ mcpCmd try { await mcpSetupAction(opts, { loadNetworkConfig: openclawSetupExports.loadNetworkConfig, - writeDkgConfig: openclawSetupExports.writeDkgConfig, + ensureDkgNodeConfig: coreExports.ensureDkgNodeConfig, startDaemon: openclawSetupExports.startDaemon, readWalletsWithRetry: openclawSetupExports.readWalletsWithRetry, logManualFundingInstructions: openclawSetupExports.logManualFundingInstructions, requestFaucetFunding: coreExports.requestFaucetFunding, + findDkgMonorepoRoot: coreExports.findDkgMonorepoRoot, + resolveDkgConfigHome: coreExports.resolveDkgConfigHome, }); } catch (err: any) { console.error(`\n[dkg mcp setup] ERROR: ${err?.message ?? err}\n`); diff --git a/packages/cli/src/mcp-setup.ts b/packages/cli/src/mcp-setup.ts index b50d0c6c3..b62e74929 100644 --- a/packages/cli/src/mcp-setup.ts +++ b/packages/cli/src/mcp-setup.ts @@ -17,10 +17,28 @@ * probe, stale-PID handling, etc.). * 3. Optionally fund the node's wallets via testnet faucet (mirrors * openclaw-setup's --no-fund posture). - * 4. Detect MCP-aware clients and register the canonical - * `{ command: "dkg", args: ["mcp", "serve"] }` block. State-aware - * (`registered`/`stale`/`not registered`) and fast-exits on no-op - * re-runs. + * 4. Detect MCP-aware clients (`detectClients()`) and register the + * context-aware canonical entry. Detected clients today: Cursor, + * Claude Code, Claude Desktop, Windsurf, VSCode + Copilot Chat, + * and Cline. (Continue + Codex CLI deferred to a follow-up; see + * the phase-4 / phase-5 commit bodies for the defer rationale.) + * State-aware (`registered` / `stale` / `not registered`) per + * client and fast-exits on no-op re-runs. + * + * Context-awareness (phase 2): when invoked from inside a dkg-v9 + * monorepo dev checkout (detected via + * `findDkgMonorepoRoot()` from `@origintrail-official/dkg-core`), + * the canonical entry writes the absolute path to the local CLI + * dist instead of the global `dkg` bin — so a contributor's local + * build runs even when a stale globally-installed `dkg` is on PATH. + * `--installed` / `--monorepo` are mutually-exclusive overrides. + * + * Per-client format / entry-shape dispatch (phase 1): Cursor, Claude + * Code, Claude Desktop, Windsurf, and Cline all use canonical + * `mcpServers.dkg` JSON. VSCode + Copilot Chat keys under + * `servers.dkg` instead. The `format` + `entryPath` fields on + * `ClientTarget` describe each client's contract; `writeRegistration` + * and `classify` dispatch on those without per-client write logic. * * Flags (parity with `dkg openclaw setup` where applicable): * --port Override daemon API port (default 9200). @@ -31,22 +49,40 @@ * --dry-run Preview steps; no filesystem or network writes. * --force Refresh every detected client regardless of state. * --print-only Emit canonical JSON only; skip every other step. - * --yes Auto-confirm (default; reserved for future prompts). + * --yes Auto-confirm registrations (default false: prompt + * per-client interactively in TTY mode; non-TTY auto- + * confirms automatically — CI / scripts work without + * the flag, but passing it explicitly is the safer + * scripted-environment posture). + * --installed Force installed-mode command form even from a + * monorepo cwd (mutually exclusive with --monorepo). + * --monorepo Force monorepo-mode command form (errors if no + * DKG monorepo root locatable; mutually exclusive + * with --installed). * * Tokens and URLs are NOT in the emitted client-config block — the MCP * server reads them from `~/.dkg/config.yaml` + the daemon-written * `auth.token` via `loadConfig` (`packages/mcp-dkg/src/config.ts`). */ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { homedir } from 'node:os'; +import { homedir, platform, release as osRelease } from 'node:os'; +import { execSync } from 'node:child_process'; +import yaml from 'js-yaml'; export interface McpSetupCliOptions { /** Refresh every detected client regardless of current registration state. */ force?: boolean; /** Emit the canonical JSON block to stdout; do not detect clients or write. */ printOnly?: boolean; - /** Auto-confirm registrations (default true; reserved for future interactive prompts). */ + /** + * Auto-confirm per-client registrations (default false). In TTY + * mode without `--yes`, the action prompts per detected client + * before writing. In non-TTY mode (CI, piped input, no controlling + * terminal) the prompt is skipped — non-interactive environments + * auto-confirm so scripts don't hang. Pass `--yes` explicitly in + * scripts for the safer posture. + */ yes?: boolean; /** Override daemon API port (default 9200). Mirrors openclaw-setup. */ port?: string; @@ -60,6 +96,42 @@ export interface McpSetupCliOptions { verify?: boolean; /** Preview without writing or starting anything. Mirrors openclaw-setup. */ dryRun?: boolean; + /** + * Force installed-mode command form even when invoked from inside + * a monorepo dev checkout. Escape hatch for contributors who want + * to test the published-CLI shape from a dev cwd. Mutually + * exclusive with `--monorepo`. + */ + installed?: boolean; + /** + * Force monorepo-mode command form (writes the absolute path to + * the local CLI dist). Errors if no monorepo root can be located. + * Mutually exclusive with `--installed`. + */ + monorepo?: boolean; +} + +/** + * Setup context — drives `canonicalEntry`'s output shape. `'installed'` + * is the default for npm-installed CLIs (writes + * `{ command: "dkg", args: ["mcp", "serve"] }`); `'monorepo'` is the + * contributor-from-dev-checkout case (writes + * `{ command: "node", args: ["/packages/cli/dist/cli.js", + * "mcp", "serve"] }` so the contributor's local-build runs, not a + * stale globally-installed version). + */ +export type SetupContext = 'installed' | 'monorepo'; + +/** + * F31: per-client registration plan item. Lifted to module scope so + * the `confirmPlan` helper can take and return arrays of these + * without re-declaring the shape inside the action body. `Action` + * mirrors the local enum the planning loop produces. + */ +export type PlannedAction = 'register' | 'refresh' | 'skip'; +export interface PlannedItem { + s: ClientState; + action: PlannedAction; } /** @@ -71,31 +143,418 @@ export interface McpSetupCliOptions { */ export interface McpSetupActionDeps { loadNetworkConfig: typeof import('@origintrail-official/dkg-adapter-openclaw').loadNetworkConfig; - writeDkgConfig: typeof import('@origintrail-official/dkg-adapter-openclaw').writeDkgConfig; + /** + * Codex Round-23 Fix 30: agent-agnostic config-write helper from + * `dkg-core`. Pre-fix this dep was `adapter-openclaw`'s + * `writeDkgConfig` wrapper, which ran 3 OpenClaw-specific + * mutations (`migrateLegacyOpenClawTransport`, plus + * `delete existing.openclawAdapter` / `delete existing.openclawChannel`) + * before delegating to this same `ensureDkgNodeConfig`. Those + * mutations are no-ops on MCP-only configs but the dependency + * was architecturally wrong — MCP setup shouldn't reach into + * the OpenClaw adapter for config writes. Calling the agent- + * agnostic helper directly drops the dead OpenClaw baggage from + * the MCP-only setup path. The OpenClaw migrations stay scoped + * to `dkg openclaw setup`'s own `writeDkgConfig` call site. + */ + ensureDkgNodeConfig: typeof import('@origintrail-official/dkg-core').ensureDkgNodeConfig; startDaemon: typeof import('@origintrail-official/dkg-adapter-openclaw').startDaemon; readWalletsWithRetry: typeof import('@origintrail-official/dkg-adapter-openclaw').readWalletsWithRetry; logManualFundingInstructions: typeof import('@origintrail-official/dkg-adapter-openclaw').logManualFundingInstructions; /** Faucet primitive from `@origintrail-official/dkg-core`. */ requestFaucetFunding: typeof import('@origintrail-official/dkg-core').requestFaucetFunding; + /** + * Walks ancestors looking for a DKG monorepo root. Defaulted to the + * dkg-core implementation in production; injectable so tests can + * stub it without touching the real filesystem. + */ + findDkgMonorepoRoot: typeof import('@origintrail-official/dkg-core').findDkgMonorepoRoot; + /** + * Codex Round-2 Bug A: resolve the DKG home directory used by the + * config / daemon / faucet steps below. Defaults to the dkg-core + * implementation in production; injectable so tests can pin a + * deterministic home without depending on `homedir()` or env. When + * mcp-setup detects monorepo context it forwards the signal here + * so the bootstrap state lands in the same `~/.dkg-dev` that the + * registered local CLI dist will read at MCP-client startup time. + */ + resolveDkgConfigHome: typeof import('@origintrail-official/dkg-core').resolveDkgConfigHome; + /** + * F31: per-client interactive confirm hook. Defaulted to the + * production readline-based implementation. Injectable so tests + * can stub deterministic answer streams without managing a real + * TTY. The helper takes the `planned` array and returns a + * possibly-modified copy where declined items are downgraded to + * `'skip'`. + * + * Optional — `mcpSetupAction` falls back to the module-level + * `confirmPlan` when not supplied so existing call sites keep + * working unchanged. + */ + confirmPlan?: ( + planned: readonly PlannedItem[], + opts: { yes: boolean }, + ) => Promise; } /** - * The canonical MCP-server entry written into client config files. Single - * source of truth — every detected client gets the same block under - * `mcpServers.dkg`. + * The canonical MCP-server entry written into client config files. + * + * Codex Round-4 unification: BOTH installed and monorepo modes + * register the SAME shape — `process.execPath` (absolute path to + * the currently-running Node binary) as `command`, and the absolute + * CLI script path as the first arg. This skips the `dkg` bin shim + * entirely. + * + * Why this matters: F30 (round-1 of this PR) wrote the resolved + * absolute `dkg` bin path expecting that to free GUI MCP clients + * from PATH dependencies. But the `dkg` bin on POSIX is a + * `#!/usr/bin/env node` script — `env` then needs `node` on PATH. + * On Windows the `.cmd` shim invokes `node.exe` similarly. Both + * still ENOENT in the GUI-client environment F30 was trying to + * fix. Calling Node directly with the script path eliminates BOTH + * PATH lookups (the `dkg` shim AND the `node` binary the shim + * would have invoked). GUI clients spawn the registered command + * with no PATH lookup at all. + * + * Installed-mode CLI script path: `realpathSync(process.argv[1])`. + * `process.argv[1]` is the script Node is currently executing — + * guaranteed valid and on disk. `realpathSync` canonicalises + * symlinks (npm's bin-shim is typically a symlink on POSIX) + * so the registered path is stable across `npm relink`. + * + * Monorepo-mode CLI script path: `/packages/cli/dist/cli.js`. + * Validated via `existsSync` to fail loudly on a fresh checkout + * with no build (Codex Round-1 Bug 3 contract). */ -function canonicalEntry(): Record { +function canonicalEntry( + context: SetupContext, + monorepoRoot: string | null, + dkgHome: string, +): Record { + let cliJsPath: string; + if (context === 'monorepo' && monorepoRoot) { + cliJsPath = join(monorepoRoot, 'packages', 'cli', 'dist', 'cli.js'); + if (!existsSync(cliJsPath)) { + throw new Error( + `Local CLI dist not found at ${cliJsPath}. Run \`pnpm --filter @origintrail-official/dkg build\` first, then re-run \`dkg mcp setup\`.`, + ); + } + } else { + // Installed mode: resolve the CLI script Node is currently + // executing. `process.argv[1]` points at the npm bin-shim's + // target (the actual cli.js file); `realpathSync` follows + // symlinks for stability across npm relink / version-manager + // rotations. + const installedCliPath = realpathSync(process.argv[1]); + // Codex Round-6 Fix 8: detect ephemeral package-manager cache + // paths (npx / pnpm dlx / yarn dlx / bunx). Persisting one of + // those into a client config means the registration silently + // breaks on the next cache cleanup. Throw an actionable error + // so the operator installs globally instead. + const ephemeralReason = detectEphemeralInstallPath(installedCliPath); + if (ephemeralReason) { + throw new Error( + `Detected ephemeral install path (${ephemeralReason}): ${installedCliPath}\n` + + `MCP client registrations must persist across runs. Install dkg globally first:\n` + + ` npm install -g @origintrail-official/dkg && dkg mcp setup`, + ); + } + cliJsPath = installedCliPath; + } + // Codex Round-9 Fix 16: propagate the resolved bootstrap home + // via the standard `env: { DKG_HOME: }` field on the MCP + // server entry. GUI clients (Claude Desktop, Cursor, VSCode + + // Copilot, Windsurf) all support this shape and DON'T inherit + // shell env when spawning the registered command — so without + // this propagation, an operator who set `DKG_HOME=/custom` + // would have setup write config / auth.token to `/custom` while + // the spawned MCP server fell back to `~/.dkg` and missed both. + // Always emitted (even for the default `~/.dkg`) so the + // registered entry is fully self-contained: operators can move + // / copy it between machines and it resolves identically without + // depending on shell state. return { - command: 'dkg', - args: ['mcp', 'serve'], + command: process.execPath, + args: [cliJsPath, 'mcp', 'serve'], + env: { DKG_HOME: dkgHome }, }; } +/** + * Codex Round-6 Fix 8: detect ephemeral package-manager cache paths + * that would yield non-persistent MCP registrations. Returns a + * short label of the matched cache pattern, or `null` if the path + * looks persistent. + * + * Patterns matched (path is normalized to forward-slashes + + * lower-case before matching, so Windows backslashes and casing + * don't escape the heuristic): + * - npm : `/_npx/` (npx CLI cache) + * - pnpm : `/.pnpm/dlx-`, `/dlx-` (pnpm dlx cache) + * - yarn : `/.yarn/cache/`, `/.yarn/berry/cache/` (yarn berry dlx) + * - bun : `/.bun/install/cache/` (bunx cache) + * + * Heuristic posture: positive-allow-list-against-cache, not + * negative-allow-list-of-globals. Globally installed bins always + * live outside these cache paths, so any false-negative still + * yields a working install. A false-positive throws and the + * operator gets a clear hint to install globally — recoverable. + */ +function detectEphemeralInstallPath(absPath: string): string | null { + const norm = absPath.replace(/\\/g, '/').toLowerCase(); + if (norm.includes('/_npx/')) return 'npx cache'; + if (norm.includes('/.pnpm/dlx-') || norm.includes('/dlx-')) return 'pnpm dlx cache'; + if (norm.includes('/.yarn/cache/') || norm.includes('/.yarn/berry/cache/')) return 'yarn cache'; + if (norm.includes('/.bun/install/cache/')) return 'bun cache'; + return null; +} + +/** + * F31 production-side per-client confirm prompt. Reads each + * to-be-written client name from the planned array and asks the + * operator interactively before writing. Skipped entries pass + * through unchanged (we don't prompt about no-ops). + * + * Auto-confirm conditions (skip prompts entirely): + * - `opts.yes === true` (operator passed `--yes`). + * - `process.stdin.isTTY === false` OR `process.stdout.isTTY === false`. + * Codex Round-4 Fix 5 tightened the TTY guard: the pre-fix + * stdin-only check would block on an invisible readline prompt + * when stdout was redirected/captured but stdin still happened + * to be a TTY (e.g. `dkg mcp setup > log.txt` from an + * interactive shell). Both must be a TTY for prompting; any + * non-TTY end auto-confirms. + * - Zero non-skip entries in the plan (nothing to confirm). + * + * Default empty answer (operator just hits Enter) accepts the + * registration — the prompt prefix is `[Y/n]` so the lower-case + * default is "yes". Only `n` / `no` (case-insensitive) declines. + * + * Exported so `cli.ts` can pass it through to `mcpSetupAction`'s + * deps surface in production. Tests inject their own stub. + */ +export async function confirmPlan( + planned: readonly PlannedItem[], + opts: { yes: boolean }, +): Promise { + const writes = planned.filter((p) => p.action !== 'skip'); + if ( + opts.yes || + !process.stdin.isTTY || + !process.stdout.isTTY || + writes.length === 0 + ) { + return [...planned]; + } + const { createInterface } = await import('node:readline/promises'); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const result: PlannedItem[] = []; + for (const p of planned) { + if (p.action === 'skip') { + result.push(p); + continue; + } + const verb = p.action === 'register' ? 'Register' : 'Refresh'; + const ans = ( + await rl.question( + `${verb} DKG MCP with ${p.s.target.name} (${p.s.target.displayPath})? [Y/n] `, + ) + ) + .trim() + .toLowerCase(); + const declined = ans === 'n' || ans === 'no'; + if (declined) { + console.log(` → declined; will skip ${p.s.target.name}`); + result.push({ ...p, action: 'skip' }); + } else { + result.push(p); + } + } + return result; + } finally { + rl.close(); + } +} + +/** + * Return the absolute directory of the currently-running CLI script, + * canonicalised through `realpath` (the npm bin shim is typically a + * symlink). Returns `null` if `process.argv[1]` is unset or the + * realpath lookup fails — caller falls back to safer defaults. + * + * Codex Round-13 Fix 19 helper. Used by `detectContext` to locate + * the running CLI's actual on-disk position, which is the correct + * signal for "is this the monorepo build?" (NOT `process.cwd()`, + * which is incidental — a global `dkg` invoked from inside a + * monorepo checkout would have `cwd` inside the repo while argv[1] + * resolves to the npm global install location). + */ +function dirnameOfRunningCli(): string | null { + try { + if (!process.argv[1]) return null; + return dirname(realpathSync(process.argv[1])); + } catch { + return null; + } +} + +/** + * Detect the setup context. With `force` set to a literal value, that + * value wins (with `--monorepo` requiring a discoverable monorepo + * root from the running CLI's location). Without `force`, walk + * ancestors of the running CLI's actual on-disk location: a hit + * means the running CLI is the monorepo dev build; a miss means + * we're globally installed. + * + * Codex Round-13 Fix 19: previously `process.cwd()` was the search + * start (Round-1 FIX 1's reaction to the wrong default which walked + * from `@origintrail-official/dkg-core`'s installed location). But + * cwd is incidental. A global `dkg` invoked from inside a monorepo + * checkout would have setup steps 1-3 bootstrap against the global + * home while the persisted MCP entry switched to the monorepo dist + * (mismatch; hard-fails if dist is unbuilt). The right signal for + * "which CLI is this?" is `realpath(process.argv[1])` — the script + * Node is currently running. + * + * `--installed` and `--monorepo` are mutually exclusive — the caller + * is expected to have validated that before calling. We accept the + * narrow union here so the action can pass through whichever flag + * commander produced without re-validating. + */ +function detectContext( + findRoot: typeof import('@origintrail-official/dkg-core').findDkgMonorepoRoot, + opts: { force?: SetupContext } = {}, +): { context: SetupContext; monorepoRoot: string | null } { + if (opts.force === 'installed') { + return { context: 'installed', monorepoRoot: null }; + } + // Round-13 Fix 19: search from the running CLI's directory. + const cliDir = dirnameOfRunningCli(); + if (opts.force === 'monorepo') { + // Codex Round-15 Fix 21: forced --monorepo searches `cwd` FIRST. + // The flag's contract is "use the monorepo from THIS checkout" + // — the user's explicit cwd-context intent overrides auto-detect + // heuristics. Pre-fix (Round-13 FIX 19) we tried `cliDir` first, + // which hard-failed when a global `dkg` was invoked from inside + // a valid monorepo with `--monorepo` (the global install path + // doesn't have a monorepo above it). Falls back to `cliDir` + // before throwing for the test pattern that invokes the dist + // directly without a matching cwd; auto-detect path below + // stays cliDir-first because cwd is incidental for unflagged + // invocations. + let root = findRoot(process.cwd()); + if (!root && cliDir) root = findRoot(cliDir); + if (!root) { + throw new Error( + '--monorepo flag passed but no DKG monorepo root could be located from this CLI invocation.', + ); + } + return { context: 'monorepo', monorepoRoot: root }; + } + // Auto-detect: if the running CLI's location is unknown, default + // to installed (safer than guessing monorepo from cwd). + if (!cliDir) { + return { context: 'installed', monorepoRoot: null }; + } + const root = findRoot(cliDir); + return root + ? { context: 'monorepo', monorepoRoot: root } + : { context: 'installed', monorepoRoot: null }; +} + interface ClientTarget { name: string; configPath: string; /** Pretty path for display, with `~` substituted back in. */ displayPath: string; + /** + * Per-client config-file format. Defaults to `'json'` so the existing + * Cursor + Claude Code targets stay byte-identical post-refactor. + * Future clients with non-JSON config (Codex CLI = TOML, Continue + * may be YAML) declare the format here so `writeRegistration` and + * `classify` dispatch to the right serializer. + */ + format?: 'json' | 'toml' | 'yaml'; + /** + * Dotted path to the per-server entry inside the parsed config. + * Defaults to `'mcpServers.dkg'` — the shape Cursor / Claude Code / + * Claude Desktop / Windsurf / Cline all use. Clients diverging from + * that shape (VSCode + Copilot Chat uses `servers.dkg`; Codex CLI + * uses `mcp_servers.dkg` under TOML) declare the alternate path + * here so a single registration helper covers all surfaces without + * per-client write functions. + */ + entryPath?: string; +} + +const DEFAULT_FORMAT: NonNullable = 'json'; +const DEFAULT_ENTRY_PATH = 'mcpServers.dkg'; + +/** + * Resolve a dotted entry-path (`'mcpServers.dkg'`, `'servers.dkg'`, + * `'mcp_servers.dkg'`) into its head segments + final key. Used by + * both classify (read) and writeRegistration (write) to navigate the + * parsed config object identically. + */ +function splitEntryPath(entryPath: string | undefined): { head: string[]; leaf: string } { + const path = entryPath ?? DEFAULT_ENTRY_PATH; + const parts = path.split('.').filter(Boolean); + if (parts.length === 0) { + throw new Error(`Invalid entryPath "${entryPath}": must be a non-empty dotted path`); + } + return { head: parts.slice(0, -1), leaf: parts[parts.length - 1] }; +} + +/** + * Walk a parsed config object down a list of head segments, lazily + * creating intermediate `Record` containers for any + * missing levels. Returns the parent container of the leaf key. + * + * Used at write time only. At read time we tolerate missing + * intermediates (the entry just classifies as `not-registered`). + */ +function ensurePathContainer( + body: Record, + head: string[], +): Record { + let cursor: Record = body; + for (const segment of head) { + const next = cursor[segment]; + if (next === undefined || next === null || typeof next !== 'object') { + const fresh: Record = {}; + cursor[segment] = fresh; + cursor = fresh; + } else { + cursor = next as Record; + } + } + return cursor; +} + +/** + * Read the leaf value at a dotted entry-path; returns `undefined` if + * any intermediate is missing or non-object. Used by `classify` so + * staleness detection works regardless of how deep the entry is + * nested. + */ +function readEntryAt( + body: Record, + entryPath: string | undefined, +): unknown { + const { head, leaf } = splitEntryPath(entryPath); + let cursor: unknown = body; + for (const segment of head) { + if (cursor === undefined || cursor === null || typeof cursor !== 'object') { + return undefined; + } + cursor = (cursor as Record)[segment]; + } + if (cursor === undefined || cursor === null || typeof cursor !== 'object') { + return undefined; + } + return (cursor as Record)[leaf]; } function expandHome(p: string): string { @@ -108,16 +567,121 @@ function tildify(p: string): string { return p.startsWith(home) ? '~' + p.slice(home.length) : p; } +/** + * Codex Round-6 Fix 9: resolve the Linux config base directory, + * honouring `XDG_CONFIG_HOME` when set. Per the XDG Base Directory + * spec, applications that store config under `~/.config` should + * defer to `$XDG_CONFIG_HOME` first — users who relocate app + * configs (common on multi-user systems and dotfile-managed + * setups) were previously invisible to `dkg mcp setup`'s detection + * sweep. Used by the Claude Desktop / VSCode + Copilot Chat / + * Cline Linux path resolvers below. + */ +function linuxConfigDir(home: string): string { + return process.env.XDG_CONFIG_HOME ?? join(home, '.config'); +} + +/** + * Resolve Claude Desktop's per-platform config path. The macOS path + * uses `~/Library/Application Support/Claude/`; Windows uses + * `%APPDATA%\Claude\`; Linux follows XDG-ish convention at + * `~/.config/Claude/`. The display path tildifies the home prefix + * so the operator-facing log reads consistently across platforms. + */ +function claudeDesktopPaths(home: string): { configPath: string; displayPath: string } { + const p = platform(); + if (p === 'darwin') { + const configPath = join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'); + return { configPath, displayPath: '~/Library/Application Support/Claude/claude_desktop_config.json' }; + } + if (p === 'win32') { + const appData = process.env.APPDATA ?? join(home, 'AppData', 'Roaming'); + const configPath = join(appData, 'Claude', 'claude_desktop_config.json'); + return { configPath, displayPath: configPath.replace(home, '~') }; + } + // Linux + everything else: XDG-style. Per Claude's docs the active + // config under Linux is `/Claude/claude_desktop_config.json`, + // falling back to `~/.config/Claude/...` when XDG_CONFIG_HOME is unset. + const configPath = join(linuxConfigDir(home), 'Claude', 'claude_desktop_config.json'); + return { configPath, displayPath: tildify(configPath) }; +} + +/** + * Resolve VSCode + Copilot Chat's per-platform user-settings MCP + * config path. VSCode keeps user-scoped settings under + * `/User/`; on Mac this is + * `~/Library/Application Support/Code/User/mcp.json`; on Windows + * it's `%APPDATA%\Code\User\mcp.json`; on Linux it's + * `~/.config/Code/User/mcp.json`. Note this is the user-scoped + * (cross-workspace) config, not the per-workspace `.vscode/mcp.json`. + * + * Diverges from the canonical `mcpServers.dkg` shape: Copilot Chat's + * MCP wiring uses `servers.dkg` instead. The phase-1 entryPath + * dispatch handles that without per-client write logic. + */ +function vscodeMcpPaths(home: string): { configPath: string; displayPath: string } { + const p = platform(); + if (p === 'darwin') { + const configPath = join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'); + return { configPath, displayPath: '~/Library/Application Support/Code/User/mcp.json' }; + } + if (p === 'win32') { + const appData = process.env.APPDATA ?? join(home, 'AppData', 'Roaming'); + const configPath = join(appData, 'Code', 'User', 'mcp.json'); + return { configPath, displayPath: configPath.replace(home, '~') }; + } + const configPath = join(linuxConfigDir(home), 'Code', 'User', 'mcp.json'); + return { configPath, displayPath: tildify(configPath) }; +} + +/** + * Resolve Cline (VSCode extension) per-platform config path. Cline + * stores its MCP wiring inside VSCode's per-extension globalStorage + * directory under the extension publisher.id namespace + * (`saoudrizwan.claude-dev`). Same `mcpServers.dkg` JSON shape as + * Cursor / Claude Code; what's hard is just the deeply-nested path. + * + * macOS: `~/Library/Application Support/Code/User/globalStorage/...` + * Windows: `%APPDATA%\Code\User\globalStorage\...` + * Linux: `~/.config/Code/User/globalStorage/...` + * + * Mirrors `vscodeMcpPaths` for the per-platform Code-user-data root, + * with the per-extension globalStorage suffix appended. + */ +function clineMcpPaths(home: string): { configPath: string; displayPath: string } { + const suffix = join( + 'globalStorage', + 'saoudrizwan.claude-dev', + 'settings', + 'cline_mcp_settings.json', + ); + const p = platform(); + if (p === 'darwin') { + const configPath = join(home, 'Library', 'Application Support', 'Code', 'User', suffix); + return { configPath, displayPath: `~/Library/Application Support/Code/User/${suffix.replace(/\\/g, '/')}` }; + } + if (p === 'win32') { + const appData = process.env.APPDATA ?? join(home, 'AppData', 'Roaming'); + const configPath = join(appData, 'Code', 'User', suffix); + return { configPath, displayPath: configPath.replace(home, '~') }; + } + const configPath = join(linuxConfigDir(home), 'Code', 'User', suffix); + return { configPath, displayPath: tildify(configPath) }; +} + /** * Discover MCP-aware clients on the machine. We look at the standard * config-file locations rather than probing for installed binaries — a * config file is the artifact `dkg mcp setup` actually writes into, and * its existence (or non-existence) is the signal that matters. * - * Cursor reads `~/.cursor/mcp.json`. Claude Code reads `~/.claude.json` - * (and on some platforms `~/.claude/mcp_servers.json`); we target - * `~/.claude.json` because that's the user-scoped path the MCP-server - * wiring already uses across the rest of the codebase. + * Per-client docs source-of-truth (verify on next-cycle if anything + * drifts): + * - Cursor: `~/.cursor/mcp.json` — global per-user MCP config + * - Claude Code: `~/.claude.json` — user-scoped path the MCP-server + * wiring already uses across the rest of the codebase + * - Claude Desktop: per-platform (see `claudeDesktopPaths`) + * - Windsurf (Codeium): `~/.codeium/windsurf/mcp_config.json` * * Detection is deliberately permissive: any client whose config file is * already present OR whose config directory is already present counts as @@ -125,8 +689,76 @@ function tildify(p: string): string { * client installed still see the fallback "no clients detected; run * `dkg mcp setup --print-only`" message. */ -function detectClients(): ClientTarget[] { +/** + * Codex Round-13 Fix 20: detect WSL2. Linux platform with `microsoft` + * / `WSL` markers in env, kernel release, or `/proc/version`. WSL + * users running `dkg mcp setup` from inside their WSL distro need + * to register Windows-side GUI clients (Claude Desktop, Windsurf, + * VSCode + Copilot, Cline) AS WELL AS any Linux-native clients — + * pre-fix they got the Linux-only set and the README's WSL2 + * promise silently failed for the apps users actually run. + * + * Multi-signal detection (env first; cheaper than fs reads): + * - `WSL_DISTRO_NAME` / `WSL_INTEROP` set by the WSL launcher. + * - `os.release()` contains `microsoft` or `wsl` (WSL kernels + * identify themselves there). + * - `/proc/version` contains the same markers (slower fallback). + */ +function isWSL(): boolean { + if (platform() !== 'linux') return false; + if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) return true; + try { + const release = osRelease().toLowerCase(); + if (release.includes('microsoft') || release.includes('wsl')) return true; + } catch { /* fall through */ } + try { + const procVersion = readFileSync('/proc/version', 'utf-8').toLowerCase(); + if (procVersion.includes('microsoft') || procVersion.includes('wsl')) return true; + } catch { /* /proc/version not readable; not WSL */ } + return false; +} + +/** + * Resolve a Windows-side env var (e.g. `%USERPROFILE%`, + * `%APPDATA%`) into a WSL-mounted Linux path (`/mnt/c/...`). Uses + * `cmd.exe` to read the env var, then `wslpath` to convert. Returns + * `null` on any failure (cmd.exe / wslpath missing, env var + * unset, conversion error) so callers fall back to Linux-only + * detection. + * + * Codex Round-13 Fix 20 helper. + */ +function wslWindowsEnvPath(envVarName: string): string | null { + try { + const winPath = execSync(`cmd.exe /c "echo %${envVarName}%"`, { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + // `cmd.exe` echoes `%FOO%` literally when the var is unset. + if (!winPath || winPath.startsWith('%')) return null; + // Strip Windows CR if present. + const cleaned = winPath.replace(/\r/g, ''); + // wslpath -u takes the Windows path and emits the /mnt/c/... + // form. Quote the input to handle spaces in usernames. + const linuxPath = execSync(`wslpath -u '${cleaned.replace(/'/g, "'\\''")}'`, { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return linuxPath || null; + } catch { + return null; + } +} + +/** + * Exported for Codex Round-13 Fix 20 tests — direct unit testing + * of WSL2 client-detection branch without going through the full + * `mcpSetupAction` body. Production callers go via the action. + */ +export function detectClients(): ClientTarget[] { const home = homedir(); + const claudeDesktop = claudeDesktopPaths(home); + const vscodeMcp = vscodeMcpPaths(home); const candidates: ClientTarget[] = [ { name: 'Cursor', @@ -138,7 +770,101 @@ function detectClients(): ClientTarget[] { configPath: join(home, '.claude.json'), displayPath: '~/.claude.json', }, + { + name: 'Claude Desktop', + configPath: claudeDesktop.configPath, + displayPath: claudeDesktop.displayPath, + }, + { + name: 'Windsurf', + configPath: join(home, '.codeium', 'windsurf', 'mcp_config.json'), + displayPath: '~/.codeium/windsurf/mcp_config.json', + }, + { + name: 'VSCode', + configPath: vscodeMcp.configPath, + displayPath: vscodeMcp.displayPath, + // Copilot Chat's MCP wiring keys under `servers`, not the + // canonical `mcpServers`. Phase-1 entryPath dispatch handles + // it without per-client write logic. + entryPath: 'servers.dkg', + }, + (() => { + const cline = clineMcpPaths(home); + return { + name: 'Cline', + configPath: cline.configPath, + displayPath: cline.displayPath, + // Cline uses the canonical `mcpServers.dkg` shape; only the + // path is unusual (deep-nested under VSCode's per-extension + // globalStorage). entryPath defaults to `mcpServers.dkg` + // so no override needed. + }; + })(), ]; + + // Codex Round-13 Fix 20: when running inside WSL2, ALSO probe the + // Windows-side config locations for the four GUI clients users + // typically run on Windows even when their dev shell is in WSL. + // Linux-side entries above are preserved (some WSL users run + // native Linux GUI clients too); the new entries are additive + // with disambiguated names so the operator-facing log is clear. + if (isWSL()) { + const winUserProfile = wslWindowsEnvPath('USERPROFILE'); + const winAppData = wslWindowsEnvPath('APPDATA'); + if (winAppData) { + // Claude Desktop on Windows: %APPDATA%\Claude\claude_desktop_config.json. + const claudeWinPath = join(winAppData, 'Claude', 'claude_desktop_config.json'); + candidates.push({ + name: 'Claude Desktop (Windows-side via WSL)', + configPath: claudeWinPath, + displayPath: claudeWinPath, + }); + // VSCode + Copilot Chat on Windows: %APPDATA%\Code\User\mcp.json. + const vscodeWinPath = join(winAppData, 'Code', 'User', 'mcp.json'); + candidates.push({ + name: 'VSCode (Windows-side via WSL)', + configPath: vscodeWinPath, + displayPath: vscodeWinPath, + entryPath: 'servers.dkg', + }); + // Cline on Windows: %APPDATA%\Code\User\globalStorage\ + // saoudrizwan.claude-dev\settings\cline_mcp_settings.json. + const clineWinPath = join( + winAppData, 'Code', 'User', + 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json', + ); + candidates.push({ + name: 'Cline (Windows-side via WSL)', + configPath: clineWinPath, + displayPath: clineWinPath, + }); + } + if (winUserProfile) { + // Windsurf on Windows: %USERPROFILE%\.codeium\windsurf\mcp_config.json + // (the `~/.codeium/...` path resolves under USERPROFILE on Windows, + // not APPDATA). + const windsurfWinPath = join(winUserProfile, '.codeium', 'windsurf', 'mcp_config.json'); + candidates.push({ + name: 'Windsurf (Windows-side via WSL)', + configPath: windsurfWinPath, + displayPath: windsurfWinPath, + }); + // Codex Round-17 Fix 23: Cursor on Windows — same shape as + // Linux Cursor (~/.cursor/mcp.json + canonical mcpServers.dkg + // entry), just resolved through %USERPROFILE%. Round-13 FIX 20 + // skipped this; "Windows Cursor + WSL shell" is a common dev + // setup that was silently unregistered until now even though + // Cursor's been in the detection set since round 1. + const cursorWinPath = join(winUserProfile, '.cursor', 'mcp.json'); + candidates.push({ + name: 'Cursor (Windows-side via WSL)', + configPath: cursorWinPath, + displayPath: cursorWinPath, + }); + } + } + return candidates.filter((c) => { if (existsSync(c.configPath)) return true; if (existsSync(dirname(c.configPath))) return true; @@ -170,11 +896,69 @@ function readJson(path: string): Record { } } -function classify(target: ClientTarget): ClientState { - const expected = canonicalEntry(); - const body = readJson(target.configPath); - const servers = (body.mcpServers as Record | undefined) ?? {}; - const current = servers.dkg as Record | undefined; +/** + * Read the parsed body of a per-client config, dispatching on + * `target.format`. JSON is the default + only format wired today; + * TOML / YAML branches throw `NotImplementedError`-style errors so + * targets that declare them but ship pre-phase-5 trip cleanly at + * registration time rather than silently writing garbage. Phase 5 + * (Codex CLI) wires the TOML branch; Continue (phase 4) wires YAML + * if Continue's config-file detection lands on `.yaml`. + */ +function readConfigBody(target: ClientTarget): Record { + const format = target.format ?? DEFAULT_FORMAT; + switch (format) { + case 'json': + return readJson(target.configPath); + case 'toml': + throw new Error( + `TOML config format not yet implemented (target: ${target.name}). Land phase 5 first.`, + ); + case 'yaml': + throw new Error( + `YAML config format not yet implemented (target: ${target.name}). Land phase 4 first.`, + ); + default: + throw new Error(`Unknown client config format: ${String(format)}`); + } +} + +/** + * Serialize a parsed body to disk, dispatching on `target.format`. + * Mirrors `readConfigBody`'s dispatch shape so phase 4/5 wiring is a + * symmetric extension. JSON output keeps the pre-refactor formatting + * (2-space indent, trailing newline) byte-for-byte. + */ +function writeConfigBody(target: ClientTarget, body: Record): void { + const format = target.format ?? DEFAULT_FORMAT; + const dir = dirname(target.configPath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + switch (format) { + case 'json': + writeFileSync(target.configPath, JSON.stringify(body, null, 2) + '\n'); + return; + case 'toml': + throw new Error( + `TOML config format not yet implemented (target: ${target.name}). Land phase 5 first.`, + ); + case 'yaml': + throw new Error( + `YAML config format not yet implemented (target: ${target.name}). Land phase 4 first.`, + ); + default: + throw new Error(`Unknown client config format: ${String(format)}`); + } +} + +function classify( + target: ClientTarget, + expected: Record, +): ClientState { + const body = readConfigBody(target); + const current = readEntryAt(body, target.entryPath) as + | Record + | null + | undefined; // Treat both `undefined` (key absent) and `null` (key present but // explicitly nulled) as "not-registered". Pre-F7 a `{ dkg: null }` // entry classified as `stale`, which made the operator-facing @@ -183,13 +967,49 @@ function classify(target: ClientTarget): ClientState { if (current === undefined || current === null) { return { target, state: 'not-registered', current: null }; } - const matches = - typeof current === 'object' && - current !== null && - (current as Record).command === expected.command && + // Codex Round-4 staleness contract: pure string equality. The + // canonical entry is now uniform `process.execPath + cli.js path` + // for both installed and monorepo modes (round-4 unified the + // shape), so all earlier asymmetric equivalence rules collapse + // to a single check. Any divergence — legacy bare-`"dkg"`, + // resolved-`/usr/local/bin/dkg`, a stale repo-root path from a + // moved checkout, etc. — classifies as `stale` and refreshes to + // the new shape on stock re-run. Auto-migration fires for free. + const expectedCommand = expected.command; + const currentCommand = (current as Record).command; + const commandMatches = currentCommand === expectedCommand; + const argsMatch = Array.isArray((current as Record).args) && JSON.stringify((current as Record).args) === JSON.stringify(expected.args); + // Codex Round-9 Fix 16 + Round-15 Fix 22: compare ONLY the + // `env.DKG_HOME` field, not the whole env object. Round-9 used + // strict JSON.stringify equality on env, but that turned any + // user-added MCP env var (NODE_OPTIONS, HTTPS_PROXY, custom + // debug flags) into spurious "stale drift" — and combined with + // writeRegistration's full-entry replace, those user vars got + // silently wiped on every re-run. Post-fix: only DKG_HOME + // matters for staleness; user-added keys are preserved by the + // write-time merge in writeRegistration. A pre-Fix-16 entry + // lacking `env` entirely classifies as `stale` (currentDkgHome + // === undefined !== expectedDkgHome) and migrates forward. + const currentEnvObj = + (current as Record).env && + typeof (current as Record).env === 'object' + ? ((current as Record).env as Record) + : undefined; + const currentDkgHome = currentEnvObj?.DKG_HOME; + const expectedDkgHome = + expected.env && typeof expected.env === 'object' + ? (expected.env as Record).DKG_HOME + : undefined; + const envMatch = currentDkgHome === expectedDkgHome; + const matches = + typeof current === 'object' && + current !== null && + commandMatches && + argsMatch && + envMatch; return { target, state: matches ? 'registered' : 'stale', @@ -197,15 +1017,49 @@ function classify(target: ClientTarget): ClientState { }; } -function writeRegistration(target: ClientTarget): void { - const body = readJson(target.configPath); - const servers = - (body.mcpServers as Record | undefined) ?? {}; - servers.dkg = canonicalEntry(); - body.mcpServers = servers; - const dir = dirname(target.configPath); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(target.configPath, JSON.stringify(body, null, 2) + '\n'); +function writeRegistration( + target: ClientTarget, + entry: Record, +): void { + const body = readConfigBody(target); + const { head, leaf } = splitEntryPath(target.entryPath); + const container = ensurePathContainer(body, head); + + // Codex Round-15 Fix 22 + Round-19 Fix 26: when refreshing an + // existing entry, MERGE the entire existing entry — not just + // env — with the expected entry. Round-15 Fix 22 added env-merge + // (NODE_OPTIONS, HTTPS_PROXY, etc. preserved) but the rest of + // the entry was still being replaced wholesale, which clobbered + // top-level keys clients use to anchor MCP servers (e.g. `cwd` + // for workspace-scoped servers, custom keys like `restartPolicy`). + // + // Spread order: existing entry first, then expected entry, then + // explicit env merge. The fields THIS COMMAND owns are + // `command`, `args`, and `env.DKG_HOME` — those override + // existing values via the second spread + explicit env override. + // Everything else passes through from the existing entry + // unchanged: arbitrary top-level keys (cwd, restartPolicy, …) + // and arbitrary env keys (NODE_OPTIONS, HTTPS_PROXY, …). + const currentEntry = container[leaf]; + const currentEntryObj = + currentEntry && typeof currentEntry === 'object' + ? (currentEntry as Record) + : {}; + const currentEnv = + currentEntryObj.env && typeof currentEntryObj.env === 'object' + ? (currentEntryObj.env as Record) + : {}; + const expectedEnv = + entry.env && typeof entry.env === 'object' + ? (entry.env as Record) + : {}; + const mergedEntry: Record = { + ...currentEntryObj, + ...entry, + env: { ...currentEnv, ...expectedEnv }, + }; + container[leaf] = mergedEntry; + writeConfigBody(target, body); } /** @@ -222,20 +1076,54 @@ function mintFallbackAgentName(): string { } /** - * Read the persisted agent name from `~/.dkg/config.json`. Returns - * `undefined` for missing/corrupt files. Used so a second `dkg mcp - * setup` run on a config whose `name` was set by a prior init doesn't - * regenerate a fresh random fallback. + * Codex Round-7 Fix 12: read the persisted DKG node config from + * either `config.json` (preferred) or `config.yaml` (fallback). + * Round-3's yaml support in `resolveDkgConfigHome()`'s configExists + * short-circuit treated yaml-only homes as established, but the + * step-1 reconcile path stayed JSON-only. The asymmetry meant + * yaml-only users hit the configExists fast path and then silently + * fell back to defaults for `name` / `apiPort` — daemon start / + * funding / verification all targeted the wrong values. + * + * Precedence: JSON wins over YAML when both exist. Deterministic + * for users who hand-edit one file while the daemon writes to the + * other; matches the existing `resolveDkgConfigHome` order. + * + * Returns `undefined` on missing or corrupt files (both formats + * tolerate parse failure — downstream uses pre-merge defaults + * silently rather than crashing setup). + */ +function readPersistedConfig(dkgDirPath: string): Record | undefined { + const jsonPath = join(dkgDirPath, 'config.json'); + if (existsSync(jsonPath)) { + try { + const raw = JSON.parse(readFileSync(jsonPath, 'utf-8')); + if (raw && typeof raw === 'object') return raw as Record; + } catch { /* corrupt JSON; fall through to YAML attempt */ } + } + const yamlPath = join(dkgDirPath, 'config.yaml'); + if (existsSync(yamlPath)) { + try { + const raw = yaml.load(readFileSync(yamlPath, 'utf-8')); + if (raw && typeof raw === 'object') return raw as Record; + } catch { /* corrupt YAML; let writeDkgConfig handle */ } + } + return undefined; +} + +/** + * Read the persisted agent name from the DKG node config (JSON or + * YAML). Returns `undefined` for missing/corrupt files. Used so a + * second `dkg mcp setup` run on a config whose `name` was set by a + * prior init doesn't regenerate a fresh random fallback. + * + * Codex Round-7 Fix 12: now accepts YAML configs in addition to + * JSON via the shared `readPersistedConfig()` helper. */ function readPersistedAgentName(dkgDirPath: string): string | undefined { - const configPath = join(dkgDirPath, 'config.json'); - if (!existsSync(configPath)) return undefined; - try { - const raw = JSON.parse(readFileSync(configPath, 'utf-8')); - if (typeof raw?.name === 'string' && raw.name.trim()) { - return raw.name.trim(); - } - } catch { /* corrupt config; let writeDkgConfig handle */ } + const persisted = readPersistedConfig(dkgDirPath); + const name = persisted?.name; + if (typeof name === 'string' && name.trim()) return name.trim(); return undefined; } @@ -260,13 +1148,113 @@ export async function mcpSetupAction( throw new Error(`Invalid port "${opts.port}" — must be an integer between 1 and 65535`); } + // Phase-2: detect setup context (installed vs monorepo dev). Drives + // `canonicalEntry`'s output shape so a contributor's local CLI dist + // is the one Cursor / Claude Code etc. invoke, not a stale globally- + // installed version. `--installed` / `--monorepo` are mutually + // exclusive overrides; flag them at the boundary so a misuse + // surfaces with a clear error rather than silent precedence. + if (opts.installed === true && opts.monorepo === true) { + throw new Error( + '--installed and --monorepo are mutually exclusive; pass at most one.', + ); + } + const forcedContext: SetupContext | undefined = opts.installed + ? 'installed' + : opts.monorepo + ? 'monorepo' + : undefined; + const { context, monorepoRoot } = detectContext(deps.findDkgMonorepoRoot, { + force: forcedContext, + }); + + // Codex Round-9 Fix 16: dkgDirPath has to be resolved BEFORE + // `canonicalEntry()` so we can propagate it via the entry's `env: + // { DKG_HOME }` field. Round-3/Round-5/Round-8 layered the cascade + // — see comment block on `previousDkgHome` capture below for the + // full rationale chain. + // + // Codex Round-3 Fix 3 + Round-8 Fix 14: capture the operator's + // pre-existing `DKG_HOME` BEFORE our own mutation — both for + // try/finally restore (Round-3 Fix 3) AND for env-precedence + // priority (Round-8 Fix 14). DKG_HOME is the highest-precedence + // operator override; it MUST win over the `--monorepo` bypass and + // over the auto-detect fallback. Pre-Fix-14 the `--monorepo` + // branch ignored env entirely, so an operator with `DKG_HOME` set + // who passed `--monorepo` would have setup state land in + // `~/.dkg-dev` while the rest of the CLI (every other downstream + // call into `resolveDkgConfigHome` / `dkgDir()`) honoured the + // env override — splitting state across two homes. + const previousDkgHome = process.env.DKG_HOME; + + // dkgDirPath cascade (highest priority first): + // 1. `previousDkgHome` (operator-set DKG_HOME) — wins always. + // 2. `--monorepo` bypass (Round-5 Fix 6) — explicit dev-isolation + // contract; bypasses configExists short-circuit but defers + // to env override above. + // 3. `resolveDkgConfigHome` auto-detect — respects configExists + // so global-install users on incidental monorepo cwd aren't + // silently redirected. + let dkgDirPath: string; + if (previousDkgHome) { + dkgDirPath = previousDkgHome; + } else if (forcedContext === 'monorepo' && monorepoRoot) { + dkgDirPath = join(homedir(), '.dkg-dev'); + } else { + dkgDirPath = deps.resolveDkgConfigHome({ isDkgMonorepo: context === 'monorepo' }); + } + + // Codex Round-4: both modes register `process.execPath` + the + // absolute CLI script path. No more `which dkg` resolution — the + // shape is uniform and PATH-free, eliminating both the `dkg` bin + // shim AND the `node` binary the shim would have invoked from + // GUI clients' lookup chain. + // Codex Round-9 Fix 16: third arg propagates dkgDirPath into the + // entry's `env: { DKG_HOME }` field so spawned MCP servers read + // the same home setup just bootstrapped (GUI clients don't + // inherit shell env). + const expectedEntry = canonicalEntry(context, monorepoRoot, dkgDirPath); + + // Codex Round-7 Fix 11 + Round-8 Fix 13: surface the exact + // command + args that will be persisted into client configs. + // The `--installed` / `--monorepo` flags only govern the + // bootstrap home — the registered binary is always whichever + // CLI is currently running. Logging it here lets operators + // verify before any client write happens. + // + // Routed to STDERR (not console.log → stdout) because this + // line runs BEFORE the `--print-only` early return, and + // `dkg mcp setup --print-only` MUST emit a single canonical + // JSON document on stdout for `… | jq …` and redirect-into- + // config workflows to work. Same convention as the VSCode + // disambiguation note (Round-2 Bug B): operator advisories on + // stderr; data on stdout. Round-7 originally used console.log + // and broke --print-only stdout purity for the second time. + const entryArgs = (expectedEntry.args as string[]).join(' '); + process.stderr.write(`[setup] Registering CLI: ${expectedEntry.command} ${entryArgs}\n`); + if (printOnly) { const block = { mcpServers: { - dkg: canonicalEntry(), + dkg: expectedEntry, }, }; process.stdout.write(JSON.stringify(block, null, 2) + '\n'); + // Codex Round-2: VSCode + Copilot Chat keys MCP servers under + // `servers`, not the canonical `mcpServers`. Round-1 of this + // fix appended the note + a second JSON object to stdout, but + // that breaks `dkg mcp setup --print-only | jq …` and any + // redirect-based workflow — the flag contract is "stdout is the + // canonical JSON document". Keep stdout a single JSON document + // and emit the disambiguation to stderr instead, matching the + // standard CLI convention (data on stdout, advisories on stderr). + process.stderr.write( + '\n' + + 'Note: VSCode + GitHub Copilot Chat uses a different shape — ' + + '`servers.dkg` instead of `mcpServers.dkg`. For VSCode, paste:\n' + + JSON.stringify({ servers: { dkg: expectedEntry } }, null, 2) + + '\n', + ); return; } @@ -276,11 +1264,22 @@ export async function mcpSetupAction( console.log('[setup] DRY RUN — no files will be modified, no daemon will start\n'); } - // ── Step 1: ensure ~/.dkg/config.json ───────────────────────────── + // ── Step 1: ensure /config.json ───────────────────────── // Mirrors `dkg openclaw setup` step 3 byte-for-byte. If the file // already exists, `writeDkgConfig` merges (first-wins on `name` / // `apiPort` unless explicit overrides are passed). - const dkgDirPath = join(homedir(), '.dkg'); + // + // Codex Round-2 Bug A: thread the monorepo signal into DKG-home + // resolution so the bootstrap state (config, daemon pid, faucet + // wallets, auth.token) lands in the SAME directory the registered + // local CLI dist will read at MCP-client startup. Setting + // `DKG_HOME` for the duration of this action overrides the + // package-path-based auto-detection inside adapter-openclaw's + // `dkgDir()` and dkg-core's daemon-lifecycle, keeping all four + // flows aligned. (`dkgDirPath` itself was computed up-front for + // Round-9 Fix 16 — we just install the env mutation here.) + process.env.DKG_HOME = dkgDirPath; + try { const yamlPath = join(dkgDirPath, 'config.yaml'); const jsonPath = join(dkgDirPath, 'config.json'); const configExists = existsSync(yamlPath) || existsSync(jsonPath); @@ -305,17 +1304,24 @@ export async function mcpSetupAction( * the source of truth for an existing install. */ const reconcileFromPersistedConfig = (): void => { - if (!existsSync(jsonPath)) return; - try { - const merged = JSON.parse(readFileSync(jsonPath, 'utf-8')); - const mergedPort = Number(merged.apiPort); - if (Number.isInteger(mergedPort) && mergedPort >= 1 && mergedPort <= 65535) { - effectivePort = mergedPort; - } - if (typeof merged.name === 'string' && merged.name.trim()) { - effectiveAgentName = merged.name.trim(); - } - } catch { /* corrupt config; downstream uses pre-merge values */ } + // Codex Round-7 Fix 12: read JSON-or-YAML via the shared + // `readPersistedConfig()` helper. Pre-fix this branch only + // tried `config.json`, so a yaml-only install would silently + // fall through with the CLI defaults (port 9200, random name) + // and the daemon / funding / verify steps would target the + // wrong values. Round-3's configExists short-circuit had + // already established yaml-only homes; this completes the + // contract. + const merged = readPersistedConfig(dkgDirPath); + if (!merged) return; + const mergedPort = Number((merged as { apiPort?: unknown }).apiPort); + if (Number.isInteger(mergedPort) && mergedPort >= 1 && mergedPort <= 65535) { + effectivePort = mergedPort; + } + const mergedName = (merged as { name?: unknown }).name; + if (typeof mergedName === 'string' && mergedName.trim()) { + effectiveAgentName = mergedName.trim(); + } }; // F25: reconcile BEFORE the branch decision so dry-run preview @@ -332,17 +1338,31 @@ export async function mcpSetupAction( if (configExists && opts.name == null && opts.port == null) { console.log(`[setup] Node config exists (${tildify(existsSync(yamlPath) ? yamlPath : jsonPath)}); leaving untouched.`); } else if (dryRun) { - console.log(`[setup] [dry-run] Would write ~/.dkg/config.json (port ${effectivePort}, name "${effectiveAgentName}")`); + console.log(`[setup] [dry-run] Would write ${tildify(jsonPath)} (port ${effectivePort}, name "${effectiveAgentName}")`); } else { try { const network = deps.loadNetworkConfig(); - deps.writeDkgConfig(effectiveAgentName, network, apiPort, { - nameExplicit: opts.name != null, - portExplicit: opts.port != null, + // Codex Round-23 Fix 30: call the agent-agnostic + // ensureDkgNodeConfig directly. The caller-loads-existing + // contract means we pre-read the persisted config (yaml or + // json) here and pass it through; the helper merges with + // network defaults + overrides. No OpenClaw migration step + // — MCP-only configs never have the legacy openclawAdapter + // / openclawChannel keys this setup never wrote. + const existing = readPersistedConfig(dkgDirPath) ?? {}; + deps.ensureDkgNodeConfig({ + agentName: effectiveAgentName, + network, + apiPort, + existing, + overrides: { + nameExplicit: opts.name != null, + portExplicit: opts.port != null, + }, }); - // Re-read after writeDkgConfig in case the daemon's config- - // merge changed `apiPort` / `name` (first-wins semantics on - // existing fields, explicit overrides on new). + // Re-read after ensureDkgNodeConfig in case the helper's + // field-level merge changed `apiPort` / `name` (first-wins + // semantics on existing fields, explicit overrides on new). reconcileFromPersistedConfig(); } catch (err: any) { console.error(`[setup] Failed to load network config: ${err?.message ?? err}`); @@ -448,9 +1468,34 @@ export async function mcpSetupAction( return; } - const states = clients.map(classify); - type Action = 'register' | 'refresh' | 'skip'; - const planned: Array<{ s: ClientState; action: Action }> = states.map((s) => { + // Codex Round-8 Fix 15: per-client classify error isolation. + // Pre-fix, a malformed config in any one detected client (e.g. a + // truncated VSCode `Code/User/mcp.json`, a broken Cline + // `cline_mcp_settings.json`) would throw out of `classify(...)` + // and abort the entire setup before other clients were even + // touched. This is especially load-bearing for VSCode/Cline, + // whose dirname-heuristic detection is broad enough to flag any + // `Code/User/` directory as a candidate even when Copilot Chat + // / Cline isn't actually installed. + // + // Fixed: track classify failures alongside states. On failure, + // emit a stderr warning, mark the target as failed, and force + // the planner below to `skip` it so no write is attempted on a + // client we couldn't read. Other clients continue unaffected. + const classifyFailed = new Set(); + const states: ClientState[] = clients.map((c) => { + try { + return classify(c, expectedEntry); + } catch (err: any) { + process.stderr.write( + `[setup] WARNING: ${c.name} classify failed (${err?.message ?? err}); skipping this client.\n`, + ); + classifyFailed.add(c.name); + return { target: c, state: 'not-registered', current: null }; + } + }); + const planned: PlannedItem[] = states.map((s) => { + if (classifyFailed.has(s.target.name)) return { s, action: 'skip' }; if (force) return { s, action: 'refresh' }; if (s.state === 'not-registered') return { s, action: 'register' }; if (s.state === 'stale') return { s, action: 'refresh' }; @@ -473,24 +1518,108 @@ export async function mcpSetupAction( console.log(` ${s.target.name.padEnd(13)} (${s.target.displayPath}) — ${stateLabel}; ${actionLabel}`); } - const writes = planned.filter((p) => p.action !== 'skip'); + // F31: per-client interactive confirm. Skipped on `--yes`, in + // non-TTY environments (CI, piped input), or when nothing's + // pending — see `confirmPlan` JSDoc for the auto-confirm matrix. + // Skip in dry-run too: dry-run is preview-only, no point asking + // the operator about writes that won't happen. + const confirm = deps.confirmPlan ?? confirmPlan; + const confirmed = dryRun + ? planned + : await confirm(planned, { yes: opts.yes === true }); + + const writes = confirmed.filter((p) => p.action !== 'skip'); if (writes.length === 0) { - console.log('\nClients all up-to-date; nothing to write. Re-run with --force to refresh anyway.'); + if (planned.some((p) => p.action !== 'skip')) { + // Codex Round-5 Fix 7: clarify the flag guidance. `--force` + // refreshes already-registered clients; `--yes` skips + // prompts. The flags are orthogonal — a re-run with only + // `--force` would re-prompt the same declined entries (since + // they're still classified as register/refresh, not skip, + // and confirmPlan still prompts in TTY mode regardless of + // force). To get past the prompt loop, the operator wants + // `--yes` (alone if the entries were unregistered; combined + // with `--force` if they want to also refresh + // already-registered clients). + console.log('\nAll pending registrations declined. Re-run with --yes to skip prompts (or --force --yes to also refresh already-registered clients).'); + } else { + console.log('\nClients all up-to-date; nothing to write. Re-run with --force to refresh anyway.'); + } } else if (dryRun) { console.log('\n[setup] [dry-run] Would write to the clients listed above.'); - } else { + } + // Codex Round-9 Fix 17: collect per-client write failures so we + // can throw a structured aggregate error after the loop. Round-8 + // Fix 15 (continue past per-client failures) is the right intent + // — but it accidentally exited setup with code 0 even when zero + // clients were actually updated, giving CI / scripted runs a + // false-success signal. Fix 17 keeps the continue-and-attempt + // behaviour AND restores the non-zero exit by throwing once the + // loop finishes, citing every failed client (classify-failed + + // write-failed). + const writeFailures: { name: string; error: string }[] = []; + if (!dryRun && writes.length > 0) { console.log(''); for (const { s, action } of writes) { try { - writeRegistration(s.target); + writeRegistration(s.target, expectedEntry); console.log(` ${action === 'register' ? 'Registered' : 'Refreshed'} ${s.target.name} → ${s.target.displayPath}`); } catch (err: any) { - console.error(` Failed to write ${s.target.displayPath}: ${err?.message ?? err}`); - throw err; + // Codex Round-8 Fix 15: per-client write error isolation. + // Pre-fix this `throw err` aborted the entire setup on the + // first per-client write failure — every subsequent client + // (and step 5's verification probe) was skipped. Operators + // hitting a permissions issue on one client config (e.g. + // VSCode's `Code/User/mcp.json` owned by root after a + // previous sudo run) would have to fix that one file by + // hand before any other registration could be written. + // Fixed: emit a stderr warning and continue with the rest + // of the writes loop. Round-9 Fix 17 collects the failure + // for the post-loop aggregate throw. + const msg = err?.message ?? String(err); + process.stderr.write( + `[setup] WARNING: ${s.target.name} write failed (${msg}); other clients still attempted.\n`, + ); + writeFailures.push({ name: s.target.name, error: msg }); } } } + // Codex Round-9 Fix 17: aggregate every classify-failed (Fix 15) + // and write-failed client into a single structured error. Three + // cases: + // - zero clients failed → fall through to step 5 verification + // and the existing "Next steps" hint. + // - all attempted clients failed → throw "No client configs + // updated" (hardest case; the registration step did nothing). + // - mixed (some succeeded, some failed) → throw "N failed; M + // succeeded" (partial; CI still sees non-zero so the + // pipeline can re-run after the operator addresses the + // per-client warnings emitted above). + // + // Skipped under dry-run (no writes attempted) and on the + // pure-decline path (planned has writes but operator declined + // every prompt — that's a deliberate operator action, not a + // failure). + if (!dryRun) { + const allFailures: { name: string; error: string }[] = [ + ...Array.from(classifyFailed).map((name) => ({ name, error: 'classify failed' })), + ...writeFailures, + ]; + if (allFailures.length > 0) { + const successfulWrites = writes.length - writeFailures.length; + const lines = allFailures.map((f) => ` - ${f.name}: ${f.error}`).join('\n'); + if (successfulWrites === 0) { + throw new Error( + `No client configs updated. ${allFailures.length} client(s) failed:\n${lines}`, + ); + } + throw new Error( + `${allFailures.length} client(s) failed to register; ${successfulWrites} succeeded:\n${lines}\nReview the warnings above and re-run \`dkg mcp setup\` after resolving the issues.`, + ); + } + } + // ── Step 5: optional verification ───────────────────────────────── // Probe the daemon's `/api/status` to confirm it's healthy on the // effective port. Cheap reachability check; if the daemon is up but @@ -520,6 +1649,13 @@ export async function mcpSetupAction( console.log(' 2. From inside the client, ask "what tools does dkg expose?" — you should see'); console.log(' dkg_assertion_create, dkg_assertion_write, dkg_assertion_query, and friends.'); console.log(''); + } finally { + // Codex Round-3 Fix 3: restore the prior `DKG_HOME` (or unset + // if it wasn't set going in). Runs on both throw and normal + // exit so the env mutation is bounded to the action's body. + if (previousDkgHome !== undefined) process.env.DKG_HOME = previousDkgHome; + else delete process.env.DKG_HOME; + } } export { expandHome }; diff --git a/packages/cli/test/mcp-setup.test.ts b/packages/cli/test/mcp-setup.test.ts index 2c01976c4..ab6bc41bf 100644 --- a/packages/cli/test/mcp-setup.test.ts +++ b/packages/cli/test/mcp-setup.test.ts @@ -1,9 +1,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; -import { tmpdir, homedir } from 'node:os'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync, realpathSync } from 'node:fs'; +import { tmpdir, homedir, platform } from 'node:os'; import { join } from 'node:path'; import { mcpSetupAction, type McpSetupActionDeps } from '../src/mcp-setup.js'; +/** + * Codex Round-4 + Round-9: canonical entry shape that production + * now writes for INSTALLED context. Both modes emit `{ command: + * process.execPath, args: [, 'mcp', 'serve'], + * env: { DKG_HOME: } }`; installed-mode resolves + * the script path from `process.argv[1]` via `realpathSync` + * (canonicalises symlinks). + * + * The optional `dkgHome` arg lets tests pin the DKG_HOME env value + * for the entry (default: `/.dkg`, i.e. the tmpHome's + * installed-mode home). Tests that exercise alternate homes + * (`--monorepo`, custom `DKG_HOME`) pass the expected path + * explicitly. + */ +const EXPECTED_INSTALLED_ENTRY = (dkgHome?: string) => ({ + command: process.execPath, + args: [realpathSync(process.argv[1]), 'mcp', 'serve'], + env: { DKG_HOME: dkgHome ?? join(process.env.HOME ?? process.env.USERPROFILE ?? '', '.dkg') }, +}); + /** * Bundled-flow fixture for `dkg mcp setup`. Per W6-pre task brief, asserts * that on a clean machine the action: @@ -19,20 +39,49 @@ describe('mcpSetupAction — bundled init + daemon-start + register flow', () => let tmpHome: string; let originalHome: string | undefined; let originalUserprofile: string | undefined; + let originalAppdata: string | undefined; + let originalDkgHome: string | undefined; + let originalXdgConfigHome: string | undefined; let logSpy: ReturnType; let warnSpy: ReturnType; let errorSpy: ReturnType; + let stderrSilencer: ReturnType; beforeEach(() => { tmpHome = mkdtempSync(join(tmpdir(), 'mcp-setup-test-')); originalHome = process.env.HOME; originalUserprofile = process.env.USERPROFILE; + originalAppdata = process.env.APPDATA; + // Codex Round-2 Bug A: mcpSetupAction now sets DKG_HOME for the + // duration of the action so adapter-openclaw / dkg-core flows + // pick up the resolved home. Save+restore it like HOME/APPDATA + // so the env mutation is bounded to each test. + originalDkgHome = process.env.DKG_HOME; + delete process.env.DKG_HOME; + // Codex Round-6 Fix 9: linuxConfigDir() reads XDG_CONFIG_HOME at + // call time. Save+restore so tests that set it don't leak into + // sibling tests and so the existing Linux fallback tests run with + // it unset (mirrors the typical operator environment). + originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + delete process.env.XDG_CONFIG_HOME; process.env.HOME = tmpHome; // node:os homedir() reads USERPROFILE on win32, HOME elsewhere; set both. process.env.USERPROFILE = tmpHome; + // Phase-3: Claude Desktop's Windows path resolves under + // %APPDATA%; redirect that into tmpHome too so the per-platform + // path resolver lands inside the test sandbox on Win32. macOS + // and Linux ignore APPDATA. + process.env.APPDATA = join(tmpHome, 'AppData', 'Roaming'); logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + // Codex Round-8 Fix 13: the "Registering CLI:" log + Round-2 + // Bug B's VSCode advisory + Round-8 Fix 15's per-client + // failure warnings all go to stderr now. Silence them by + // default so the test reporter stays readable. Tests that + // need to assert on stderr re-spy after entering the test body + // (the overlap is harmless — vi resolves the most-recent spy). + stderrSilencer = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); }); afterEach(() => { @@ -40,26 +89,55 @@ describe('mcpSetupAction — bundled init + daemon-start + register flow', () => else delete process.env.HOME; if (originalUserprofile !== undefined) process.env.USERPROFILE = originalUserprofile; else delete process.env.USERPROFILE; + if (originalAppdata !== undefined) process.env.APPDATA = originalAppdata; + else delete process.env.APPDATA; + if (originalDkgHome !== undefined) process.env.DKG_HOME = originalDkgHome; + else delete process.env.DKG_HOME; + if (originalXdgConfigHome !== undefined) process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + else delete process.env.XDG_CONFIG_HOME; logSpy.mockRestore(); warnSpy.mockRestore(); errorSpy.mockRestore(); + stderrSilencer.mockRestore(); rmSync(tmpHome, { recursive: true, force: true }); }); /** - * Build a fresh stubbed deps surface. `writeDkgConfig` writes a real - * file into the temp HOME so the post-merge readback in mcpSetupAction - * sees a valid config — that's the byte-aligned behaviour with the - * production primitive without spawning a real daemon. + * Build a fresh stubbed deps surface. `ensureDkgNodeConfig` writes a + * real file into the temp HOME so the post-merge readback in + * mcpSetupAction sees a valid config — byte-aligned with the + * production helper's contract without spawning a real daemon. + * + * Codex Round-23 Fix 30: signature is the object-shape one + * (`{ agentName, network, apiPort, existing, overrides }`) used + * by `dkg-core`'s helper. Round-2 Bug A's DKG_HOME-honouring + * posture is preserved — the stub reads `process.env.DKG_HOME` + * (set by the action) for the write target. */ function makeDeps(overrides: Partial = {}): McpSetupActionDeps { const startDaemon = vi.fn(async (_port: number) => {}); - const writeDkgConfig = vi.fn((agentName: string, _network: any, apiPort: number) => { - const dkgDir = join(tmpHome, '.dkg'); + const ensureDkgNodeConfig = vi.fn((opts: { + agentName: string; + network: any; + apiPort: number; + existing: Record; + overrides?: { nameExplicit?: boolean; portExplicit?: boolean }; + }) => { + const dkgDir = process.env.DKG_HOME ?? join(tmpHome, '.dkg'); mkdirSync(dkgDir, { recursive: true }); + // Mirror the production helper's first-wins / explicit-override + // semantics minimally — most tests just check that the call + // happened with these args, but a few re-read the file so we + // emit something realistic. + const merged = { + ...opts.existing, + name: opts.overrides?.nameExplicit ? opts.agentName : (opts.existing?.name ?? opts.agentName), + apiPort: opts.overrides?.portExplicit ? opts.apiPort : (opts.existing?.apiPort ?? opts.apiPort), + nodeRole: opts.existing?.nodeRole ?? 'edge', + }; writeFileSync( join(dkgDir, 'config.json'), - JSON.stringify({ name: agentName, apiPort, nodeRole: 'edge' }, null, 2), + JSON.stringify(merged, null, 2), ); }); const loadNetworkConfig = vi.fn(() => ({ @@ -72,13 +150,42 @@ describe('mcpSetupAction — bundled init + daemon-start + register flow', () => const readWalletsWithRetry = vi.fn(async () => ['0xtest1', '0xtest2', '0xtest3']); const requestFaucetFunding = vi.fn(async () => ({ success: true }) as any); const logManualFundingInstructions = vi.fn(() => {}); + // Phase-2: detectContext defaults to "installed" by returning null + // from findDkgMonorepoRoot. Tests that exercise the monorepo path + // override this dep to return a mock repo root. + const findDkgMonorepoRoot = vi.fn((_startDir?: string) => null as string | null); + // Codex Round-4: `resolveDkgBin` was removed from the deps + // surface — both modes now register `process.execPath + + // `, so the `which dkg` resolution it provided is + // obsolete. + // Codex Round-2 Bug A: resolveDkgConfigHome defaults to mirroring + // the production dkg-core posture against the test's tmpHome. + // `isDkgMonorepo: true` ⇒ `/.dkg-dev`; otherwise ⇒ + // `/.dkg`. Existing tests that don't exercise the + // monorepo path keep landing in `/.dkg` byte-aligned + // with the pre-Bug-A behaviour. + const resolveDkgConfigHome = vi.fn( + (opts: { isDkgMonorepo?: boolean } = {}): string => { + if (opts.isDkgMonorepo) { + const devDir = join(tmpHome, '.dkg-dev'); + // Tests that hit the monorepo branch expect writeDkgConfig + // to land in this directory; create it eagerly so existsSync + // probes downstream don't trip over a missing parent. + mkdirSync(devDir, { recursive: true }); + return devDir; + } + return join(tmpHome, '.dkg'); + }, + ); return { loadNetworkConfig, - writeDkgConfig, + ensureDkgNodeConfig, startDaemon, readWalletsWithRetry, requestFaucetFunding, logManualFundingInstructions, + findDkgMonorepoRoot, + resolveDkgConfigHome, ...overrides, }; } @@ -92,12 +199,12 @@ describe('mcpSetupAction — bundled init + daemon-start + register flow', () => // Avoid the verify step's real-network probe. await mcpSetupAction({ verify: false, fund: false }, deps); - // (a) writeDkgConfig was called with port 9200 (default) + a fallback agent name. - expect(deps.writeDkgConfig).toHaveBeenCalledTimes(1); - const writeArgs = (deps.writeDkgConfig as any).mock.calls[0]; - expect(writeArgs[2]).toBe(9200); - expect(typeof writeArgs[0]).toBe('string'); - expect(writeArgs[0]).toMatch(/^mcp-agent-/); + // (a) ensureDkgNodeConfig was called with port 9200 (default) + a fallback agent name. + expect(deps.ensureDkgNodeConfig).toHaveBeenCalledTimes(1); + const writeArgs = (deps.ensureDkgNodeConfig as any).mock.calls[0][0]; + expect(writeArgs.apiPort).toBe(9200); + expect(typeof writeArgs.agentName).toBe('string'); + expect(writeArgs.agentName).toMatch(/^mcp-agent-/); expect(existsSync(join(tmpHome, '.dkg', 'config.json'))).toBe(true); // (b) startDaemon was called once with the effective port. @@ -106,10 +213,7 @@ describe('mcpSetupAction — bundled init + daemon-start + register flow', () => // (c) Cursor client config was written with the canonical entry. const cursorConfig = JSON.parse(readFileSync(join(tmpHome, '.cursor', 'mcp.json'), 'utf-8')); - expect(cursorConfig.mcpServers.dkg).toEqual({ - command: 'dkg', - args: ['mcp', 'serve'], - }); + expect(cursorConfig.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); }); it('skips writeDkgConfig when ~/.dkg/config.yaml already exists and no overrides given', async () => { @@ -122,7 +226,7 @@ describe('mcpSetupAction — bundled init + daemon-start + register flow', () => const deps = makeDeps(); await mcpSetupAction({ verify: false, fund: false }, deps); - expect(deps.writeDkgConfig).not.toHaveBeenCalled(); + expect(deps.ensureDkgNodeConfig).not.toHaveBeenCalled(); // Daemon start still runs unless --no-start was passed. expect(deps.startDaemon).toHaveBeenCalledTimes(1); }); @@ -151,7 +255,7 @@ describe('mcpSetupAction — bundled init + daemon-start + register flow', () => // writeDkgConfig MUST NOT have run — the existing-config branch // was taken (no overrides supplied). - expect(deps.writeDkgConfig).not.toHaveBeenCalled(); + expect(deps.ensureDkgNodeConfig).not.toHaveBeenCalled(); // startDaemon MUST receive the persisted 9300, not the CLI default. expect(deps.startDaemon).toHaveBeenCalledTimes(1); expect((deps.startDaemon as any).mock.calls[0][0]).toBe(9300); @@ -179,7 +283,7 @@ describe('mcpSetupAction — bundled init + daemon-start + register flow', () => // is structural: the branch ran without throwing on the corrupt // configs / missing fields path the helper handles. Companion // assertion to the port-9300 case above. - expect(deps.writeDkgConfig).not.toHaveBeenCalled(); + expect(deps.ensureDkgNodeConfig).not.toHaveBeenCalled(); expect(deps.startDaemon).not.toHaveBeenCalled(); }); @@ -275,7 +379,7 @@ describe('mcpSetupAction — bundled init + daemon-start + register flow', () => await mcpSetupAction({ dryRun: true }, deps); - expect(deps.writeDkgConfig).not.toHaveBeenCalled(); + expect(deps.ensureDkgNodeConfig).not.toHaveBeenCalled(); expect(deps.startDaemon).not.toHaveBeenCalled(); expect(deps.requestFaucetFunding).not.toHaveBeenCalled(); expect(existsSync(join(tmpHome, '.dkg', 'config.json'))).toBe(false); @@ -288,12 +392,13 @@ describe('mcpSetupAction — bundled init + daemon-start + register flow', () => await mcpSetupAction({ printOnly: true }, deps); - expect(deps.writeDkgConfig).not.toHaveBeenCalled(); + expect(deps.ensureDkgNodeConfig).not.toHaveBeenCalled(); expect(deps.startDaemon).not.toHaveBeenCalled(); - // Asserted JSON shape on stdout. - const allWrites = (stdoutSpy.mock.calls as any[]).map((c) => c[0]).join(''); - const parsed = JSON.parse(allWrites); - expect(parsed.mcpServers.dkg).toEqual({ command: 'dkg', args: ['mcp', 'serve'] }); + // Codex Issue 5: --print-only now emits TWO JSON blocks (the + // canonical mcpServers.dkg shape PLUS a VSCode-shape note). + // Use parseStdoutJson which walks the first balanced object. + const parsed = parseStdoutJson(stdoutSpy); + expect(parsed.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); stdoutSpy.mockRestore(); }); @@ -307,16 +412,16 @@ describe('mcpSetupAction — bundled init + daemon-start + register flow', () => ).rejects.toThrow(/Invalid port/); }); - it('--port and --name overrides flow through to writeDkgConfig', async () => { + it('--port and --name overrides flow through to ensureDkgNodeConfig', async () => { mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); const deps = makeDeps(); await mcpSetupAction({ port: '9300', name: 'override-agent', verify: false, fund: false }, deps); - const writeArgs = (deps.writeDkgConfig as any).mock.calls[0]; - expect(writeArgs[0]).toBe('override-agent'); - expect(writeArgs[2]).toBe(9300); - expect(writeArgs[3]).toEqual({ nameExplicit: true, portExplicit: true }); + const writeArgs = (deps.ensureDkgNodeConfig as any).mock.calls[0][0]; + expect(writeArgs.agentName).toBe('override-agent'); + expect(writeArgs.apiPort).toBe(9300); + expect(writeArgs.overrides).toEqual({ nameExplicit: true, portExplicit: true }); // Daemon start uses the override port. expect((deps.startDaemon as any).mock.calls[0][0]).toBe(9300); }); @@ -328,11 +433,2558 @@ describe('mcpSetupAction — bundled init + daemon-start + register flow', () => throw new Error('faucet 503'); }), }); + // F14 + F26: the funding step now probes daemon reachability via + // `/api/status` before attempting the faucet call. Stub fetch to + // mark the daemon reachable so the throwing-faucet mock is + // actually reached. Without this stub the funding step would + // short-circuit on the unreachable-path log line and the + // throwing-faucet mock would never run. + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + return new Response('{}', { status: 200 }) as any; + }); await mcpSetupAction({ verify: false }, deps); expect(deps.logManualFundingInstructions).toHaveBeenCalledTimes(1); // Registration still proceeds. expect(existsSync(join(tmpHome, '.cursor', 'mcp.json'))).toBe(true); + + fetchSpy.mockRestore(); + }); + + // ── Phase-2: monorepo context detection + --installed/--monorepo flags ── + + // Helper: parse the single canonical JSON object from spied stdout. + // Codex Round-2 Bug B: --print-only stdout is now contractually a + // single JSON document (the VSCode-shape note + secondary block + // moved to stderr). `JSON.parse(all)` would also work, but we + // keep the brace-walking shape so leading/trailing whitespace + // around the JSON body never trips the parser. + const parseStdoutJson = ( + spy: ReturnType, + ): Record => { + const all = (spy.mock.calls as any[]).map((c) => String(c[0])).join(''); + const start = all.indexOf('{'); + if (start < 0) throw new Error(`No JSON object in stdout: ${JSON.stringify(all)}`); + let depth = 0; + let inString = false; + let escaped = false; + for (let i = start; i < all.length; i++) { + const ch = all[i]; + if (escaped) { escaped = false; continue; } + if (ch === '\\') { escaped = true; continue; } + if (ch === '"') { inString = !inString; continue; } + if (inString) continue; + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) { + return JSON.parse(all.slice(start, i + 1)); + } + } + } + throw new Error(`Unbalanced JSON object in stdout: ${JSON.stringify(all)}`); + }; + + // Codex Bug 3: tests that pass a fake monorepoRoot via the + // findDkgMonorepoRoot stub MUST also pre-create + // `/packages/cli/dist/cli.js` because canonicalEntry now + // existsSync-checks the path before returning the monorepo entry. + // This helper does both: builds a fake root under tmpHome, creates + // the dist file as an empty placeholder, returns the root path. + function makeFakeMonorepoRoot(): string { + const root = join(tmpHome, 'fake-monorepo'); + const distDir = join(root, 'packages', 'cli', 'dist'); + mkdirSync(distDir, { recursive: true }); + writeFileSync(join(distDir, 'cli.js'), '// fake CLI dist for tests\n'); + return root; + } + + it('phase-2: --print-only with monorepo auto-detect emits the local-CLI-dist absolute-path form', async () => { + const fakeRepoRoot = makeFakeMonorepoRoot(); + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + }); + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await mcpSetupAction({ printOnly: true }, deps); + + const parsed = parseStdoutJson(stdoutSpy); + // Codex Bug 2: command is `process.execPath` (absolute path to + // the running Node binary), not bare `'node'`. args[0] is the + // absolute path to the contributor's local CLI dist as produced + // by path.join — platform-native separators. + expect(parsed.mcpServers.dkg.command).toBe(process.execPath); + expect(parsed.mcpServers.dkg.args[0]).toBe( + join(fakeRepoRoot, 'packages', 'cli', 'dist', 'cli.js'), + ); + expect(parsed.mcpServers.dkg.args.slice(1)).toEqual(['mcp', 'serve']); + stdoutSpy.mockRestore(); + }); + + it('phase-2: --print-only with no monorepo detected emits the standard `dkg` installed form', async () => { + const deps = makeDeps(); // findDkgMonorepoRoot defaults to returning null + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await mcpSetupAction({ printOnly: true }, deps); + + const parsed = parseStdoutJson(stdoutSpy); + expect(parsed.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + stdoutSpy.mockRestore(); + }); + + it('phase-2: --installed forces the standard form even from inside a monorepo', async () => { + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => makeFakeMonorepoRoot()), + }); + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await mcpSetupAction({ printOnly: true, installed: true }, deps); + + const parsed = parseStdoutJson(stdoutSpy); + expect(parsed.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + stdoutSpy.mockRestore(); + }); + + it('phase-2: --monorepo from outside any monorepo throws the canonical error', async () => { + const deps = makeDeps(); // findDkgMonorepoRoot returns null + await expect( + mcpSetupAction({ printOnly: true, monorepo: true }, deps), + ).rejects.toThrow(/--monorepo flag passed but no DKG monorepo root/); + }); + + it('phase-2: --installed and --monorepo together throw the mutual-exclusion error', async () => { + const deps = makeDeps(); + await expect( + mcpSetupAction({ printOnly: true, installed: true, monorepo: true }, deps), + ).rejects.toThrow(/mutually exclusive/); + }); + + it('phase-2: a stored installed-form entry classifies as `stale` when run in monorepo mode', async () => { + // Stale-across-context: a config with the `dkg` (installed) form + // is correct when the user is on the global install but stale + // when they switch to a dev-checkout invocation. Asserts the + // staleness detection compares against the context-aware + // canonical entry, not a hardcoded form. + const fakeRepoRoot = makeFakeMonorepoRoot(); + const cursorDir = join(tmpHome, '.cursor'); + mkdirSync(cursorDir, { recursive: true }); + // Pre-populate Cursor with the installed-form entry. + writeFileSync( + join(cursorDir, 'mcp.json'), + JSON.stringify( + { mcpServers: { dkg: { command: 'dkg', args: ['mcp', 'serve'] } } }, + null, + 2, + ), + ); + + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + throw new Error('connection refused'); + }); + + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + // Post-write, the config now carries the monorepo-form entry — + // command is `process.execPath` (Codex Bug 2), args[0] is the + // absolute CLI dist path. + const after = JSON.parse(readFileSync(join(cursorDir, 'mcp.json'), 'utf-8')); + expect(after.mcpServers.dkg.command).toBe(process.execPath); + expect(after.mcpServers.dkg.args[0]).toBe( + join(fakeRepoRoot, 'packages', 'cli', 'dist', 'cli.js'), + ); + expect(after.mcpServers.dkg.args.slice(1)).toEqual(['mcp', 'serve']); + + fetchSpy.mockRestore(); + }); + + // ── Phase-3: Claude Desktop + Windsurf detection + write ────────── + + /** + * Helper: resolve the per-platform Claude Desktop config path under + * a fake home root. Mirrors the production `claudeDesktopPaths` + * resolver byte-for-byte so the test pins what the production + * code does on whatever platform is running this test. + */ + function claudeDesktopPathUnder(fakeHome: string): string { + const p = platform(); + if (p === 'darwin') { + return join(fakeHome, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'); + } + if (p === 'win32') { + const appData = process.env.APPDATA ?? join(fakeHome, 'AppData', 'Roaming'); + return join(appData, 'Claude', 'claude_desktop_config.json'); + } + // Codex Round-6 Fix 9: Linux honours XDG_CONFIG_HOME when set. + const configBase = process.env.XDG_CONFIG_HOME ?? join(fakeHome, '.config'); + return join(configBase, 'Claude', 'claude_desktop_config.json'); + } + + it('phase-3: Claude Desktop is detected when its config dir exists; gets canonical entry written', async () => { + // Pre-create the per-platform config directory so detection + // fires even though the file doesn't exist yet (parent-dir + // existence is a sufficient detection signal). + const claudePath = claudeDesktopPathUnder(tmpHome); + mkdirSync(join(claudePath, '..'), { recursive: true }); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + expect(existsSync(claudePath)).toBe(true); + const written = JSON.parse(readFileSync(claudePath, 'utf-8')); + expect(written.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + }); + + it('phase-3: Windsurf is detected at ~/.codeium/windsurf/; gets canonical entry written', async () => { + const windsurfPath = join(tmpHome, '.codeium', 'windsurf', 'mcp_config.json'); + mkdirSync(join(windsurfPath, '..'), { recursive: true }); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + expect(existsSync(windsurfPath)).toBe(true); + const written = JSON.parse(readFileSync(windsurfPath, 'utf-8')); + expect(written.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + }); + + it('phase-3: clients with no config dir are not detected — silent and absent', async () => { + // Cursor's parent dir exists (we'll pre-create it), but Claude + // Desktop's and Windsurf's do NOT — so only Cursor should be + // touched. Pins the "permissive but only when the parent + // directory exists" detection contract. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + expect(existsSync(join(tmpHome, '.cursor', 'mcp.json'))).toBe(true); + expect(existsSync(claudeDesktopPathUnder(tmpHome))).toBe(false); + expect(existsSync(join(tmpHome, '.codeium', 'windsurf', 'mcp_config.json'))).toBe(false); + }); + + it('phase-3: pre-existing Claude Desktop entry on a sibling key is preserved', async () => { + // Common real-world shape: a Claude Desktop user already has + // other MCP servers registered. The setup must merge — write + // `dkg` alongside without clobbering siblings. + const claudePath = claudeDesktopPathUnder(tmpHome); + mkdirSync(join(claudePath, '..'), { recursive: true }); + writeFileSync( + claudePath, + JSON.stringify( + { + mcpServers: { + 'some-other-server': { command: 'foo', args: ['bar'] }, + }, + }, + null, + 2, + ), + ); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const written = JSON.parse(readFileSync(claudePath, 'utf-8')); + // Sibling preserved. + expect(written.mcpServers['some-other-server']).toEqual({ command: 'foo', args: ['bar'] }); + // dkg added. + expect(written.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + }); + + // ── Phase-4: VSCode + Copilot Chat (servers.dkg shape) ──────────── + + /** + * Helper: resolve VSCode + Copilot Chat's per-platform user-mcp + * config path under a fake home root. Mirrors the production + * `vscodeMcpPaths` resolver so the test pins exactly what the + * production code does on this platform. + */ + function vscodeMcpPathUnder(fakeHome: string): string { + const p = platform(); + if (p === 'darwin') { + return join(fakeHome, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'); + } + if (p === 'win32') { + const appData = process.env.APPDATA ?? join(fakeHome, 'AppData', 'Roaming'); + return join(appData, 'Code', 'User', 'mcp.json'); + } + // Codex Round-6 Fix 9: Linux honours XDG_CONFIG_HOME when set. + const configBase = process.env.XDG_CONFIG_HOME ?? join(fakeHome, '.config'); + return join(configBase, 'Code', 'User', 'mcp.json'); + } + + it('phase-4: VSCode + Copilot Chat is detected and writes under `servers.dkg` (not `mcpServers.dkg`)', async () => { + const vscodePath = vscodeMcpPathUnder(tmpHome); + mkdirSync(join(vscodePath, '..'), { recursive: true }); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + expect(existsSync(vscodePath)).toBe(true); + const written = JSON.parse(readFileSync(vscodePath, 'utf-8')); + // VSCode + Copilot Chat keys under `servers`, NOT `mcpServers`. + // Pins the entryPath dispatch wired in phase 1. + expect(written.servers?.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + // The canonical `mcpServers.dkg` shape MUST NOT be present in + // VSCode's file — that would be the wrong key for Copilot Chat. + expect(written.mcpServers).toBeUndefined(); + }); + + it('phase-4: pre-existing VSCode `servers.` siblings are preserved on merge', async () => { + const vscodePath = vscodeMcpPathUnder(tmpHome); + mkdirSync(join(vscodePath, '..'), { recursive: true }); + writeFileSync( + vscodePath, + JSON.stringify( + { servers: { 'other-mcp': { command: 'baz' } } }, + null, + 2, + ), + ); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const written = JSON.parse(readFileSync(vscodePath, 'utf-8')); + expect(written.servers['other-mcp']).toEqual({ command: 'baz' }); + expect(written.servers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + }); + + // ── Phase-5: Cline (deep-nested VSCode globalStorage path) ──────── + + /** + * Helper: resolve Cline's per-platform globalStorage settings + * path under a fake home root. Mirrors the production + * `clineMcpPaths` resolver byte-for-byte. + */ + function clineMcpPathUnder(fakeHome: string): string { + const suffix = join( + 'globalStorage', + 'saoudrizwan.claude-dev', + 'settings', + 'cline_mcp_settings.json', + ); + const p = platform(); + if (p === 'darwin') { + return join(fakeHome, 'Library', 'Application Support', 'Code', 'User', suffix); + } + if (p === 'win32') { + const appData = process.env.APPDATA ?? join(fakeHome, 'AppData', 'Roaming'); + return join(appData, 'Code', 'User', suffix); + } + // Codex Round-6 Fix 9: Linux honours XDG_CONFIG_HOME when set. + const configBase = process.env.XDG_CONFIG_HOME ?? join(fakeHome, '.config'); + return join(configBase, 'Code', 'User', suffix); + } + + it('phase-5: Cline is detected at VSCode globalStorage and writes canonical `mcpServers.dkg`', async () => { + const clinePath = clineMcpPathUnder(tmpHome); + // Pre-create the parent dir (the deep-nested + // globalStorage/saoudrizwan.claude-dev/settings/ chain) so + // detection fires. + mkdirSync(join(clinePath, '..'), { recursive: true }); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + expect(existsSync(clinePath)).toBe(true); + const written = JSON.parse(readFileSync(clinePath, 'utf-8')); + // Cline keys under canonical `mcpServers.dkg` (unlike VSCode's + // `servers.dkg`), so no entryPath override on the candidate. + expect(written.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + }); + + it('phase-5: Cline siblings preserved — pre-existing entries don\'t get clobbered', async () => { + const clinePath = clineMcpPathUnder(tmpHome); + mkdirSync(join(clinePath, '..'), { recursive: true }); + writeFileSync( + clinePath, + JSON.stringify( + { mcpServers: { 'github': { command: 'gh-mcp' } } }, + null, + 2, + ), + ); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const written = JSON.parse(readFileSync(clinePath, 'utf-8')); + expect(written.mcpServers['github']).toEqual({ command: 'gh-mcp' }); + expect(written.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + }); + + // ── F30: resolve absolute `dkg` bin path on installed-mode setup ── + + // ── Codex Round-4: process.execPath unification ────────────────── + + it('Codex Round-4: installed mode writes process.execPath + cli.js path (no `dkg` bin shim)', async () => { + // Round-4 unified the canonical entry shape across both modes. + // Installed mode: `{ command: process.execPath, args: [, + // 'mcp', 'serve'] }` — Node binary directly + the cli.js script + // Node is currently executing. No more `which dkg` step; no + // dependency on `dkg` shim or `node` binary being on the GUI + // client's PATH. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const deps = makeDeps(); + + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const cursorConfig = JSON.parse(readFileSync(join(tmpHome, '.cursor', 'mcp.json'), 'utf-8')); + expect(cursorConfig.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + // Specific shape pins: + expect(cursorConfig.mcpServers.dkg.command).toBe(process.execPath); + expect(typeof cursorConfig.mcpServers.dkg.args[0]).toBe('string'); + expect(cursorConfig.mcpServers.dkg.args.slice(1)).toEqual(['mcp', 'serve']); + // Belt-and-braces: the registered command MUST NOT be the bare + // `dkg` shim form anymore — that's the F30 PATH-dependency we + // removed by switching to direct-Node invocation. + expect(cursorConfig.mcpServers.dkg.command).not.toBe('dkg'); + }); + + it('Codex Round-4: monorepo mode writes process.execPath + local cli.dist path', async () => { + // Monorepo mode is byte-aligned with installed mode on the + // command field (process.execPath) and differs only on args[0] + // (local cli.dist absolute path vs the installed cli.js path + // realpathSync resolves to). Asserts the unification. + const fakeRepoRoot = makeFakeMonorepoRoot(); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + }); + + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const cursorConfig = JSON.parse(readFileSync(join(tmpHome, '.cursor', 'mcp.json'), 'utf-8')); + expect(cursorConfig.mcpServers.dkg.command).toBe(process.execPath); + expect(cursorConfig.mcpServers.dkg.args[0]).toBe( + join(fakeRepoRoot, 'packages', 'cli', 'dist', 'cli.js'), + ); + expect(cursorConfig.mcpServers.dkg.args.slice(1)).toEqual(['mcp', 'serve']); + }); + + it('Codex Round-4: legacy bare-"dkg" entries auto-migrate to process.execPath form on stock re-run', async () => { + // Pre-Round-4 setup runs (or pre-F30 hand-edited configs) wrote + // `{ command: "dkg", args: ["mcp", "serve"] }`. Round-4's pure + // string equality classifier sees that as `stale` against the + // new `process.execPath + cli.js` expected entry, and refreshes + // to the new shape on a stock re-run — no `--force` needed. + // + // This is the migration story for users upgrading from any + // earlier version of the setup tool. + const cursorDir = join(tmpHome, '.cursor'); + mkdirSync(cursorDir, { recursive: true }); + writeFileSync( + join(cursorDir, 'mcp.json'), + JSON.stringify( + { mcpServers: { dkg: { command: 'dkg', args: ['mcp', 'serve'] } } }, + null, + 2, + ), + ); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const after = JSON.parse(readFileSync(join(cursorDir, 'mcp.json'), 'utf-8')); + expect(after.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + expect(after.mcpServers.dkg.command).toBe(process.execPath); + }); + + it('Codex Round-4: pre-existing F30-style absolute `dkg` bin entry ALSO migrates (uniform classifier)', async () => { + // The interim Round-1 F30 form was `{ command: "/usr/local/bin/ + // dkg", args: ["mcp", "serve"] }`. Round-4's process.execPath + // form supersedes it (skips the bin shim entirely). Pure + // string equality classifies the old absolute-bin entry as + // `stale` and migrates it forward — no special-casing needed. + const cursorDir = join(tmpHome, '.cursor'); + mkdirSync(cursorDir, { recursive: true }); + const legacyAbsBin = platform() === 'win32' + ? 'C:\\Users\\test\\AppData\\Local\\fnm\\dkg.exe' + : '/usr/local/bin/dkg'; + writeFileSync( + join(cursorDir, 'mcp.json'), + JSON.stringify( + { mcpServers: { dkg: { command: legacyAbsBin, args: ['mcp', 'serve'] } } }, + null, + 2, + ), + ); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const after = JSON.parse(readFileSync(join(cursorDir, 'mcp.json'), 'utf-8')); + expect(after.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + expect(after.mcpServers.dkg.command).not.toBe(legacyAbsBin); + }); + + // ── F31: per-client interactive confirm prompts ─────────────────── + + it('F31: --yes skips prompts; confirmPlan stub passes plan through unchanged', async () => { + // The action MUST call confirmPlan with `yes: true` so the + // stub knows the operator opted into auto-confirm. The stub + // returns the plan unchanged → all detected clients register. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const confirmPlan = vi.fn(async (planned: any) => [...planned]); + const deps = makeDeps({ confirmPlan }); + + await mcpSetupAction({ start: false, fund: false, verify: false, yes: true }, deps); + + expect(confirmPlan).toHaveBeenCalledTimes(1); + expect(confirmPlan.mock.calls[0][1]).toEqual({ yes: true }); + expect(existsSync(join(tmpHome, '.cursor', 'mcp.json'))).toBe(true); + }); + + it('F31: confirmPlan-stub-says-no on a single-client plan → zero writes', async () => { + // Operator declined the only pending registration. The action + // emits the "All pending registrations declined" log line and + // writes nothing. Asserts the decline path is non-fatal and + // the file stays absent. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const confirmPlan = vi.fn(async (planned: any) => + planned.map((p: any) => ({ ...p, action: 'skip' })), + ); + const deps = makeDeps({ confirmPlan }); + + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + expect(confirmPlan).toHaveBeenCalledTimes(1); + expect(existsSync(join(tmpHome, '.cursor', 'mcp.json'))).toBe(false); + const logged = (logSpy.mock.calls as any[]).map((c) => c.join(' ')).join('\n'); + expect(logged).toMatch(/All pending registrations declined/); + // Codex Round-5 Fix 7: the guidance recommends `--yes` (skips + // prompts), not `--force` alone (which only refreshes + // already-registered clients but still prompts in TTY mode). A + // re-run with `--force` would re-prompt the same declined + // entries; only `--yes` (or `--force --yes`) escapes the prompt + // loop. + expect(logged).toMatch(/--yes/); + expect(logged).not.toMatch(/Re-run with --force or --yes/); + }); + + it('F31: mixed yes/no — declined entries skip; accepted entries register', async () => { + // Two clients pending. Stub declines Cursor, accepts Claude + // Desktop. Post-action: only Claude Desktop's file exists. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const claudePath = claudeDesktopPathUnder(tmpHome); + mkdirSync(join(claudePath, '..'), { recursive: true }); + + const confirmPlan = vi.fn(async (planned: any) => + planned.map((p: any) => + p.s.target.name === 'Cursor' ? { ...p, action: 'skip' } : p, + ), + ); + const deps = makeDeps({ confirmPlan }); + + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + expect(existsSync(join(tmpHome, '.cursor', 'mcp.json'))).toBe(false); + expect(existsSync(claudePath)).toBe(true); + }); + + it('F31: all-skip plan (everything already registered) → confirmPlan still called but produces zero writes', async () => { + // Pre-populate every detected-by-default client with the + // Round-4 canonical entry (process.execPath + cli.js path) so + // they all classify as `registered`. Plan ends up all-skip; + // confirmPlan still called (the action doesn't pre-filter) but + // no writes follow. + const cursorDir = join(tmpHome, '.cursor'); + mkdirSync(cursorDir, { recursive: true }); + const canonical = { mcpServers: { dkg: EXPECTED_INSTALLED_ENTRY() } }; + writeFileSync(join(cursorDir, 'mcp.json'), JSON.stringify(canonical, null, 2)); + // ~/.claude.json's parent IS tmpHome → always detected. Pre-register. + writeFileSync(join(tmpHome, '.claude.json'), JSON.stringify(canonical, null, 2)); + const beforeCursor = (await import('node:fs')).statSync(join(cursorDir, 'mcp.json')).mtimeMs; + const beforeClaude = (await import('node:fs')).statSync(join(tmpHome, '.claude.json')).mtimeMs; + + const confirmPlan = vi.fn(async (planned: any) => [...planned]); + const deps = makeDeps({ confirmPlan }); + + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + // No rewrite of either file — both existing entries' mtimes + // are unchanged. + const afterCursor = (await import('node:fs')).statSync(join(cursorDir, 'mcp.json')).mtimeMs; + const afterClaude = (await import('node:fs')).statSync(join(tmpHome, '.claude.json')).mtimeMs; + expect(afterCursor).toBe(beforeCursor); + expect(afterClaude).toBe(beforeClaude); + // The "all up-to-date" log line fires (the original phrasing, + // NOT the F31 declined-prompt phrasing). + const logged = (logSpy.mock.calls as any[]).map((c) => c.join(' ')).join('\n'); + expect(logged).toMatch(/Clients all up-to-date/); + expect(logged).not.toMatch(/All pending registrations declined/); + }); + + it('F31: dry-run skips confirmPlan entirely (preview-only; no point asking about non-writes)', async () => { + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const confirmPlan = vi.fn(async (planned: any) => [...planned]); + const deps = makeDeps({ confirmPlan }); + + await mcpSetupAction({ dryRun: true }, deps); + + expect(confirmPlan).not.toHaveBeenCalled(); + expect(existsSync(join(tmpHome, '.cursor', 'mcp.json'))).toBe(false); + }); + + it('F31: production confirmPlan auto-confirms when stdin.isTTY is false (CI / piped input)', async () => { + // Direct test of the production helper (not the stub) — we + // import it from the same module and call it without going + // through `mcpSetupAction`. This pins the non-TTY auto-confirm + // contract that lets CI runs work without `--yes`. + const { confirmPlan: prodConfirmPlan } = await import('../src/mcp-setup.js'); + const fakePlan = [ + { s: { target: { name: 'Cursor', displayPath: '~/.cursor/mcp.json' } } as any, action: 'register' as const }, + { s: { target: { name: 'Claude Code', displayPath: '~/.claude.json' } } as any, action: 'refresh' as const }, + ]; + + // Vitest already runs non-TTY; document the assumption and + // assert the no-prompt path returns the plan unchanged. + expect(process.stdin.isTTY).toBeFalsy(); + const result = await prodConfirmPlan(fakePlan, { yes: false }); + expect(result).toHaveLength(2); + expect(result.map((p) => p.action)).toEqual(['register', 'refresh']); + }); + + it('Codex Round-4 Fix 5: confirmPlan auto-confirms when stdout.isTTY is false even if stdin.isTTY is true', async () => { + // Pre-fix: the auto-confirm guard only checked + // `process.stdin.isTTY`. If stdout was redirected/captured but + // stdin still happened to be a TTY (e.g. `dkg mcp setup > log.txt` + // from an interactive shell), the helper opened a readline + // prompt that emitted to a non-visible stdout — the user saw + // nothing while their terminal blocked. + // + // Post-fix: BOTH stdin AND stdout must be TTY before prompting. + // Either non-TTY end ⇒ auto-confirm. + const { confirmPlan: prodConfirmPlan } = await import('../src/mcp-setup.js'); + const fakePlan = [ + { s: { target: { name: 'Cursor', displayPath: '~/.cursor/mcp.json' } } as any, action: 'register' as const }, + ]; + + const originalStdinIsTTY = process.stdin.isTTY; + const originalStdoutIsTTY = process.stdout.isTTY; + try { + // Force stdin TTY=true (the scenario the pre-fix missed), + // stdout TTY=false (redirected). The post-fix guard MUST + // auto-confirm and not block on readline. + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + configurable: true, + }); + + // No timeout / hang protection needed — if the guard regresses + // and the helper actually prompts, vitest's per-test timeout + // catches it. Under the post-fix guard, this resolves + // synchronously with the plan unchanged. + const result = await prodConfirmPlan(fakePlan, { yes: false }); + expect(result).toHaveLength(1); + expect(result[0].action).toBe('register'); + } finally { + // Restore the original TTY flags so subsequent tests aren't + // affected by the override. + Object.defineProperty(process.stdin, 'isTTY', { + value: originalStdinIsTTY, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: originalStdoutIsTTY, + configurable: true, + }); + } + }); + + // ── PR #394 Codex review round 1 ──────────────────────────────── + + it('Codex Bug 1: detectContext passes process.cwd() to findDkgMonorepoRoot', async () => { + // Pre-fix: findDkgMonorepoRoot() was called with no argument, + // defaulting to the dirname of @origintrail-official/dkg-core's + // installed location. For a globally-installed CLI run from + // inside a user's monorepo cwd, that walks node_modules/... + // not the user's cwd → monorepo auto-detect never fires. + // Post-fix: cwd is passed explicitly. + const fakeRepoRoot = makeFakeMonorepoRoot(); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const findStub = vi.fn((startDir?: string) => { + // Mimic the production semantic: only return monorepo root + // when startDir is something inside the monorepo. Without the + // Bug 1 fix, startDir would be undefined here (default arg). + return startDir ? fakeRepoRoot : null; + }); + const deps = makeDeps({ findDkgMonorepoRoot: findStub }); + + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + // The stub was called with a defined startDir argument (the + // production code now passes process.cwd() explicitly). + expect(findStub).toHaveBeenCalled(); + const callArg = findStub.mock.calls[0][0]; + expect(callArg).toBeDefined(); + expect(typeof callArg).toBe('string'); + // Monorepo mode fired: the entry uses execPath + cli.js, not the + // bare-`"dkg"` installed form. + const cursorConfig = JSON.parse(readFileSync(join(tmpHome, '.cursor', 'mcp.json'), 'utf-8')); + expect(cursorConfig.mcpServers.dkg.command).toBe(process.execPath); + }); + + it('Codex Bug 2: monorepo entry uses process.execPath, not bare "node"', async () => { + // Pre-fix: command was hard-coded to 'node'. Same PATH- + // inheritance failure as bare-`"dkg"` for GUI MCP clients. + // Post-fix: process.execPath (absolute path to running Node). + const fakeRepoRoot = makeFakeMonorepoRoot(); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + }); + + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const cursorConfig = JSON.parse(readFileSync(join(tmpHome, '.cursor', 'mcp.json'), 'utf-8')); + // process.execPath is always an absolute path; assert that + // shape rather than hardcoding the runtime-specific value. + expect(cursorConfig.mcpServers.dkg.command).toBe(process.execPath); + expect(cursorConfig.mcpServers.dkg.command).not.toBe('node'); + // Sanity: it's actually absolute on this platform. + expect(cursorConfig.mcpServers.dkg.command.length).toBeGreaterThan(4); + }); + + it('Codex Bug 3: monorepo mode errors clearly when local cli.dist/cli.js is missing', async () => { + // Fresh checkout / pnpm clean / source-only edits all leave + // dist absent. Pre-fix: setup wrote a broken entry that points + // at a non-existent file, overwriting a previously-working + // installed registration. Post-fix: throws an actionable error + // and writes nothing. + const fakeRepoRoot = join(tmpHome, 'fake-monorepo-no-dist'); + // Deliberately do NOT create packages/cli/dist/cli.js — root exists + // (so findDkgMonorepoRoot's stub returning it is plausible) but + // the dist file is absent. + mkdirSync(fakeRepoRoot, { recursive: true }); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + }); + + await expect( + mcpSetupAction({ start: false, fund: false, verify: false }, deps), + ).rejects.toThrow(/Local CLI dist not found at .*Run `pnpm.*build` first/); + + // No client config was written; the previously-empty Cursor + // dir stays empty (no file touched). + expect(existsSync(join(tmpHome, '.cursor', 'mcp.json'))).toBe(false); + }); + + it('Codex Issue 5 + Round-2 Bug B: --print-only stdout stays pure canonical JSON; VSCode note goes to stderr', async () => { + // Round-1 of Issue 5: --print-only appended a second JSON block + // + prose to stdout to disambiguate VSCode's `servers.dkg` + // shape. Round-2 Codex feedback: that broke the + // `dkg mcp setup --print-only | jq …` flag contract — stdout + // must be a single canonical JSON document. Final shape: stdout + // stays the canonical `mcpServers.dkg` block (single JSON + // document, parses cleanly with `jq`), and the VSCode-shape + // note is emitted on stderr instead. + const deps = makeDeps(); + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + await mcpSetupAction({ printOnly: true }, deps); + + // STDOUT: a single JSON document, parseable as-is — no prose, + // no second object. This is the `dkg mcp setup --print-only | + // jq …` flag contract. + const stdoutText = (stdoutSpy.mock.calls as any[]).map((c) => String(c[0])).join(''); + const stdoutParsed = JSON.parse(stdoutText); + expect(stdoutParsed.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + // No `servers.dkg` (the VSCode shape) on stdout — keeps it + // machine-readable. + expect(stdoutParsed.servers).toBeUndefined(); + + // STDERR: the VSCode-shape disambiguation note + a second JSON + // block under `servers.dkg`. Same entry contents as the canonical + // block — pinning that the note isn't drift. + const stderrText = (stderrSpy.mock.calls as any[]).map((c) => String(c[0])).join(''); + expect(stderrText).toMatch(/VSCode/i); + expect(stderrText).toMatch(/servers\.dkg/); + // The stderr note contains a parseable `{ servers: { dkg: ... } }` + // block; extract the JSON portion (between the first `{` and the + // matching closing `}`) and parse it. + const stderrJsonStart = stderrText.indexOf('{'); + expect(stderrJsonStart).toBeGreaterThanOrEqual(0); + const stderrJsonText = stderrText.slice(stderrJsonStart).trim(); + const stderrParsed = JSON.parse(stderrJsonText); + expect(stderrParsed.servers?.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + }); + + it('phase-4: VSCode staleness — pre-existing dkg entry under `servers.dkg` reclassifies on context flip to monorepo', async () => { + // Cross-shape staleness: a Cursor-shaped entry written into + // VSCode's `servers.dkg` wouldn't classify as `registered` if + // the canonical entry's command/args differ. Here we pin the + // installed→monorepo flip works for VSCode the same as for + // Cursor (phase-2 covered the Cursor case). + const fakeRepoRoot = makeFakeMonorepoRoot(); + const vscodePath = vscodeMcpPathUnder(tmpHome); + mkdirSync(join(vscodePath, '..'), { recursive: true }); + writeFileSync( + vscodePath, + JSON.stringify( + { servers: { dkg: { command: 'dkg', args: ['mcp', 'serve'] } } }, + null, + 2, + ), + ); + + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + }); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + throw new Error('connection refused'); + }); + + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const written = JSON.parse(readFileSync(vscodePath, 'utf-8')); + // Codex Bug 2: command is process.execPath, not bare 'node'. + expect(written.servers.dkg.command).toBe(process.execPath); + expect(written.servers.dkg.args[0]).toBe( + join(fakeRepoRoot, 'packages', 'cli', 'dist', 'cli.js'), + ); + + fetchSpy.mockRestore(); + }); + + // ── Codex Round-2 review fixes ──────────────────────────────────── + + it('Codex Round-2 Bug A: monorepo context routes DKG home to dev dir + sets DKG_HOME', async () => { + // Pre-fix: mcpSetupAction hard-coded `~/.dkg` regardless of + // monorepo detection. The registered local CLI dist (whose + // own dkgDir() resolves to `~/.dkg-dev` from inside the + // monorepo) would read a different home than mcp-setup just + // bootstrapped — config / daemon / faucet split across two + // dirs. Post-fix: thread the monorepo signal into + // `resolveDkgConfigHome({ isDkgMonorepo: true })` and set + // `DKG_HOME` so adapter-openclaw's dkgDir() and dkg-core's + // daemon-lifecycle agree on the dev home. + const fakeRepoRoot = makeFakeMonorepoRoot(); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + let isDkgMonorepoArg: boolean | undefined; + let dkgHomeAtWriteCall: string | undefined; + let dkgHomeAtStartDaemonCall: string | undefined; + + const resolveDkgConfigHomeSpy = vi.fn((opts: { isDkgMonorepo?: boolean } = {}) => { + isDkgMonorepoArg = opts.isDkgMonorepo; + const dir = opts.isDkgMonorepo ? join(tmpHome, '.dkg-dev') : join(tmpHome, '.dkg'); + mkdirSync(dir, { recursive: true }); + return dir; + }); + + const ensureDkgNodeConfigSpy = vi.fn((opts: any) => { + // Capture the env at the moment ensureDkgNodeConfig is invoked + // so we can assert that DKG_HOME was set BEFORE step 1's write. + dkgHomeAtWriteCall = process.env.DKG_HOME; + const dir = process.env.DKG_HOME ?? join(tmpHome, '.dkg'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'config.json'), + JSON.stringify({ name: opts.agentName, apiPort: opts.apiPort, nodeRole: 'edge' }, null, 2), + ); + }); + + const startDaemonSpy = vi.fn(async (_port: number) => { + dkgHomeAtStartDaemonCall = process.env.DKG_HOME; + }); + + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + resolveDkgConfigHome: resolveDkgConfigHomeSpy, + ensureDkgNodeConfig: ensureDkgNodeConfigSpy, + startDaemon: startDaemonSpy, + }); + + await mcpSetupAction({ fund: false, verify: false }, deps); + + // (1) resolveDkgConfigHome was called with isDkgMonorepo: true — + // the monorepo signal threaded through. + expect(isDkgMonorepoArg).toBe(true); + + // (2) DKG_HOME was set BEFORE step 1's writeDkgConfig and was + // still set BEFORE step 2's startDaemon. Both downstream + // primitives delegate to dkgDir() which respects this env var, + // so all four flows (mcp-setup, openclaw, core daemon-lifecycle, + // and the registered local CLI) land in the SAME home. + expect(dkgHomeAtWriteCall).toBe(join(tmpHome, '.dkg-dev')); + expect(dkgHomeAtStartDaemonCall).toBe(join(tmpHome, '.dkg-dev')); + + // (3) The bootstrapped config landed in the dev home, not ~/.dkg. + expect(existsSync(join(tmpHome, '.dkg-dev', 'config.json'))).toBe(true); + expect(existsSync(join(tmpHome, '.dkg', 'config.json'))).toBe(false); + }); + + it('Codex Round-2 Bug A: installed context keeps DKG home at ~/.dkg (no dev-dir leak)', async () => { + // Counterpart to the monorepo case: when no monorepo is + // detected, DKG home stays at the canonical `~/.dkg`. Pre-fix + // and post-fix behaviour byte-aligned for installed-mode users. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + let isDkgMonorepoArg: boolean | undefined; + const resolveDkgConfigHomeSpy = vi.fn((opts: { isDkgMonorepo?: boolean } = {}) => { + isDkgMonorepoArg = opts.isDkgMonorepo; + return join(tmpHome, '.dkg'); + }); + + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => null), + resolveDkgConfigHome: resolveDkgConfigHomeSpy, + }); + + // Capture DKG_HOME at write time (mid-action) — the only + // observable point where the env mutation is visible. Round-3 + // Fix 3 added a try/finally that restores DKG_HOME after the + // action returns, so reading it post-`await` no longer reflects + // the in-action value. + let dkgHomeAtWriteCall: string | undefined; + const ensureDkgNodeConfigSpy = vi.fn((opts: any) => { + dkgHomeAtWriteCall = process.env.DKG_HOME; + const dir = process.env.DKG_HOME ?? join(tmpHome, '.dkg'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'config.json'), + JSON.stringify({ name: opts.agentName, apiPort: opts.apiPort, nodeRole: 'edge' }, null, 2), + ); + }); + (deps as any).ensureDkgNodeConfig = ensureDkgNodeConfigSpy; + + await mcpSetupAction({ fund: false, verify: false }, deps); + + expect(isDkgMonorepoArg).toBe(false); + // During the action, DKG_HOME was the resolved installed home. + expect(dkgHomeAtWriteCall).toBe(join(tmpHome, '.dkg')); + expect(existsSync(join(tmpHome, '.dkg', 'config.json'))).toBe(true); + // No accidental .dkg-dev creation on the installed path. + expect(existsSync(join(tmpHome, '.dkg-dev'))).toBe(false); + }); + + // ── Codex Round-5 Fix 6: --monorepo bypasses configExists fallback ─ + + it('Codex Round-5 Fix 6: --monorepo with pre-existing ~/.dkg/config.json still isolates to ~/.dkg-dev', async () => { + // Pre-fix: `--monorepo` only set `isDkgMonorepo: true` on the + // resolveDkgConfigHome call. The helper still respected the + // configExists short-circuit (Round-3 Fix 2 made it OR + // config.json | config.yaml), so a user with a pre-existing + // `~/.dkg/config.json` (typical for anyone who has ever + // installed the global CLI) who passed `--monorepo` would + // bootstrap their local checkout against the installed node's + // state — exactly the dev/installed mixup the flag is meant + // to break. + // + // Post-fix: `--monorepo` (forcedContext === 'monorepo' AND a + // monorepo root located) bypasses resolveDkgConfigHome + // entirely, computing `~/.dkg-dev` directly via homedir(). + const fakeRepoRoot = makeFakeMonorepoRoot(); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + // Pre-existing `~/.dkg/config.json` — the configExists short- + // circuit would normally redirect us back to `~/.dkg`. + const installedDkg = join(tmpHome, '.dkg'); + mkdirSync(installedDkg, { recursive: true }); + writeFileSync( + join(installedDkg, 'config.json'), + JSON.stringify({ name: 'persisted', apiPort: 9200, nodeRole: 'edge' }, null, 2), + ); + + // Real production-shape resolveDkgConfigHome stub: respects + // configExists. The Fix 6 bypass means this stub MUST NOT be + // called when `--monorepo` is forced. + const resolveDkgConfigHomeSpy = vi.fn((opts: { isDkgMonorepo?: boolean; configExists?: boolean } = {}) => { + // Mirror production: configExists wins over isDkgMonorepo. + if (opts.configExists ?? existsSync(join(installedDkg, 'config.json'))) { + return installedDkg; + } + if (opts.isDkgMonorepo) return join(tmpHome, '.dkg-dev'); + return installedDkg; + }); + + let dkgHomeAtWriteCall: string | undefined; + const ensureDkgNodeConfigSpy = vi.fn((opts: any) => { + dkgHomeAtWriteCall = process.env.DKG_HOME; + const dir = process.env.DKG_HOME ?? installedDkg; + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'config.json'), + JSON.stringify({ name: opts.agentName, apiPort: opts.apiPort, nodeRole: 'edge' }, null, 2), + ); + }); + + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + resolveDkgConfigHome: resolveDkgConfigHomeSpy, + ensureDkgNodeConfig: ensureDkgNodeConfigSpy, + }); + + await mcpSetupAction({ monorepo: true, fund: false, verify: false }, deps); + + // (1) The bypass kicked in: resolveDkgConfigHome was NOT called + // for the dkgDirPath computation under forced --monorepo. + expect(resolveDkgConfigHomeSpy).not.toHaveBeenCalled(); + // (2) DKG_HOME was set to ~/.dkg-dev mid-action — bootstrap + // state landed in the dev home, NOT the installed home. + expect(dkgHomeAtWriteCall).toBe(join(tmpHome, '.dkg-dev')); + // (3) The pre-existing installed config is untouched. + const installedConfig = JSON.parse(readFileSync(join(installedDkg, 'config.json'), 'utf-8')); + expect(installedConfig.name).toBe('persisted'); + // (4) The dev-home config was newly written. + expect(existsSync(join(tmpHome, '.dkg-dev', 'config.json'))).toBe(true); + }); + + it('Codex Round-5 Fix 6: --monorepo with pre-existing ~/.dkg/config.yaml still isolates to ~/.dkg-dev', async () => { + // Same as above but with YAML instead of JSON. Round-3 Fix 2 + // extended configExists to OR both file types; Round-5 Fix 6 + // bypasses the whole short-circuit when --monorepo is forced, + // so neither file shape redirects the dev-home isolation. + const fakeRepoRoot = makeFakeMonorepoRoot(); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const installedDkg = join(tmpHome, '.dkg'); + mkdirSync(installedDkg, { recursive: true }); + writeFileSync(join(installedDkg, 'config.yaml'), 'name: persisted\napiPort: 9200\n'); + + const resolveDkgConfigHomeSpy = vi.fn(() => installedDkg); + let dkgHomeAtWriteCall: string | undefined; + const ensureDkgNodeConfigSpy = vi.fn((opts: any) => { + dkgHomeAtWriteCall = process.env.DKG_HOME; + const dir = process.env.DKG_HOME ?? installedDkg; + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'config.json'), + JSON.stringify({ name: opts.agentName, apiPort: opts.apiPort, nodeRole: 'edge' }, null, 2), + ); + }); + + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + resolveDkgConfigHome: resolveDkgConfigHomeSpy, + ensureDkgNodeConfig: ensureDkgNodeConfigSpy, + }); + + await mcpSetupAction({ monorepo: true, fund: false, verify: false }, deps); + + expect(resolveDkgConfigHomeSpy).not.toHaveBeenCalled(); + expect(dkgHomeAtWriteCall).toBe(join(tmpHome, '.dkg-dev')); + // YAML preserved untouched. + const yaml = readFileSync(join(installedDkg, 'config.yaml'), 'utf-8'); + expect(yaml).toContain('name: persisted'); + }); + + it('Codex Round-5 Fix 6: AUTO-detect (no --monorepo flag) + monorepo cwd + existing ~/.dkg/config.json → still respects configExists, returns ~/.dkg', async () => { + // Pin the asymmetry between forced and auto. Auto-detect + // monorepo (no flag) MUST keep the configExists short-circuit + // — users who installed the CLI globally and happen to walk + // into a monorepo checkout shouldn't be silently redirected + // to a dev home they don't know about. + // + // Only the explicit --monorepo flag bypasses the fallback; + // auto-detect defers to resolveDkgConfigHome's existing + // semantics. + const fakeRepoRoot = makeFakeMonorepoRoot(); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const installedDkg = join(tmpHome, '.dkg'); + mkdirSync(installedDkg, { recursive: true }); + writeFileSync( + join(installedDkg, 'config.json'), + JSON.stringify({ name: 'persisted', apiPort: 9200, nodeRole: 'edge' }, null, 2), + ); + + let resolveCallArgs: { isDkgMonorepo?: boolean } | undefined; + const resolveDkgConfigHomeSpy = vi.fn((opts: { isDkgMonorepo?: boolean } = {}) => { + resolveCallArgs = opts; + // Mirror production semantics: configExists wins → ~/.dkg. + return installedDkg; + }); + + // Use startDaemon as the mid-action observable. With a + // pre-existing config, the action skips writeDkgConfig (F25 + // reconcile path), but startDaemon always runs and DKG_HOME is + // already set by the time it does. + let dkgHomeAtStartDaemon: string | undefined; + const startDaemonSpy = vi.fn(async (_port: number) => { + dkgHomeAtStartDaemon = process.env.DKG_HOME; + }); + + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + resolveDkgConfigHome: resolveDkgConfigHomeSpy, + startDaemon: startDaemonSpy, + }); + + // No --monorepo flag — auto-detect path. + await mcpSetupAction({ fund: false, verify: false }, deps); + + // (1) resolveDkgConfigHome WAS called (auto-detect doesn't + // bypass), and isDkgMonorepo: true was passed to it. + expect(resolveDkgConfigHomeSpy).toHaveBeenCalledTimes(1); + expect(resolveCallArgs?.isDkgMonorepo).toBe(true); + // (2) Despite the monorepo signal, configExists short-circuit + // returned ~/.dkg, and DKG_HOME mid-action reflects that. + expect(dkgHomeAtStartDaemon).toBe(installedDkg); + // (3) No accidental .dkg-dev creation on the auto-detect path + // when an installed config already exists. + expect(existsSync(join(tmpHome, '.dkg-dev'))).toBe(false); + }); + + it('Codex Round-2 Bug B: --print-only stdout is a single parseable JSON document (jq-compatible)', async () => { + // Round-1 of Issue 5 emitted the canonical JSON + prose + a + // second JSON object on stdout, breaking + // `dkg mcp setup --print-only | jq …`. Round-2 fix: stdout + // stays a single JSON document. This test asserts the strict + // contract: `JSON.parse(allStdout)` succeeds, with no leftover + // bytes after the canonical block. + const deps = makeDeps(); + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + await mcpSetupAction({ printOnly: true }, deps); + + const stdoutText = (stdoutSpy.mock.calls as any[]).map((c) => String(c[0])).join(''); + // jq-style strict parse: the entire stdout (after trimming + // trailing newline) must round-trip through JSON.parse with + // nothing left over. + const trimmed = stdoutText.trim(); + expect(() => JSON.parse(trimmed)).not.toThrow(); + const parsed = JSON.parse(trimmed); + // Exactly one top-level key: `mcpServers`. No `servers` (VSCode + // shape) on stdout. + expect(Object.keys(parsed)).toEqual(['mcpServers']); + // No prose contamination on stdout. + expect(stdoutText).not.toMatch(/Note/); + expect(stdoutText).not.toMatch(/VSCode/i); + + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + }); + + // Codex Round-2 Bug C tests retired: Round-4's process.execPath + // unification eliminated the `isAbsoluteDkgBinPath` equivalence + // those tests pinned. Both modes now write the same absolute + // process.execPath form, so any divergent entry — bare `"dkg"`, + // an old absolute-bin path, a moved-checkout cli.js path — + // classifies as `stale` via pure string equality and refreshes + // forward. The auto-migration story is exercised by the + // "legacy bare-`dkg` migrates" and "F30-style absolute migrates" + // Round-4 tests above. + + // ── Codex Round-3 Fix 3: try/finally DKG_HOME env mutation ──────── + + it('Codex Round-3 Fix 3: action throwing midway restores DKG_HOME', async () => { + // Pre-fix: `process.env.DKG_HOME = dkgDirPath` was a permanent + // global side effect. If the action threw mid-body (e.g. step 2's + // `startDaemon` rejected; step 4's client-config write hit a + // permissions error), the override leaked into the rest of the + // process and any unrelated downstream code reading DKG_HOME. + // + // Post-fix: try/finally wraps the action body. The finally + // restores the prior `DKG_HOME` value (or unsets it if it wasn't + // set going in) on BOTH throw and normal exit. + const PRIOR = '/some/external/dkg-home'; + process.env.DKG_HOME = PRIOR; + + // Force a throw mid-action: stub `startDaemon` to reject. By + // then DKG_HOME has been mutated to `/.dkg`. + const deps = makeDeps({ + startDaemon: vi.fn(async () => { + throw new Error('synthetic startDaemon failure for env-restore test'); + }), + }); + + await expect( + mcpSetupAction({ fund: false, verify: false }, deps), + ).rejects.toThrow(/synthetic startDaemon failure/); + + // The finally restored DKG_HOME to its prior value. + expect(process.env.DKG_HOME).toBe(PRIOR); + }); + + it('Codex Round-3 Fix 3: action with previously-unset DKG_HOME deletes the var on exit', async () => { + // Counterpart: when DKG_HOME wasn't set going into the action, + // the finally must DELETE it (not set to `undefined` or empty + // string), so the next caller's `process.env.DKG_HOME` lookup + // sees `undefined` and falls through to the auto-detect path. + delete process.env.DKG_HOME; + + const deps = makeDeps(); + await mcpSetupAction({ fund: false, verify: false }, deps); + + expect(process.env.DKG_HOME).toBeUndefined(); + expect('DKG_HOME' in process.env).toBe(false); + }); + + it('Codex Round-3 Fix 3: two sequential mcpSetupAction calls don\'t bleed env state', async () => { + // Two back-to-back calls with different contexts: the first + // forces monorepo (sets DKG_HOME to `/.dkg-dev`); the + // second forces installed (sets DKG_HOME to `/.dkg`). + // Without the try/finally, the second call would observe the + // first's leftover override at the top of its body when it + // calls `resolveDkgConfigHome()` — which prefers DKG_HOME over + // any other signal — and silently inherit the wrong home. + // + // Post-fix: each call's env mutation is bounded to its own + // body, so the second call observes the original (unset) + // DKG_HOME at entry and gets to make the correct context-aware + // resolution. + delete process.env.DKG_HOME; + + const fakeRepoRoot = makeFakeMonorepoRoot(); + const observedHomesAtWriteCall: string[] = []; + + const ensureDkgNodeConfigSpy = vi.fn((opts: any) => { + observedHomesAtWriteCall.push(process.env.DKG_HOME ?? ''); + const dir = process.env.DKG_HOME ?? join(tmpHome, '.dkg'); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'config.json'), + JSON.stringify({ name: opts.agentName, apiPort: opts.apiPort, nodeRole: 'edge' }, null, 2), + ); + }); + + // Call 1: force monorepo. `findDkgMonorepoRoot` stub returns + // the fake repo root; `resolveDkgConfigHome` returns dev dir. + const depsMono = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + ensureDkgNodeConfig: ensureDkgNodeConfigSpy, + }); + await mcpSetupAction({ monorepo: true, fund: false, verify: false }, depsMono); + // After call 1: DKG_HOME restored to unset. + expect(process.env.DKG_HOME).toBeUndefined(); + + // Call 2: force installed. Default `resolveDkgConfigHome` stub + // returns `/.dkg`. + const depsInstalled = makeDeps({ + ensureDkgNodeConfig: ensureDkgNodeConfigSpy, + }); + await mcpSetupAction({ installed: true, fund: false, verify: false }, depsInstalled); + // After call 2: DKG_HOME restored to unset. + expect(process.env.DKG_HOME).toBeUndefined(); + + // The two writeDkgConfig invocations saw different homes — + // dev for call 1, prod-default for call 2 — confirming no + // bleed of call 1's override into call 2. + expect(observedHomesAtWriteCall).toEqual([ + join(tmpHome, '.dkg-dev'), + join(tmpHome, '.dkg'), + ]); + }); + + // ── Codex Round-6 Fix 8: detect ephemeral install paths ────────── + + /** + * Helper: temporarily override `process.argv[1]` to a fake CLI + * script path, ensuring the file exists so `realpathSync` doesn't + * throw before `detectEphemeralInstallPath` gets to run. Returns + * a restore function the caller MUST run in `finally`. + */ + function withFakeArgv1(fakeAbsPath: string): () => void { + mkdirSync(join(fakeAbsPath, '..'), { recursive: true }); + writeFileSync(fakeAbsPath, '// fake cli.js for argv[1] override'); + const original = process.argv[1]; + process.argv[1] = fakeAbsPath; + return () => { + process.argv[1] = original; + }; + } + + it('Codex Round-6 Fix 8: npx-style ephemeral install path → throws "install globally first"', async () => { + // npx caches packages under `~/.npm/_npx//...`. A user who + // invokes `npx @origintrail-official/dkg mcp setup` would have + // `process.argv[1]` resolved to a path inside that cache; writing + // it into client configs means the registration silently breaks + // on the next `npm cache clean --force` or after the npx cache + // TTL expires. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const ephemeralPath = join(tmpHome, '.npm', '_npx', 'abc123', 'node_modules', '@origintrail-official', 'dkg', 'dist', 'cli.js'); + const restore = withFakeArgv1(ephemeralPath); + try { + const deps = makeDeps(); + await expect( + mcpSetupAction({ start: false, fund: false, verify: false }, deps), + ).rejects.toThrow(/Detected ephemeral install path \(npx cache\)/); + + // No client config was written on the throw path. + expect(existsSync(join(tmpHome, '.cursor', 'mcp.json'))).toBe(false); + } finally { + restore(); + } + }); + + it('Codex Round-6 Fix 8: pnpm-dlx-style ephemeral install path → throws "install globally first"', async () => { + // pnpm dlx stores packages under + // `~/.local/share/pnpm/dlx-/...` (or similar dlx- prefix + // paths). Same persistence problem as npx. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const ephemeralPath = join(tmpHome, '.local', 'share', 'pnpm', 'dlx-abc123', 'node_modules', '@origintrail-official', 'dkg', 'dist', 'cli.js'); + const restore = withFakeArgv1(ephemeralPath); + try { + const deps = makeDeps(); + await expect( + mcpSetupAction({ start: false, fund: false, verify: false }, deps), + ).rejects.toThrow(/Detected ephemeral install path \(pnpm dlx cache\)/); + expect(existsSync(join(tmpHome, '.cursor', 'mcp.json'))).toBe(false); + } finally { + restore(); + } + }); + + it('Codex Round-6 Fix 8: persistent global install path → no throw, normal canonical entry', async () => { + // Counterpart guard: a "real" global install path (not in any + // package-manager cache) MUST NOT be flagged as ephemeral. This + // pins the heuristic isn't over-broad — false positives would + // break normal global installs by throwing for everyone. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + // A path that looks like a normal npm global install. NOTE: we + // can't override realpathSync, so we just place the fake cli.js + // somewhere on disk that isn't matched by any of the cache + // patterns. + const persistentPath = join(tmpHome, 'usr-local-lib', 'node_modules', '@origintrail-official', 'dkg', 'dist', 'cli.js'); + const restore = withFakeArgv1(persistentPath); + try { + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + // No throw; the Cursor entry was written with the persistent + // path as args[0]. + const cursorConfig = JSON.parse(readFileSync(join(tmpHome, '.cursor', 'mcp.json'), 'utf-8')); + expect(cursorConfig.mcpServers.dkg.command).toBe(process.execPath); + expect(cursorConfig.mcpServers.dkg.args[0]).toBe(persistentPath); + expect(cursorConfig.mcpServers.dkg.args.slice(1)).toEqual(['mcp', 'serve']); + } finally { + restore(); + } + }); + + // ── Codex Round-6 Fix 9: respect XDG_CONFIG_HOME on Linux paths ── + + it('Codex Round-6 Fix 9: Linux Claude Desktop with XDG_CONFIG_HOME → detected at custom location', async () => { + // The detection on Linux MUST defer to XDG_CONFIG_HOME when the + // operator has set it (common in dotfile-managed setups). Pre-fix + // the path was hardcoded to `~/.config/Claude/...` regardless, + // so users with a relocated config dir were invisible. + if (platform() === 'win32') { + // Windows uses %APPDATA%, not XDG; this test is Linux/macOS + // only. Skip on Windows so the suite stays cross-platform + // green. (macOS uses Library/, but the production code's + // linuxConfigDir branch is also taken on any non-darwin/non- + // win32 platform; the test below for the helper covers macOS + // by directing through the Linux branch when not on Windows.) + return; + } + const xdgConfig = join(tmpHome, 'custom-xdg', 'config'); + process.env.XDG_CONFIG_HOME = xdgConfig; + const claudePath = claudeDesktopPathUnder(tmpHome); + // Sanity check on the helper: when XDG is set, the path + // resolves under it on Linux, not `~/.config/`. + if (platform() !== 'darwin') { + expect(claudePath).toContain(xdgConfig); + expect(claudePath).not.toContain(join(tmpHome, '.config')); + } + mkdirSync(join(claudePath, '..'), { recursive: true }); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + // Detected at the XDG-relocated path; entry written. + expect(existsSync(claudePath)).toBe(true); + const written = JSON.parse(readFileSync(claudePath, 'utf-8')); + expect(written.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + }); + + it('Codex Round-6 Fix 9: Linux Claude Desktop without XDG_CONFIG_HOME → detected at ~/.config/Claude/ (fallback)', async () => { + // Counterpart: the existing `~/.config/Claude/...` behaviour is + // preserved when XDG_CONFIG_HOME is unset (the default for most + // users). Pre-fix tests were already exercising this path; the + // explicit test here pins the fallback contract so a future + // refactor doesn't accidentally break it. + if (platform() === 'win32') return; // %APPDATA% path on Windows. + expect(process.env.XDG_CONFIG_HOME).toBeUndefined(); + const claudePath = claudeDesktopPathUnder(tmpHome); + if (platform() !== 'darwin') { + expect(claudePath).toContain(join(tmpHome, '.config', 'Claude')); + } + mkdirSync(join(claudePath, '..'), { recursive: true }); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + expect(existsSync(claudePath)).toBe(true); + }); + + it('Codex Round-6 Fix 9: Linux VSCode + Copilot Chat with XDG_CONFIG_HOME → detected at custom location', async () => { + if (platform() === 'win32') return; + const xdgConfig = join(tmpHome, 'custom-xdg', 'config'); + process.env.XDG_CONFIG_HOME = xdgConfig; + const vscodePath = vscodeMcpPathUnder(tmpHome); + if (platform() !== 'darwin') { + expect(vscodePath).toContain(xdgConfig); + } + mkdirSync(join(vscodePath, '..'), { recursive: true }); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + expect(existsSync(vscodePath)).toBe(true); + const written = JSON.parse(readFileSync(vscodePath, 'utf-8')); + // VSCode uses `servers.dkg` shape (not `mcpServers.dkg`). + expect(written.servers?.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + }); + + it('Codex Round-6 Fix 9: Linux Cline with XDG_CONFIG_HOME → detected at custom location', async () => { + if (platform() === 'win32') return; + const xdgConfig = join(tmpHome, 'custom-xdg', 'config'); + process.env.XDG_CONFIG_HOME = xdgConfig; + const clinePath = clineMcpPathUnder(tmpHome); + if (platform() !== 'darwin') { + expect(clinePath).toContain(xdgConfig); + } + mkdirSync(join(clinePath, '..'), { recursive: true }); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + expect(existsSync(clinePath)).toBe(true); + const written = JSON.parse(readFileSync(clinePath, 'utf-8')); + expect(written.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + }); + + // ── Codex Round-7 Fix 11: narrow --installed flag + log line ───── + + it('Codex Round-7 Fix 11: --installed from monorepo cwd registers the running CLI (NOT a hypothetical installed binary)', async () => { + // Pre-fix the `--installed` flag implied it would force the + // published CLI binary. Post-fix it controls bootstrap home + // only — the registered CLI is always the one currently + // running. Pin both behaviours together: bootstrap home goes + // to ~/.dkg (forced), but registered command is `process.argv[1]` + // (the test runner's own argv[1]), NOT some hypothetical installed + // path. + const fakeRepoRoot = makeFakeMonorepoRoot(); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + let dkgHomeAtStartDaemon: string | undefined; + const startDaemonSpy = vi.fn(async (_port: number) => { + dkgHomeAtStartDaemon = process.env.DKG_HOME; + }); + + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + startDaemon: startDaemonSpy, + }); + + await mcpSetupAction({ installed: true, fund: false, verify: false }, deps); + + // (1) Bootstrap home is the installed-mode home, not dev. + expect(dkgHomeAtStartDaemon).toBe(join(tmpHome, '.dkg')); + expect(existsSync(join(tmpHome, '.dkg-dev'))).toBe(false); + + // (2) Registered command is the CURRENTLY-RUNNING CLI, NOT + // the monorepo cli.dist (even though monorepoRoot is detected + // and the user explicitly opted out of monorepo mode). + const cursorConfig = JSON.parse(readFileSync(join(tmpHome, '.cursor', 'mcp.json'), 'utf-8')); + expect(cursorConfig.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + // Belt-and-braces: the registered cli.js is NOT the fake repo + // root's dist path — `--installed` does NOT swap binaries. + expect(cursorConfig.mcpServers.dkg.args[0]).not.toBe( + join(fakeRepoRoot, 'packages', 'cli', 'dist', 'cli.js'), + ); + }); + + it('Codex Round-7 Fix 11 + Round-8 Fix 13: "Registering CLI:" log goes to STDERR (preserves --print-only stdout purity)', async () => { + // Operators should see exactly which binary will be persisted + // into client configs BEFORE any client write happens. Round-8 + // Fix 13 routed this log to stderr (not stdout) so it doesn't + // contaminate `dkg mcp setup --print-only | jq …` workflows — + // stdout stays a single canonical JSON document. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const deps = makeDeps(); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const stderrText = (stderrSpy.mock.calls as any[]).map((c) => String(c[0])).join(''); + // Log line includes the literal "Registering CLI:" prefix. + expect(stderrText).toMatch(/Registering CLI:/); + // And the absolute Node binary path. + expect(stderrText).toContain(process.execPath); + // And the resolved cli.js path. + expect(stderrText).toContain('mcp serve'); + // Belt-and-braces: the line did NOT go to stdout (logSpy + // captures console.log calls, which would be the pre-Round-8 + // path). + const stdoutLogged = (logSpy.mock.calls as any[]).map((c) => c.join(' ')).join('\n'); + expect(stdoutLogged).not.toMatch(/Registering CLI:/); + + stderrSpy.mockRestore(); + }); + + // ── Codex Round-7 Fix 12: complete yaml fast-path read ─────────── + + it('Codex Round-7 Fix 12: yaml-only ~/.dkg/config.yaml — readPersistedAgentName + reconcile use the YAML values', async () => { + // Pre-fix: yaml-only home would hit the configExists short- + // circuit (Round-3 Fix 2) but step 1's reconcile path only + // read config.json, so name/port silently fell back to + // defaults — daemon start, funding, verification all targeted + // the wrong values. Post-fix: readPersistedConfig() helper + // tries JSON then YAML. + const dkgDir = join(tmpHome, '.dkg'); + mkdirSync(dkgDir, { recursive: true }); + writeFileSync( + join(dkgDir, 'config.yaml'), + 'name: my-yaml-agent\napiPort: 9001\nnodeRole: edge\n', + ); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + const deps = makeDeps(); + await mcpSetupAction({ fund: false, verify: false }, deps); + + // (1) writeDkgConfig was NOT called — yaml-only configExists + // fast path keeps the existing file untouched. + expect(deps.ensureDkgNodeConfig).not.toHaveBeenCalled(); + // (2) startDaemon got the YAML port (9001), NOT the CLI + // default 9200. This is the load-bearing assertion: pre-fix + // this would have been 9200. + expect(deps.startDaemon).toHaveBeenCalledTimes(1); + expect((deps.startDaemon as any).mock.calls[0][0]).toBe(9001); + }); + + it('Codex Round-7 Fix 12: yaml-only with no fields → falls back to defaults gracefully (no crash)', async () => { + // Empty YAML object: readPersistedConfig returns the empty + // object, but `name`/`apiPort` reads come back undefined → + // pre-merge defaults are used. No crash; no agent-name + // regeneration loop on re-runs (since configExists short- + // circuits writeDkgConfig). + const dkgDir = join(tmpHome, '.dkg'); + mkdirSync(dkgDir, { recursive: true }); + writeFileSync(join(dkgDir, 'config.yaml'), '{}\n'); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + const deps = makeDeps(); + await expect( + mcpSetupAction({ fund: false, verify: false }, deps), + ).resolves.not.toThrow(); + + expect(deps.ensureDkgNodeConfig).not.toHaveBeenCalled(); + // Default port 9200 used since YAML had no apiPort field. + expect((deps.startDaemon as any).mock.calls[0][0]).toBe(9200); + }); + + it('Codex Round-7 Fix 12: both config.json AND config.yaml exist → JSON wins (deterministic precedence)', async () => { + // When both files exist, the helper prefers JSON. Mirrors + // resolveDkgConfigHome's order of checks and gives a + // deterministic answer for users who hand-edit one file + // while the daemon writes the other. + const dkgDir = join(tmpHome, '.dkg'); + mkdirSync(dkgDir, { recursive: true }); + writeFileSync( + join(dkgDir, 'config.json'), + JSON.stringify({ name: 'json-wins', apiPort: 9100, nodeRole: 'edge' }, null, 2), + ); + writeFileSync( + join(dkgDir, 'config.yaml'), + 'name: yaml-loses\napiPort: 9200\n', + ); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + const deps = makeDeps(); + await mcpSetupAction({ fund: false, verify: false }, deps); + + // JSON's port (9100) wins. + expect((deps.startDaemon as any).mock.calls[0][0]).toBe(9100); + }); + + // ── Codex Round-8 Fix 13: --print-only stdout-purity regression ── + + it('Codex Round-8 Fix 13: --print-only stdout is parseable JSON (no Registering CLI prefix)', async () => { + // Round-7 broke the --print-only stdout-purity contract for the + // SECOND time (Round-2 Bug B was the first). Round-8 Fix 13 + // routes the "Registering CLI:" log to stderr. This test pins + // the stdout-purity invariant: `JSON.parse(stdout)` succeeds + // and the parsed object has the canonical mcpServers.dkg shape. + const deps = makeDeps(); + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + await mcpSetupAction({ printOnly: true }, deps); + + const stdoutText = (stdoutSpy.mock.calls as any[]).map((c) => String(c[0])).join(''); + // No "Registering CLI:" prefix on stdout. Pre-fix this string + // contaminated stdout. + expect(stdoutText).not.toMatch(/Registering CLI:/); + // No "VSCode" advisory on stdout (Round-2 Fix B regression + // guard rebaselined for Round-8). + expect(stdoutText).not.toMatch(/VSCode/i); + // Strict JSON-parses cleanly. + const parsed = JSON.parse(stdoutText.trim()); + expect(parsed.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + + // STDERR carries BOTH the "Registering CLI:" log AND the + // VSCode-shape disambiguation note. + const stderrText = (stderrSpy.mock.calls as any[]).map((c) => String(c[0])).join(''); + expect(stderrText).toMatch(/Registering CLI:/); + expect(stderrText).toMatch(/VSCode/i); + + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + }); + + // ── Codex Round-8 Fix 14: DKG_HOME env precedence over --monorepo ── + + it('Codex Round-8 Fix 14: DKG_HOME set + --monorepo → uses DKG_HOME (env wins over flag bypass)', async () => { + // Pre-fix: Round-5 Fix 6's --monorepo bypass of + // resolveDkgConfigHome ALSO bypassed the DKG_HOME env-var + // precedence. Operators with `DKG_HOME=/custom/path` who passed + // `--monorepo` would have setup state land in `~/.dkg-dev` + // while the rest of the CLI honoured the custom path — + // splitting state across two homes. + // + // Post-fix: DKG_HOME wins always, regardless of mode flags. + const fakeRepoRoot = makeFakeMonorepoRoot(); + const customDkgHome = join(tmpHome, 'custom-dkg-home'); + mkdirSync(customDkgHome, { recursive: true }); + process.env.DKG_HOME = customDkgHome; + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + let dkgHomeAtStartDaemon: string | undefined; + const startDaemonSpy = vi.fn(async (_port: number) => { + dkgHomeAtStartDaemon = process.env.DKG_HOME; + }); + + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + startDaemon: startDaemonSpy, + }); + + await mcpSetupAction({ monorepo: true, fund: false, verify: false }, deps); + + // DKG_HOME wins — neither the --monorepo bypass to ~/.dkg-dev + // nor any other branch overrode it. + expect(dkgHomeAtStartDaemon).toBe(customDkgHome); + // ~/.dkg-dev was NOT created (the bypass branch was skipped). + expect(existsSync(join(tmpHome, '.dkg-dev'))).toBe(false); + + // Restore env (try/finally restore should have already done this). + expect(process.env.DKG_HOME).toBe(customDkgHome); + }); + + it('Codex Round-8 Fix 14: DKG_HOME set + auto-detect (no flag) on monorepo cwd → uses DKG_HOME', async () => { + // Auto-detect path (no --monorepo flag) ALSO defers to DKG_HOME + // when set. Pre-Round-8 the auto-detect path called + // resolveDkgConfigHome which already respects DKG_HOME, so this + // case worked already; Fix 14 makes the precedence explicit and + // unconditional in the cli's own cascade. + const fakeRepoRoot = makeFakeMonorepoRoot(); + const customDkgHome = join(tmpHome, 'custom-dkg-home'); + mkdirSync(customDkgHome, { recursive: true }); + process.env.DKG_HOME = customDkgHome; + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + let dkgHomeAtStartDaemon: string | undefined; + const startDaemonSpy = vi.fn(async (_port: number) => { + dkgHomeAtStartDaemon = process.env.DKG_HOME; + }); + + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + startDaemon: startDaemonSpy, + }); + + await mcpSetupAction({ fund: false, verify: false }, deps); + + expect(dkgHomeAtStartDaemon).toBe(customDkgHome); + }); + + it('Codex Round-8 Fix 14: no DKG_HOME + --monorepo → ~/.dkg-dev (existing FIX 6 behaviour preserved)', async () => { + // Counterpart to the DKG_HOME-set case: when DKG_HOME is unset, + // the --monorepo bypass still kicks in (Round-5 Fix 6 + // contract). Pin that the env-precedence addition didn't + // accidentally regress the bypass. + const fakeRepoRoot = makeFakeMonorepoRoot(); + delete process.env.DKG_HOME; + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + let dkgHomeAtStartDaemon: string | undefined; + const startDaemonSpy = vi.fn(async (_port: number) => { + dkgHomeAtStartDaemon = process.env.DKG_HOME; + }); + + const resolveDkgConfigHomeSpy = vi.fn(() => join(tmpHome, '.dkg')); + + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + resolveDkgConfigHome: resolveDkgConfigHomeSpy, + startDaemon: startDaemonSpy, + }); + + await mcpSetupAction({ monorepo: true, fund: false, verify: false }, deps); + + expect(dkgHomeAtStartDaemon).toBe(join(tmpHome, '.dkg-dev')); + // resolveDkgConfigHome was NOT called (--monorepo bypass took + // over, since DKG_HOME wasn't set). + expect(resolveDkgConfigHomeSpy).not.toHaveBeenCalled(); + }); + + it('Codex Round-8 Fix 14: DKG_HOME restored to its pre-action value after exit (Fix 3 invariant preserved)', async () => { + // Round-3 Fix 3 added try/finally save+restore of DKG_HOME + // around the action body. Round-8 Fix 14 captures + // previousDkgHome BEFORE the cascade; the try/finally restore + // MUST still use that captured value. This test pins the + // invariant for both the env-set and env-unset cases. + const PRIOR = '/some/external/dkg-home'; + process.env.DKG_HOME = PRIOR; + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + const deps = makeDeps(); + await mcpSetupAction({ fund: false, verify: false }, deps); + + // After exit, DKG_HOME is back to PRIOR. (And during the + // action, since previousDkgHome === PRIOR was non-empty, + // dkgDirPath itself was PRIOR per Fix 14's cascade.) + expect(process.env.DKG_HOME).toBe(PRIOR); + }); + + // ── Codex Round-8 Fix 15: per-client failure isolation ─────────── + + it('Codex Round-8 Fix 15 + Round-9 Fix 17: classify error on one client → others still attempted, failing client skipped, action throws partial-failure', async () => { + // Round-8 Fix 15 isolates per-client classify errors so other + // clients still get attempted. Round-9 Fix 17 layered an + // aggregate-failure throw on top so CI / scripted invocations + // see a non-zero exit signal even when SOME clients + // succeeded — the partial-success state is still a failure + // for "did setup complete its registration step?" purposes. + // + // Setup: Cursor's config is malformed (truncated JSON) → + // classify throws → marked skipped. Claude Code is + // unconfigured → registers cleanly. The action throws + // "1 client(s) failed to register; 1 succeeded" at the end. + const cursorDir = join(tmpHome, '.cursor'); + mkdirSync(cursorDir, { recursive: true }); + writeFileSync(join(cursorDir, 'mcp.json'), '{"truncated":'); + + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const deps = makeDeps(); + + await expect( + mcpSetupAction({ start: false, fund: false, verify: false }, deps), + ).rejects.toThrow(/1 client\(s\) failed to register; 1 succeeded/); + + const stderrText = (stderrSpy.mock.calls as any[]).map((c) => String(c[0])).join(''); + // Stderr warning for the failing classify. + expect(stderrText).toMatch(/WARNING: Cursor classify failed/); + // Cursor's malformed file is NOT overwritten (failed-client + // skip semantics from Fix 15). + expect(readFileSync(join(cursorDir, 'mcp.json'), 'utf-8')).toBe('{"truncated":'); + // Other client (Claude Code) was still registered before the + // throw — its ~/.claude.json file exists post-action. + expect(existsSync(join(tmpHome, '.claude.json'))).toBe(true); + const claudeWritten = JSON.parse(readFileSync(join(tmpHome, '.claude.json'), 'utf-8')); + expect(claudeWritten.mcpServers.dkg).toEqual(EXPECTED_INSTALLED_ENTRY()); + + stderrSpy.mockRestore(); + }); + + it('Codex Round-8 Fix 15 + Round-9 Fix 17: write error on one client → others still attempted, action throws partial-failure', async () => { + // Per-client write isolation (Fix 15) + non-zero exit on + // partial failure (Fix 17). Force a write failure by pre- + // creating the Cursor config dir as a regular FILE (so the + // mcp.json create-or-write throws). Claude Code's parent + // (tmpHome) still works. Action throws partial-failure at + // the end; Claude Code IS still registered before the throw. + const cursorDir = join(tmpHome, '.cursor'); + writeFileSync(cursorDir, 'this is a file, not a directory'); + + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const deps = makeDeps(); + + await expect( + mcpSetupAction({ start: false, fund: false, verify: false }, deps), + ).rejects.toThrow(/1 client\(s\) failed/); + + const stderrText = (stderrSpy.mock.calls as any[]).map((c) => String(c[0])).join(''); + // Stderr warning for the failing client. + expect(stderrText).toMatch(/WARNING: Cursor (classify|write) failed/); + // Other client (Claude Code) was still written. + expect(existsSync(join(tmpHome, '.claude.json'))).toBe(true); + + stderrSpy.mockRestore(); + }); + + it('Codex Round-8 Fix 15 + Round-9 Fix 17: ALL clients failing → action throws "No client configs updated" (zero successes)', async () => { + // When EVERY detected client fails, mcpSetupAction MUST throw + // a structured "No client configs updated" error so CI sees + // a non-zero exit. Round-8 Fix 15 (continue past per-client + // failures) is preserved — every client still gets tried — + // but Round-9 Fix 17 ensures the aggregate exit signal + // reflects the actual outcome. + const cursorDir = join(tmpHome, '.cursor'); + mkdirSync(cursorDir, { recursive: true }); + writeFileSync(join(cursorDir, 'mcp.json'), '{"corrupt":'); + writeFileSync(join(tmpHome, '.claude.json'), '{"also-corrupt":'); + + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const deps = makeDeps(); + + await expect( + mcpSetupAction({ start: false, fund: false, verify: false }, deps), + ).rejects.toThrow(/No client configs updated\. 2 client\(s\) failed/); + + const stderrText = (stderrSpy.mock.calls as any[]).map((c) => String(c[0])).join(''); + // Both clients' classify failures logged before the throw. + expect(stderrText).toMatch(/WARNING: Cursor classify failed/); + expect(stderrText).toMatch(/WARNING: Claude Code classify failed/); + + // Neither malformed file was overwritten. + expect(readFileSync(join(cursorDir, 'mcp.json'), 'utf-8')).toBe('{"corrupt":'); + expect(readFileSync(join(tmpHome, '.claude.json'), 'utf-8')).toBe('{"also-corrupt":'); + + stderrSpy.mockRestore(); + }); + + // ── Codex Round-9 Fix 16: env DKG_HOME propagation in entry ────── + + it('Codex Round-9 Fix 16: default install → entry has env: { DKG_HOME: ~/.dkg }', async () => { + // The MCP entry's env field carries the resolved bootstrap + // home so spawned MCP servers (in GUI clients that don't + // inherit shell env) read the same config / auth.token setup + // just bootstrapped. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const deps = makeDeps(); + + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const cursor = JSON.parse(readFileSync(join(tmpHome, '.cursor', 'mcp.json'), 'utf-8')); + expect(cursor.mcpServers.dkg.env).toEqual({ DKG_HOME: join(tmpHome, '.dkg') }); + }); + + it('Codex Round-9 Fix 16: operator DKG_HOME=/custom → entry has env: { DKG_HOME: /custom }', async () => { + const customHome = join(tmpHome, 'custom-dkg-home'); + mkdirSync(customHome, { recursive: true }); + process.env.DKG_HOME = customHome; + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const cursor = JSON.parse(readFileSync(join(tmpHome, '.cursor', 'mcp.json'), 'utf-8')); + expect(cursor.mcpServers.dkg.env).toEqual({ DKG_HOME: customHome }); + }); + + it('Codex Round-9 Fix 16: --monorepo → entry has env: { DKG_HOME: ~/.dkg-dev }', async () => { + const fakeRepoRoot = makeFakeMonorepoRoot(); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + }); + await mcpSetupAction({ monorepo: true, start: false, fund: false, verify: false }, deps); + + const cursor = JSON.parse(readFileSync(join(tmpHome, '.cursor', 'mcp.json'), 'utf-8')); + expect(cursor.mcpServers.dkg.env).toEqual({ DKG_HOME: join(tmpHome, '.dkg-dev') }); + }); + + it('Codex Round-9 Fix 16: classifier compares env.DKG_HOME — DKG_HOME drift classifies as stale and refreshes', async () => { + // Pre-existing entry has env: { DKG_HOME: '/old/path' }; a + // re-run with DKG_HOME unset (or pointing somewhere new) + // computes a different env and classifies as stale, refreshing + // to the new value. + const cursorDir = join(tmpHome, '.cursor'); + mkdirSync(cursorDir, { recursive: true }); + // Pre-existing entry with stale DKG_HOME. + writeFileSync( + join(cursorDir, 'mcp.json'), + JSON.stringify({ + mcpServers: { + dkg: { + command: process.execPath, + args: [realpathSync(process.argv[1]), 'mcp', 'serve'], + env: { DKG_HOME: '/old/abandoned/path' }, + }, + }, + }, null, 2), + ); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + // Refreshed: the new env carries the current home. + const after = JSON.parse(readFileSync(join(cursorDir, 'mcp.json'), 'utf-8')); + expect(after.mcpServers.dkg.env).toEqual({ DKG_HOME: join(tmpHome, '.dkg') }); + // Classifier saw the env drift, didn't treat the entry as + // already-registered. + expect(after.mcpServers.dkg.env.DKG_HOME).not.toBe('/old/abandoned/path'); + }); + + it('Codex Round-9 Fix 16: pre-Fix-16 entries (no env field) classify as stale and migrate forward', async () => { + // Legacy entries from any setup version pre-Fix-16 lack the + // env field. The classifier's JSON.stringify(env) comparison + // sees `undefined` vs `{ DKG_HOME }` and marks stale → the + // refresh path adds the env field automatically. This is the + // auto-migration story for users upgrading. + const cursorDir = join(tmpHome, '.cursor'); + mkdirSync(cursorDir, { recursive: true }); + writeFileSync( + join(cursorDir, 'mcp.json'), + JSON.stringify({ + mcpServers: { + dkg: { + command: process.execPath, + args: [realpathSync(process.argv[1]), 'mcp', 'serve'], + // Note: no env field at all — pre-Fix-16 shape. + }, + }, + }, null, 2), + ); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const after = JSON.parse(readFileSync(join(cursorDir, 'mcp.json'), 'utf-8')); + expect(after.mcpServers.dkg.env).toEqual({ DKG_HOME: join(tmpHome, '.dkg') }); + }); + + // ── Codex Round-9 Fix 17: aggregate failure throw cases ────────── + + it('Codex Round-9 Fix 17: all clients succeed → no throw; "Next steps" hint emitted', async () => { + // Counterpart to the all-fail / partial-fail tests above: + // the happy path. Every detected client registers cleanly, + // mcpSetupAction returns (does not throw), and the + // operator-facing "Next steps" hint appears in stdout. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const deps = makeDeps(); + + await expect( + mcpSetupAction({ start: false, fund: false, verify: false }, deps), + ).resolves.not.toThrow(); + + const logged = (logSpy.mock.calls as any[]).map((c) => c.join(' ')).join('\n'); + expect(logged).toMatch(/Next steps:/); + }); + + it('Codex Round-9 Fix 17: dry-run does NOT throw on classify failures (preview-only path)', async () => { + // Dry-run is preview-only. Even with classify failures in + // detected clients, dry-run MUST return cleanly — no writes + // attempted, no aggregate-failure throw. Operators use it to + // see what setup WOULD do without committing. + const cursorDir = join(tmpHome, '.cursor'); + mkdirSync(cursorDir, { recursive: true }); + writeFileSync(join(cursorDir, 'mcp.json'), '{"corrupt":'); + + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const deps = makeDeps(); + + await expect( + mcpSetupAction({ dryRun: true, start: false, fund: false, verify: false }, deps), + ).resolves.not.toThrow(); + + // Stderr warning still fires (operator sees the issue). + const stderrText = (stderrSpy.mock.calls as any[]).map((c) => String(c[0])).join(''); + expect(stderrText).toMatch(/WARNING: Cursor classify failed/); + + stderrSpy.mockRestore(); + }); + + // ── Codex Round-13 Fix 19: detectContext uses running CLI's location ── + + it('Codex Round-13 Fix 19: auto-detect uses dirname(realpath(argv[1])), NOT process.cwd()', async () => { + // Pre-fix: detectContext called findDkgMonorepoRoot(process.cwd()) + // — a global `dkg` invoked from inside a monorepo checkout + // would resolve cwd → repo root and switch the registered MCP + // entry to the (potentially unbuilt) monorepo dist. Mismatch: + // setup steps 1-3 ran against the global home; the persisted + // entry pointed at the local checkout. + // + // Post-fix: auto-detect calls findDkgMonorepoRoot with the + // RUNNING CLI's directory (dirname(realpathSync(argv[1]))). + // The test runner's argv[1] is vitest's own dist (in + // `node_modules/.pnpm/vitest@.../dist/...`), which is + // outside any monorepo root by definition. + // + // We assert the stub findDkgMonorepoRoot was called with a + // path that's NOT process.cwd() (which IS inside the + // dkg-v9 monorepo when the test runs from within it). + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const findRootSpy = vi.fn((startDir?: string) => { + // Whatever the start dir is, return null (no monorepo) so + // we test the auto-detect → installed fallback path. + return null as string | null; + }); + const deps = makeDeps({ findDkgMonorepoRoot: findRootSpy }); + + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + // findRoot was called at least once (by detectContext). + expect(findRootSpy).toHaveBeenCalled(); + const callArg = findRootSpy.mock.calls[0][0]; + // The argument is a string path (running CLI's dir), NOT + // undefined (which would mean default-walk-from-package-path, + // the broken pre-Round-1 default). + expect(typeof callArg).toBe('string'); + // And it's NOT process.cwd() — that was the round-1 fix that + // round-13 corrected. The CLI's location and process.cwd() are + // different when the test runner runs from within dkg-v9 but + // vitest's dist lives in node_modules/.pnpm/.... If they happen + // to coincide on a particular machine, this assertion is + // a no-op (which is fine — the cwd-vs-cli-dir distinction + // only matters when they differ). + if (callArg && callArg !== process.cwd()) { + // The argument is a directory path containing the test + // runner's dist — vitest is the running CLI in this test. + expect(callArg).toContain('node_modules'); + } + }); + + it('Codex Round-13 Fix 19: auto-detect with monorepo-located CLI → context = monorepo', async () => { + // Counterpart: when findDkgMonorepoRoot returns a root for the + // running CLI's directory, auto-detect picks monorepo. Stub + // returns the fake repo root regardless of input. + const fakeRepoRoot = makeFakeMonorepoRoot(); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + }); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const cursor = JSON.parse(readFileSync(join(tmpHome, '.cursor', 'mcp.json'), 'utf-8')); + // Monorepo path: args[0] is the local CLI dist. + expect(cursor.mcpServers.dkg.args[0]).toBe( + join(fakeRepoRoot, 'packages', 'cli', 'dist', 'cli.js'), + ); + }); + + it('Codex Round-13 Fix 19: --monorepo force errors when no root found from running CLI dir', async () => { + // Tighter contract: --monorepo demands the running CLI live + // inside a monorepo. Pre-fix, `cwd` could mask this. Post-fix, + // a global `dkg` invoked with --monorepo from inside a clone + // would still throw if the global CLI isn't itself the + // monorepo build. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => null), + }); + + await expect( + mcpSetupAction({ monorepo: true, start: false, fund: false, verify: false }, deps), + ).rejects.toThrow(/no DKG monorepo root could be located/); + }); + + // ── Codex Round-13 Fix 20: WSL2 detection + Windows-side probing ── + + let originalWslDistroName: string | undefined; + let originalWslInterop: string | undefined; + + function saveWslEnv(): void { + originalWslDistroName = process.env.WSL_DISTRO_NAME; + originalWslInterop = process.env.WSL_INTEROP; + } + + function restoreWslEnv(): void { + if (originalWslDistroName !== undefined) process.env.WSL_DISTRO_NAME = originalWslDistroName; + else delete process.env.WSL_DISTRO_NAME; + if (originalWslInterop !== undefined) process.env.WSL_INTEROP = originalWslInterop; + else delete process.env.WSL_INTEROP; + } + + it('Codex Round-13 Fix 20: non-WSL Linux platform — only Linux-side entries (regression guard)', async () => { + // Pre-Round-13 default behaviour: a regular Linux box (no WSL + // env vars, plain /proc/version) must continue to detect only + // the Linux-side configs. This test pins that the Round-13 + // additions don't accidentally widen the candidate set on + // non-WSL platforms. + if (platform() !== 'linux') return; // Linux-only test; macOS/Windows skip. + saveWslEnv(); + delete process.env.WSL_DISTRO_NAME; + delete process.env.WSL_INTEROP; + try { + const { detectClients } = await import('../src/mcp-setup.js'); + const detected = detectClients(); + // No "(Windows-side via WSL)" entries on plain Linux. + const wslEntries = detected.filter((c) => c.name.includes('Windows-side via WSL')); + expect(wslEntries.length).toBe(0); + } finally { + restoreWslEnv(); + } + }); + + it('Codex Round-13 Fix 20: WSL env (WSL_DISTRO_NAME set) on Linux — adds Windows-side entries for the 4 GUI clients', async () => { + // Synthesize a WSL environment via the env-var signal (cheapest + // detection branch), then assert detectClients returns the + // additional "(Windows-side via WSL)" entries for Claude + // Desktop, VSCode + Copilot, Cline, and Windsurf. + // + // Skipped on non-Linux platforms: the WSL detector early-returns + // false unless platform() === 'linux', and we can't override + // platform() without a vi.mock at the top of the file. + if (platform() !== 'linux') return; + saveWslEnv(); + process.env.WSL_DISTRO_NAME = 'TestDistro'; + try { + // The wslWindowsEnvPath helper shells out to cmd.exe + wslpath. + // In a test environment those binaries don't exist; the helper + // catches and returns null, so the WSL branch's additive entries + // are skipped silently. To exercise the additive-entry path + // we'd need to mock execSync — out of scope for this CI test. + // What we CAN verify: isWSL() detection fired correctly and + // detectClients didn't throw or hang; it just returned the + // base set when wsl path resolution failed. + const { detectClients } = await import('../src/mcp-setup.js'); + const detected = detectClients(); + // The detector found at least the Linux-side defaults that + // exist on this test runner (probably Cursor's parent if + // tmpHome is set up, or none at all on a clean test box). + // The contract this test pins: detectClients does NOT crash + // when WSL is detected but cmd.exe / wslpath are unavailable. + expect(Array.isArray(detected)).toBe(true); + // No partial / null Windows-side entries leaked through. + for (const c of detected) { + expect(typeof c.configPath).toBe('string'); + expect(c.configPath.length).toBeGreaterThan(0); + } + } finally { + restoreWslEnv(); + } + }); + + it('Codex Round-13 Fix 20: isWSL() detection helper — returns false on Windows, true with WSL_DISTRO_NAME on Linux', async () => { + // Direct unit test of the detection signal. Round-13 added + // multi-source detection (env, os.release, /proc/version); + // this test pins the env-var path which is the cheapest and + // most-common signal in real WSL launches. + saveWslEnv(); + try { + // Windows / macOS / non-WSL Linux: detector returns false on + // any non-Linux platform regardless of env vars. + if (platform() !== 'linux') { + process.env.WSL_DISTRO_NAME = 'Ubuntu'; + // detectClients should NOT add Windows-side entries on + // Windows (the detector's `if (platform() !== 'linux') + // return false` guard). + const { detectClients } = await import('../src/mcp-setup.js'); + const detected = detectClients(); + const wslEntries = detected.filter((c) => c.name.includes('Windows-side via WSL')); + expect(wslEntries.length).toBe(0); + } + // On Linux platforms, isWSL would return true with the env + // var set. We can't directly observe the helper without + // exporting it, but the contract is exercised via the + // detectClients-with-WSL-env test above. + } finally { + restoreWslEnv(); + } + }); + + it('Codex Round-13 Fix 20: graceful fallback when wslpath/cmd.exe unavailable (no crash, no half-baked entries)', async () => { + // The wslWindowsEnvPath helper catches exec failures and + // returns null; detectClients then skips the additive + // Windows-side entries silently and returns the base set. + // This test pins that graceful-degradation contract — even + // when WSL is detected (env signal) but the cmd.exe / wslpath + // tooling isn't reachable, setup keeps working with the + // Linux-only client set. + if (platform() !== 'linux') return; + saveWslEnv(); + process.env.WSL_DISTRO_NAME = 'TestDistro'; + try { + const { detectClients } = await import('../src/mcp-setup.js'); + // Should not throw despite WSL being "detected" while + // cmd.exe/wslpath are unavailable in the test environment. + const detected = detectClients(); + expect(Array.isArray(detected)).toBe(true); + // Every returned entry has well-formed string paths. + for (const c of detected) { + expect(typeof c.configPath).toBe('string'); + } + } finally { + restoreWslEnv(); + } + }); + + // ── Codex Round-15 Fix 21: --monorepo cwd-first ordering ───────── + + it('Codex Round-15 Fix 21: --monorepo + global CLI invoked from inside a valid monorepo cwd → resolves against cwd', async () => { + // Pre-fix (Round-13 FIX 19) the forced-monorepo branch tried + // `cliDir` first, which hard-failed when a global `dkg` was + // invoked from inside a valid monorepo with `--monorepo`. The + // global install path doesn't have a monorepo above it; the + // user's intent was clearly cwd. Post-fix: cwd first, cliDir + // as a fallback before throwing. + // + // Test setup: simulate the global-CLI-from-inside-monorepo case. + // Stub findRoot so: + // - `` returns a fake monorepo root (the user's intent). + // - any other path (cliDir, e.g.) returns null. + const fakeRepoRoot = makeFakeMonorepoRoot(); + const userCwd = process.cwd(); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + const findStub = vi.fn((startDir?: string) => { + // cwd matches → return the fake repo root. + if (startDir === userCwd) return fakeRepoRoot; + // Any other start dir (cliDir would be vitest's dist + // directory) → no monorepo above it. + return null; + }); + + const deps = makeDeps({ findDkgMonorepoRoot: findStub }); + + // Should NOT throw. The cwd-first logic finds the root. + await mcpSetupAction({ monorepo: true, start: false, fund: false, verify: false }, deps); + + // The Cursor entry's args[0] points at the fake repo root's + // cli.dist (proves the monorepo branch was taken with the + // cwd-derived root). + const cursor = JSON.parse(readFileSync(join(tmpHome, '.cursor', 'mcp.json'), 'utf-8')); + expect(cursor.mcpServers.dkg.args[0]).toBe( + join(fakeRepoRoot, 'packages', 'cli', 'dist', 'cli.js'), + ); + // findStub was called with cwd at least once (cwd-first). + const callArgs = findStub.mock.calls.map((c) => c[0]); + expect(callArgs).toContain(userCwd); + }); + + it('Codex Round-15 Fix 21: --monorepo + cwd has no monorepo + cliDir has one → falls back to cliDir', async () => { + // The fallback contract: when cwd doesn't have a monorepo above + // it but the running CLI's dir does (test runner pattern: the + // test invokes mcpSetupAction with --monorepo from a tmpHome + // cwd that's outside any monorepo, but the test runner's own + // dist might be inside a monorepo for cli-self-test scenarios). + const fakeRepoRoot = makeFakeMonorepoRoot(); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + + const findStub = vi.fn((startDir?: string) => { + // Anything matching `` (test's tmpHome ancestors) → no + // monorepo. Anything else (cliDir-derived) → fakeRepoRoot. + const cwd = process.cwd(); + if (startDir && startDir.startsWith(cwd)) return null; + return fakeRepoRoot; + }); + + const deps = makeDeps({ findDkgMonorepoRoot: findStub }); + // Should resolve via the cliDir fallback. + await mcpSetupAction({ monorepo: true, start: false, fund: false, verify: false }, deps); + + const cursor = JSON.parse(readFileSync(join(tmpHome, '.cursor', 'mcp.json'), 'utf-8')); + expect(cursor.mcpServers.dkg.args[0]).toBe( + join(fakeRepoRoot, 'packages', 'cli', 'dist', 'cli.js'), + ); + // findStub called at least twice — once with cwd, once with cliDir. + expect(findStub.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it('Codex Round-15 Fix 21: --monorepo + neither cwd nor cliDir has a monorepo → throws actionable error', async () => { + // Existing behavior preserved: when nothing finds a root, the + // throw fires with the same actionable message as before. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const findStub = vi.fn(() => null); + const deps = makeDeps({ findDkgMonorepoRoot: findStub }); + + await expect( + mcpSetupAction({ monorepo: true, start: false, fund: false, verify: false }, deps), + ).rejects.toThrow(/no DKG monorepo root could be located/); + + // Both cwd and cliDir attempted before throwing. + expect(findStub.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + // ── Codex Round-15 Fix 22: classify DKG_HOME-only + writeRegistration env merge ── + + it('Codex Round-15 Fix 22: existing entry with user env keys + DKG_HOME drift → stale; refresh preserves user keys, updates DKG_HOME', async () => { + // Load-bearing: an operator hand-edited their MCP config to add + // NODE_OPTIONS / HTTPS_PROXY for proxy or memory tuning. Pre-fix + // a setup re-run with a different DKG_HOME would (a) classify + // as stale (correct) AND (b) silently wipe the user's vars on + // refresh because writeRegistration replaced the whole entry. + // + // Post-fix: stale-classification reason narrows to DKG_HOME + // drift only; refresh merges existing env keys with the + // expected env so DKG_HOME wins but user keys survive. + const cursorDir = join(tmpHome, '.cursor'); + mkdirSync(cursorDir, { recursive: true }); + writeFileSync( + join(cursorDir, 'mcp.json'), + JSON.stringify({ + mcpServers: { + dkg: { + command: process.execPath, + args: [realpathSync(process.argv[1]), 'mcp', 'serve'], + env: { + DKG_HOME: '/old/abandoned/path', + NODE_OPTIONS: '--max-old-space-size=8192', + HTTPS_PROXY: 'http://corporate-proxy:8080', + }, + }, + }, + }, null, 2), + ); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const after = JSON.parse(readFileSync(join(cursorDir, 'mcp.json'), 'utf-8')); + // DKG_HOME refreshed to current bootstrap home. + expect(after.mcpServers.dkg.env.DKG_HOME).toBe(join(tmpHome, '.dkg')); + // User keys PRESERVED — Round-15 Fix 22's load-bearing assertion. + expect(after.mcpServers.dkg.env.NODE_OPTIONS).toBe('--max-old-space-size=8192'); + expect(after.mcpServers.dkg.env.HTTPS_PROXY).toBe('http://corporate-proxy:8080'); + }); + + it('Codex Round-15 Fix 22: existing entry with user env keys + matching DKG_HOME → registered (no spurious stale)', async () => { + // Pre-fix: strict-equal env comparison flagged user-added keys + // as drift even when DKG_HOME matched, forcing a needless + // refresh. Post-fix: only DKG_HOME matters; user keys are + // ignored for staleness purposes. Re-run is a no-op. + const cursorDir = join(tmpHome, '.cursor'); + mkdirSync(cursorDir, { recursive: true }); + const expectedHome = join(tmpHome, '.dkg'); + writeFileSync( + join(cursorDir, 'mcp.json'), + JSON.stringify({ + mcpServers: { + dkg: { + command: process.execPath, + args: [realpathSync(process.argv[1]), 'mcp', 'serve'], + env: { + DKG_HOME: expectedHome, + NODE_OPTIONS: '--max-old-space-size=8192', + }, + }, + }, + }, null, 2), + ); + const beforeMtime = (await import('node:fs')).statSync(join(cursorDir, 'mcp.json')).mtimeMs; + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + // File NOT rewritten — classifier saw matching DKG_HOME and + // ignored the unrelated NODE_OPTIONS. + const afterMtime = (await import('node:fs')).statSync(join(cursorDir, 'mcp.json')).mtimeMs; + expect(afterMtime).toBe(beforeMtime); + const after = JSON.parse(readFileSync(join(cursorDir, 'mcp.json'), 'utf-8')); + expect(after.mcpServers.dkg.env.NODE_OPTIONS).toBe('--max-old-space-size=8192'); + }); + + it('Codex Round-15 Fix 22: fresh client (no existing entry) → entry written with just env: { DKG_HOME }', async () => { + // Regression guard: when there's nothing to merge, writeRegistration + // emits the expected entry verbatim. No accidental empty `env` + // spread artifacts; no leftover keys from a non-existent prior. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const deps = makeDeps(); + + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const cursor = JSON.parse(readFileSync(join(tmpHome, '.cursor', 'mcp.json'), 'utf-8')); + expect(cursor.mcpServers.dkg.env).toEqual({ DKG_HOME: join(tmpHome, '.dkg') }); + // No extra keys leaked into env. + expect(Object.keys(cursor.mcpServers.dkg.env)).toEqual(['DKG_HOME']); + }); + + // ── Codex Round-17 Fix 23: Cursor in WSL Windows-side detection ── + + it('Codex Round-17 Fix 23: WSL detected on Linux — Cursor (Windows-side via WSL) is among detected entries', async () => { + // Round-13 FIX 20 added 4 Windows-side WSL entries (Claude + // Desktop, VSCode, Cline, Windsurf) but skipped Cursor — + // leaving the common "Windows Cursor + WSL shell" dev setup + // unregistered even though Cursor's been the original client + // in the detection set since round 1. Round-17 Fix 23 adds + // the 5th WSL entry mirroring the existing winUserProfile- + // based pattern. + // + // Skipped on non-Linux platforms: isWSL()'s `platform() === + // 'linux'` early-return short-circuits the WSL branch on + // Windows/macOS regardless of env stubs. + if (platform() !== 'linux') return; + saveWslEnv(); + process.env.WSL_DISTRO_NAME = 'TestDistro'; + try { + const { detectClients } = await import('../src/mcp-setup.js'); + const detected = detectClients(); + // We can't fake cmd.exe / wslpath in this test env, so the + // Windows-side entries (including Cursor) won't actually be + // pushed — but the contract this test pins is that the + // WSL branch DOESN'T CRASH and that any Cursor entry that + // does get pushed has the right shape. If the helper + // succeeds, assert on it; otherwise just verify the + // detection didn't error out. + const cursorWslEntries = detected.filter( + (c) => c.name === 'Cursor (Windows-side via WSL)', + ); + // If the WSL helpers succeed in this environment, the + // entry is present with the canonical mcpServers.dkg shape + // (no entryPath override) and the path includes `.cursor`. + for (const entry of cursorWslEntries) { + expect(entry.entryPath).toBeUndefined(); + expect(entry.configPath).toContain('.cursor'); + } + } finally { + restoreWslEnv(); + } + }); + + it('Codex Round-17 Fix 23: graceful fallback when wslpath/cmd.exe unavailable — no Cursor WSL entry, no crash', async () => { + // Mirrors the existing graceful-degradation contract for the + // 4 other WSL-side clients. When cmd.exe / wslpath aren't + // reachable, wslWindowsEnvPath returns null → the + // `if (winUserProfile)` block doesn't fire → no Cursor (or + // Windsurf) Windows-side entry is pushed. detectClients + // still completes without throwing. + if (platform() !== 'linux') return; + saveWslEnv(); + process.env.WSL_DISTRO_NAME = 'TestDistro'; + try { + const { detectClients } = await import('../src/mcp-setup.js'); + const detected = detectClients(); + // No crash. Every entry is well-formed. + expect(Array.isArray(detected)).toBe(true); + for (const c of detected) { + expect(typeof c.configPath).toBe('string'); + expect(c.configPath.length).toBeGreaterThan(0); + } + } finally { + restoreWslEnv(); + } + }); + + // ── Codex Round-19 Fix 26: writeRegistration merges full entry ── + + it('Codex Round-19 Fix 26: refresh preserves top-level user keys (cwd) AND env keys; updates command/args/env.DKG_HOME', async () => { + // Round-15 Fix 22 added env-merge but the rest of the entry + // was still being replaced wholesale, so top-level keys like + // `cwd` (workspace-anchoring, common in MCP server configs) + // got clobbered on first refresh. Round-19 Fix 26 extends + // the merge to the entire entry: spread existing first, then + // expected, then explicit env merge. Fields THIS COMMAND owns + // (command, args, env.DKG_HOME) override; everything else + // passes through unchanged. + const cursorDir = join(tmpHome, '.cursor'); + mkdirSync(cursorDir, { recursive: true }); + writeFileSync( + join(cursorDir, 'mcp.json'), + JSON.stringify({ + mcpServers: { + dkg: { + command: '/old/legacy/dkg', + args: ['legacy-arg'], + cwd: '/workspaces/my-project', + env: { + DKG_HOME: '/old/abandoned/path', + NODE_OPTIONS: '--inspect', + HTTPS_PROXY: 'http://corp:8080', + }, + }, + }, + }, null, 2), + ); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const after = JSON.parse(readFileSync(join(cursorDir, 'mcp.json'), 'utf-8')); + // Fields this command owns: refreshed. + expect(after.mcpServers.dkg.command).toBe(process.execPath); + expect(after.mcpServers.dkg.args[0]).toBe(realpathSync(process.argv[1])); + expect(after.mcpServers.dkg.args.slice(1)).toEqual(['mcp', 'serve']); + expect(after.mcpServers.dkg.env.DKG_HOME).toBe(join(tmpHome, '.dkg')); + // Top-level user key `cwd`: PRESERVED (load-bearing). + expect(after.mcpServers.dkg.cwd).toBe('/workspaces/my-project'); + // env user keys: PRESERVED (Round-15 Fix 22 contract carries forward). + expect(after.mcpServers.dkg.env.NODE_OPTIONS).toBe('--inspect'); + expect(after.mcpServers.dkg.env.HTTPS_PROXY).toBe('http://corp:8080'); + }); + + it('Codex Round-19 Fix 26: arbitrary unknown top-level keys preserved across refresh', async () => { + // Pin the contract: `command`, `args`, `env.DKG_HOME` are + // the ONLY fields this command owns. Any other top-level key + // — even ones we don't know about today — passes through + // unchanged. + const cursorDir = join(tmpHome, '.cursor'); + mkdirSync(cursorDir, { recursive: true }); + writeFileSync( + join(cursorDir, 'mcp.json'), + JSON.stringify({ + mcpServers: { + dkg: { + command: '/old/dkg', + args: ['old'], + env: { DKG_HOME: '/old' }, + // Hypothetical user-added or future-MCP-spec keys. + restartPolicy: 'always', + timeout: 30000, + tags: ['dev', 'experimental'], + }, + }, + }, null, 2), + ); + + const deps = makeDeps(); + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const after = JSON.parse(readFileSync(join(cursorDir, 'mcp.json'), 'utf-8')); + expect(after.mcpServers.dkg.restartPolicy).toBe('always'); + expect(after.mcpServers.dkg.timeout).toBe(30000); + expect(after.mcpServers.dkg.tags).toEqual(['dev', 'experimental']); + // And the command-owned fields refreshed correctly. + expect(after.mcpServers.dkg.command).toBe(process.execPath); + expect(after.mcpServers.dkg.env.DKG_HOME).toBe(join(tmpHome, '.dkg')); + }); + + it('Codex Round-19 Fix 26: fresh client (no existing entry) → entry written as-is, no merge artifacts', async () => { + // Regression guard: when there's nothing to merge with, + // writeRegistration emits the expected entry verbatim. No + // accidental empty-spread artifacts; the entry's keys are + // exactly what canonicalEntry produced. + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const deps = makeDeps(); + + await mcpSetupAction({ start: false, fund: false, verify: false }, deps); + + const cursor = JSON.parse(readFileSync(join(tmpHome, '.cursor', 'mcp.json'), 'utf-8')); + // Exactly the expected keys: command, args, env. No leftover + // top-level keys from a non-existent prior. + expect(Object.keys(cursor.mcpServers.dkg).sort()).toEqual(['args', 'command', 'env']); + expect(cursor.mcpServers.dkg.env).toEqual({ DKG_HOME: join(tmpHome, '.dkg') }); + }); + + // ── Codex Round-23 Fix 29: dry-run log honesty ───────────────────── + + it('Codex Round-23 Fix 29: --dry-run log line cites the RESOLVED dkgDirPath, not literal ~/.dkg', async () => { + // Pre-fix the dry-run log hardcoded `~/.dkg/config.json` regardless + // of where the resolved bootstrap home actually pointed (e.g. + // `~/.dkg-dev` under monorepo mode, or a custom DKG_HOME). The + // operator was reading the wrong path in the preview. Post-fix + // the log uses `tildify(jsonPath)` so it reflects the actual + // write target. + // + // Force monorepo mode so the resolved home is `/.dkg-dev` + // — different enough from `~/.dkg` that we can assert the log + // doesn't contain the literal old string. + const fakeRepoRoot = makeFakeMonorepoRoot(); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + const deps = makeDeps({ + findDkgMonorepoRoot: vi.fn(() => fakeRepoRoot), + }); + + await mcpSetupAction( + { monorepo: true, dryRun: true, fund: false, verify: false }, + deps, + ); + + const logged = (logSpy.mock.calls as any[]).map((c) => c.join(' ')).join('\n'); + // The dry-run line cites the resolved home path. + expect(logged).toMatch(/\[dry-run\] Would write/); + expect(logged).toContain('.dkg-dev'); + // And does NOT cite the bare-literal `~/.dkg/config.json` (the + // pre-fix string). + expect(logged).not.toMatch(/Would write ~\/\.dkg\/config\.json/); }); }); diff --git a/packages/core/src/dkg-home.ts b/packages/core/src/dkg-home.ts index a90685626..df98943fe 100644 --- a/packages/core/src/dkg-home.ts +++ b/packages/core/src/dkg-home.ts @@ -50,7 +50,17 @@ export function resolveDkgConfigHome(opts: ResolveDkgConfigHomeOptions = {}): st const home = opts.homeDir ?? homedir(); const defaultDir = join(home, '.dkg'); - const configExists = opts.configExists ?? existsSync(join(defaultDir, 'config.json')); + // Codex Round-3 Fix 2: detect both config.json AND config.yaml. + // The pre-fix check only looked at `config.json`, so a user + // running on a YAML-only `~/.dkg` (a perfectly valid shape — + // adapter-openclaw writes JSON but operators frequently hand-edit + // YAML) inside a monorepo checkout was silently redirected to + // `~/.dkg-dev`, splitting their state across two directories on + // every `dkg mcp setup` re-run from a clone. + const configExists = opts.configExists ?? ( + existsSync(join(defaultDir, 'config.json')) || + existsSync(join(defaultDir, 'config.yaml')) + ); const isMonorepo = opts.isDkgMonorepo ?? findDkgMonorepoRoot(opts.startDir) !== null; if (isMonorepo && !configExists) return join(home, '.dkg-dev'); return defaultDir; diff --git a/packages/core/test/dkg-home.test.ts b/packages/core/test/dkg-home.test.ts index 4c242844f..aa09196f2 100644 --- a/packages/core/test/dkg-home.test.ts +++ b/packages/core/test/dkg-home.test.ts @@ -87,6 +87,52 @@ describe('resolveDkgConfigHome', () => { configExists: false, })).toBe(join(tempHome, '.dkg')); }); + + it('Codex Round-3 Fix 2: monorepo + ~/.dkg/config.yaml exists (no JSON) → returns ~/.dkg, not ~/.dkg-dev', async () => { + // Pre-fix: the auto-detection only looked at `config.json`, so a + // user who hand-edited a `config.yaml` (a perfectly valid shape + // — adapter-openclaw writes JSON but operators frequently keep + // YAML) inside a monorepo checkout was silently redirected to + // `~/.dkg-dev`, splitting their state across two homes on every + // re-run from the clone. + // + // Post-fix: the configExists auto-detection ORs both files, so a + // YAML-only `~/.dkg` correctly wins over the dev-home fallback. + const dkgDir = join(tempHome, '.dkg'); + await mkdir(dkgDir, { recursive: true }); + await writeFile(join(dkgDir, 'config.yaml'), 'name: test\napiPort: 9200\n'); + // No `configExists` opt — exercises the production auto-detect path. + expect(resolveDkgConfigHome({ + env: {}, + homeDir: tempHome, + isDkgMonorepo: true, + })).toBe(join(tempHome, '.dkg')); + }); + + it('Codex Round-3 Fix 2: monorepo + neither config.json nor config.yaml → still routes to ~/.dkg-dev', async () => { + // Counterpart: with NO config files in `~/.dkg`, the monorepo + // dev-home redirect still fires. Pins that the YAML detection + // doesn't accidentally over-broaden the configExists check. + expect(resolveDkgConfigHome({ + env: {}, + homeDir: tempHome, + isDkgMonorepo: true, + })).toBe(join(tempHome, '.dkg-dev')); + }); + + it('Codex Round-3 Fix 2: monorepo + both config.json AND config.yaml exist → returns ~/.dkg', async () => { + // Both files present: still returns `~/.dkg` (the OR + // short-circuits on either side). + const dkgDir = join(tempHome, '.dkg'); + await mkdir(dkgDir, { recursive: true }); + await writeFile(join(dkgDir, 'config.json'), '{}'); + await writeFile(join(dkgDir, 'config.yaml'), 'name: test\n'); + expect(resolveDkgConfigHome({ + env: {}, + homeDir: tempHome, + isDkgMonorepo: true, + })).toBe(join(tempHome, '.dkg')); + }); }); describe('dkgAuthTokenPath', () => { diff --git a/packages/mcp-dkg/README.md b/packages/mcp-dkg/README.md index 7c5e102e1..8c9a9b6ed 100644 --- a/packages/mcp-dkg/README.md +++ b/packages/mcp-dkg/README.md @@ -1,6 +1,6 @@ # `@origintrail-official/dkg-mcp` -[Model Context Protocol](https://modelcontextprotocol.io) server that exposes your local DKG V10 daemon to **Cursor**, **Claude Code**, **Continue**, **Cline**, and any other MCP-aware coding assistant. It is the canonical V10 surface for "DKG as agent memory." +[Model Context Protocol](https://modelcontextprotocol.io) server that exposes your local DKG V10 daemon to **Cursor**, **Claude Code**, **Claude Desktop**, **Windsurf**, **VSCode + GitHub Copilot Chat**, **Cline**, and any other MCP-aware coding assistant. It is the canonical V10 surface for "DKG as agent memory." The package ships transitively as part of `@origintrail-official/dkg`. You don't run the bin directly — the umbrella CLI's `dkg mcp serve` invokes it on the client's behalf. @@ -18,24 +18,33 @@ dkg mcp setup # one-shot: init + start + fund + r 1. Initializes `~/.dkg/config.json` if absent (skipped silently when present) 2. Starts the DKG daemon as a background process (skipped if already running) 3. Funds the node's wallets via the testnet faucet (skip with `--no-fund`) -4. Detects each MCP-aware client by its config file (`~/.cursor/mcp.json`, `~/.claude.json`) and writes the canonical entry under `mcpServers.dkg` +4. Detects each MCP-aware client by its config file and writes the canonical entry. **You confirm per detected client interactively** (`Register DKG MCP with ? [Y/n]`) unless `--yes` is passed; non-TTY invocations (CI, piped stdin) auto-confirm so scripts don't hang. The detection set is six clients: **Cursor** (`~/.cursor/mcp.json`), **Claude Code** (`~/.claude.json`), **Claude Desktop** (per-platform — `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows, `$XDG_CONFIG_HOME/Claude/claude_desktop_config.json` (or `~/.config/Claude/...` when XDG_CONFIG_HOME is unset) on Linux), **Windsurf** (`~/.codeium/windsurf/mcp_config.json`), **VSCode + GitHub Copilot Chat** (per-platform Code user-settings dir + `mcp.json` — note this client uses the `servers.dkg` shape, not `mcpServers.dkg`), and **Cline** (deep-nested under VSCode's per-extension globalStorage at `Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`). The five `mcpServers.dkg` clients receive the same JSON block 5. Verifies the daemon is healthy -Every step short-circuits when its work is already done, so re-running on a set-up box is safe. Step-skip flags: `--no-start`, `--no-fund`, `--no-verify`, `--dry-run` (preview only), `--force` (refresh every detected client config regardless of state). First-init overrides: `--port `, `--name `. The bundled flow re-uses the same primitives `dkg openclaw setup` does, so the two verbs stay byte-aligned on network defaults, daemon-readiness probes, faucet retry/back-off, and manual-curl fallback. +Every step short-circuits when its work is already done, so re-running on a set-up box is safe. Step-skip flags: `--no-start`, `--no-fund`, `--no-verify`, `--dry-run` (preview only), `--force` (refresh every detected client config regardless of state), `--yes` (auto-confirm per-client registrations; default false — TTY mode prompts interactively, non-TTY auto-confirms; pass `--yes` in scripts for the safer scripted-environment posture). First-init overrides: `--port `, `--name `. The bundled flow re-uses the same primitives `dkg openclaw setup` does, so the two verbs stay byte-aligned on network defaults, daemon-readiness probes, faucet retry/back-off, and manual-curl fallback. -The canonical entry written into each client's config: +The canonical entry written into each client's config (paths shown POSIX-style; Windows users see equivalent Windows-absolute paths): ```json { "mcpServers": { "dkg": { - "command": "dkg", - "args": ["mcp", "serve"] + "command": "/usr/local/bin/node", + "args": [ + "/usr/local/lib/node_modules/@origintrail-official/dkg/dist/cli.js", + "mcp", + "serve" + ], + "env": { + "DKG_HOME": "/Users/you/.dkg" + } } } } ``` +The `command` is the absolute path to the Node binary running this CLI (`process.execPath` at setup time); the first arg is the absolute path to the installed CLI's `cli.js` (resolved from `process.argv[1]` via `realpathSync`, which canonicalises symlinks across `npm relink` / version-manager rotations). GUI MCP clients (Claude Desktop, Windsurf, VSCode + Copilot) often don't inherit the shell PATH that includes `node` or the `dkg` shim, so writing the resolved absolute paths makes the registration robust against that gap. The `env.DKG_HOME` field propagates the resolved bootstrap home so spawned MCP servers (which don't inherit shell env in GUI clients) read the same `config.yaml` / `auth.token` that setup just bootstrapped — important under `DKG_HOME=/custom` or `--monorepo` where the home is `~/.dkg-dev`. `dkg mcp setup` resolves and writes all three automatically — you only need this manual shape when configuring by hand. For VSCode + Copilot Chat, swap the outer `mcpServers` key for `servers` while keeping the same inner block. + No tokens or URLs in the JSON — those live in `~/.dkg/config.yaml` and the daemon-written `~/.dkg/auth.token`. If no client is detected, run `dkg mcp setup --print-only` to emit the JSON for manual paste. After `dkg mcp setup` runs, restart your client so it discovers the MCP. Verify by asking the agent: *"What tools does dkg expose?"* The `tools/list` response must include `dkg_assertion_create`, `dkg_assertion_write`, and `dkg_memory_search`. @@ -44,24 +53,58 @@ After `dkg mcp setup` runs, restart your client so it discovers the MCP. Verify For environments where `dkg mcp setup` can't run (CI, locked-down configs, custom paths), drop the same block in by hand: -- **Cursor** — `~/.cursor/mcp.json` (or workspace `.cursor/mcp.json`) -- **Claude Code** — `~/.claude.json`, or run `claude mcp add dkg dkg mcp serve` -- **Continue / Cline / generic MCP client** — the project's MCP config file, same JSON shape +- **Cursor** — `~/.cursor/mcp.json` (or workspace `.cursor/mcp.json`); `mcpServers.dkg` +- **Claude Code** — `~/.claude.json`, or run `claude mcp add dkg dkg mcp serve`; `mcpServers.dkg` +- **Claude Desktop** — per-platform path (see step 4 above); `mcpServers.dkg` +- **Windsurf (Codeium)** — `~/.codeium/windsurf/mcp_config.json`; `mcpServers.dkg` +- **VSCode + GitHub Copilot Chat** — per-platform Code user-settings dir + `mcp.json`; **`servers.dkg`** (Copilot Chat uses `servers`, not `mcpServers` — copy the inner block, not the outer wrapper) +- **Cline** — `Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` under your VSCode user dir; `mcpServers.dkg` +- **Continue / Codex CLI / generic MCP client** — Continue uses YAML and Codex CLI uses TOML; not auto-detected today (deferred to a follow-up). Run `dkg mcp setup --print-only` to emit the canonical JSON block, then translate into the client's native format + +### Contributor (monorepo dev) workflow + +To register the local monorepo CLI dist with your MCP clients (so the registered server runs your in-progress changes), use **either** of these two entry-points. Auto-detect keys off the *running CLI's* on-disk location, **not** your shell `cwd` — so just `cd`-ing into the checkout and calling the global `dkg` is NOT enough. -For monorepo contributors working from source without a global install, the workspace-relative form (matches the repo's own `.cursor/mcp.json`): +**Option A (preferred): invoke the repo-built CLI directly.** Auto-detect sees the running CLI lives inside the monorepo and switches to monorepo mode automatically: + +```bash +pnpm --filter @origintrail-official/dkg build # rebuild the CLI dist +node packages/cli/dist/cli.js mcp setup # invoke the local build directly +``` + +**Option B: pass `--monorepo` with the global bin.** When you have `npm i -g @origintrail-official/dkg` already and want to override auto-detect from the global install, pass `--monorepo` from inside the checkout. The flag's contract is "use the monorepo from this `cwd`", so the global `dkg` invocation resolves the local checkout via cwd: + +```bash +pnpm --filter @origintrail-official/dkg build # rebuild the CLI dist +dkg mcp setup --monorepo # global `dkg` + explicit monorepo override +``` + +Either way, the resolved registration looks like this — the shape stays uniform across modes; only `args[0]` differs: ```json { "mcpServers": { "dkg": { - "command": "pnpm", - "args": ["exec", "tsx", "packages/mcp-dkg/src/index.ts"], - "cwd": "${workspaceFolder}" + "command": "/usr/local/bin/node", + "args": ["/absolute/path/to/dkg-v9/packages/cli/dist/cli.js", "mcp", "serve"] } } } ``` +**What does NOT work**: `cd dkg-v9 && dkg mcp setup` (without `--monorepo`). With a globally-installed `dkg`, the running CLI lives at the npm global path — auto-detect sees that location is outside any monorepo and stays in installed mode, registering the global build. Your local edits won't be picked up. Either invoke the local dist directly (Option A) or pass `--monorepo` (Option B). + +**Always rebuild before re-running setup** — skip the rebuild and the registered entry points at a stale `dist/cli.js`, so your edits won't show up. + +**Mode overrides** (mutually exclusive — pass at most one): + +- `--installed` forces installed-mode setup. **Bootstrap home**: `~/.dkg`. **Registered binary**: the running CLI (whichever invoked the command — typically the global `dkg`). Use this from a monorepo cwd when you want the global install registered instead of the local dist. Only the bootstrap home changes — the registered binary is always the CLI you ran. +- `--monorepo` forces monorepo-mode setup. **Bootstrap home**: `~/.dkg-dev`. **Registered binary**: the local `/packages/cli/dist/cli.js` script (located via cwd-first walk; falls back to the running CLI dir). Errors if no DKG monorepo root is detected. Unlike `--installed`, this switches **both** the bootstrap home **and** the registered binary — so re-running setup in a fresh checkout with `--monorepo` swaps the persisted MCP entry to the local build. + +The `[setup] Registering CLI: …` log line emitted at registration time prints the exact `command` and `args` that will be persisted into client configs, so you can verify the resolved binary path before any write happens. + +**Moved checkout caveat.** The written `args` carry an absolute path. If you rename or move your checkout, every registered client still points at the old path. Re-run `dkg mcp setup --force` from the new location to refresh every detected client's entry. + ### Configuration sources The MCP server resolves config from two places, in priority order: @@ -218,13 +261,17 @@ Per-turn state is kept in `~/.cache/dkg-mcp/sessions/*.json`; safe to delete at ## Troubleshooting -- **`dkg mcp setup` says "no MCP-aware clients detected"** → install Cursor, Claude Code, Continue, or Cline (or run with `--print-only` to copy the JSON yourself). +- **`dkg mcp setup` says "no MCP-aware clients detected"** → install one of Cursor, Claude Code, Claude Desktop, Windsurf, VSCode + GitHub Copilot Chat, or Cline. Continue and Codex CLI are NOT auto-detected today (Continue's YAML-config shape and Codex CLI's TOML format ship in a follow-up); users with those clients should run `dkg mcp setup --print-only` and paste the JSON manually. - **`dkg mcp` says command not found** → the umbrella CLI isn't on PATH. Verify with `which dkg`. Note: `npm i -g @origintrail-official/dkg` does NOT propagate transitive bins to global PATH, so the `dkg-mcp` bin is only reachable through `dkg mcp serve` or via a direct `npx -p @origintrail-official/dkg-mcp dkg-mcp`. - **MCP not visible in client** → restart the client. On Cursor, verify `~/.cursor/mcp.json` is syntactically valid JSON. On Claude Code, run `claude mcp list`. - **"No project specified"** → set `contextGraph: ` in `.dkg/config.yaml`, or pass `projectId` on each tool call, or export `DKG_PROJECT`. - **HTTP 401 from MCP tools** → token mismatch. `dkg auth show` returns the expected value; confirm it matches `~/.dkg/auth.token`. On CI / containers / proxied environments where `dkg init` can't run, the env-var fallbacks are `DKG_API` (daemon URL, default `http://localhost:9200`), `DKG_TOKEN` (bearer), `DKG_PROJECT` (default context graph), `DKG_AGENT_URI` (operator agent URI). A stale exported `DKG_PROJECT` from a prior session can silently mis-route writes — unset it if you switch projects. - **HTTP 404 on `/api/context-graph/list`** → you're on an older daemon; the client automatically falls back to the legacy endpoint. - **`tools/list` is missing tools after `dkg mcp setup`** → the client's MCP config still points at a prior install. Re-run `dkg mcp setup --force` to refresh stale entries. +- **Daemon unreachable** → `dkg status`; if it errors, `dkg logs` and `cat ~/.dkg/daemon.log`. Stale pid → `cat ~/.dkg/daemon.pid` and kill it, then `dkg start` again. +- **Port 9200 already in use** → another node is running. `dkg stop` once, or override via `dkg init` and pick a different API port. +- **WSL2: daemon dies when the terminal closes** → wrap in `tmux` or install as a systemd user service. See the [WSL2 section in JOIN_TESTNET.md](../../docs/setup/JOIN_TESTNET.md) for the systemd unit file. +- **WSL2: Windows-side MCP clients (Claude Desktop, Cursor, VSCode + Copilot, Cline, Windsurf)** → run `dkg mcp setup` from **PowerShell**, not from inside WSL. Setup invoked from WSL detects the Windows-side configs and writes entries into them, but the registered `command` is the Linux-side `node` binary path; Win32 clients can't spawn Linux executables, so the entries fail at MCP startup. For **Linux-side** clients (Linux Cursor, Linux Claude Code), run setup from inside WSL as normal. End-to-end Windows-side support from a WSL invocation is tracked separately (will use a `wsl.exe`-wrapper command form once shipped). ## Package layout diff --git a/packages/mcp-dkg/docs/INBOUND_INVITES.md b/packages/mcp-dkg/docs/INBOUND_INVITES.md deleted file mode 100644 index 2e1ae6d19..000000000 --- a/packages/mcp-dkg/docs/INBOUND_INVITES.md +++ /dev/null @@ -1,62 +0,0 @@ -# Inbound invite notification surface — investigation + proposal - -**Investigation date:** 2026-04-18 -**Status:** confirmed gap; deferred to Phase 8 (requires daemon changes, not just UI polish) - -## Question - -When Operator A invites Operator B's agent to a curated context graph via `POST /api/context-graph/invite`, does Operator B see any passive UI indicator (bell, banner, inbox) on their node-ui without having to know the CG ID and paste it into `JoinProjectModal`? - -## Answer: No - -The existing notification + SSE infrastructure handles **join-request** flows (Operator B requests to join a CG they already know about; Operator A as curator sees the request; Operator B gets a `join_approved` notification) but does **not** handle the **curator-pushes-allowlist-entry** case. - -## What works today - -- **Curator side:** `JOIN_REQUEST_RECEIVED` event → `dashDb.insertNotification` + `sseBroadcast('join_request', ...)` → Header notification bell + `useNodeEvents` SSE listener pick it up. -- **Requester side (after curator approves):** `JOIN_APPROVED` event → notification + `sseBroadcast('join_approved', ...)` → same UI pickup. -- `JoinProjectModal` provides a paste-an-invite-code UX, signs the join request, polls `/api/context-graph//catchup-status` until `done` / `denied` / `failed`. - -## What's missing - -`POST /api/context-graph/invite` (`packages/cli/src/daemon.ts:4506`) calls `agent.inviteToContextGraph(contextGraphId, peerId)` (`packages/agent/src/dkg-agent.ts:3292`), which only updates the curator's local `_meta` allowlist. There is no: - -- Daemon endpoint exposing "invites my agent appears on the allowlist for" -- P2P "you've been invited" message from curator → invitee -- Event bus emission on the invitee's node when their agent's address appears in a remote curator's allowlist (which they'd see via gossip of `_meta` SWM) -- SSE event `context_graph_invite` for the bell to render - -## Proposed fix (Phase 8) - -Smallest incremental wiring, ordered: - -1. **Daemon — detect allowlist membership on meta-sync.** When `_meta` from a curator syncs in and contains an allowlist entry naming this node's agent address, emit `DKGEvent.CONTEXT_GRAPH_INVITED` on the agent's event bus. The detection is a SPARQL query against the just-synced `_meta` graph: `SELECT ?cg WHERE { ?cg dkg:allowedAgent }`. - -2. **Daemon — wire the event to notification + SSE.** Mirror the `join_request` / `join_approved` pattern in `daemon.ts`: - ```ts - agent.eventBus.on(DKGEvent.CONTEXT_GRAPH_INVITED, (data) => { - dashDb.insertNotification({ - type: 'context_graph_invite', - title: 'You have been invited to a project', - message: `${shortId(data.curatorAgent)} added you to ${shortId(data.contextGraphId)}.`, - meta: JSON.stringify({ contextGraphId: data.contextGraphId, curatorAgent: data.curatorAgent }), - }); - sseBroadcast('context_graph_invite', { contextGraphId: data.contextGraphId, curatorAgent: data.curatorAgent }); - }); - ``` - -3. **UI — extend the SSE listener.** Add `'context_graph_invite'` to the `NodeEventType` union in `packages/node-ui/src/ui/hooks/useNodeEvents.ts` and have `Header.tsx` reload notifications when it fires (already done generically — adding the case is one line). - -4. **UI — make the notification clickable.** When the operator clicks an invite notification in the Header bell, open `JoinProjectModal` pre-filled with the `contextGraphId` from `meta.contextGraphId`. Already supported via `JoinProjectModal`'s `initialContextGraphId` prop. - -5. **(Optional) Inbox panel.** A dedicated `Inbox` view listing all unread `context_graph_invite` notifications with one-click join buttons. Nice-to-have; the bell badge + click-to-join handles the v1 use case. - -Estimated effort: ~half a day. Mostly daemon work; UI is trivial once the events and notifications flow. - -## Why we didn't ship it in Phase 7 - -Phase 7's scope is agent-emitted graph annotations + project ontology + URI convergence. Inbound invite notifications are a separate concern (operator UX vs agent annotation behaviour) and the daemon work is non-trivial enough to warrant its own change (event-bus addition, SPARQL detection logic, allowlist-sync semantics). Better to file it cleanly than to half-ship. - -## Workaround for now - -Operator A pastes the project ID + multiaddr into a chat or message; Operator B opens `JoinProjectModal` and pastes it. Functional but not passive. diff --git a/packages/mcp-dkg/src/config.ts b/packages/mcp-dkg/src/config.ts index 49b6f1fdc..cd452d92f 100644 --- a/packages/mcp-dkg/src/config.ts +++ b/packages/mcp-dkg/src/config.ts @@ -8,6 +8,34 @@ * by environment variables so npx-style installs that live outside a * workspace can still point at something: * + * DKG_HOME — DKG state directory. When set, config is + * resolved from one of two sources at the + * home: + * 1. `/config.json` — the daemon + * config that `dkg mcp setup`'s + * writeDkgConfig writes (apiPort / + * contextGraphs / auth shape). Translated + * to DkgConfig via loadConfigFromDkgHome: + * `api ← http://localhost:`, + * `token ← /auth.token`'s first + * non-comment line, `defaultProject ← + * contextGraphs[0]`. + * 2. `/config.yaml` — workspace- + * shape, parsed via the regular yaml flow. + * Used when an operator hand-writes a + * workspace config at the home directly. + * The cwd-walk is skipped entirely under + * DKG_HOME. Propagated by `dkg mcp setup` via + * the MCP entry's `env: { DKG_HOME }` field + * (Round-9 Fix 16) so GUI clients spawning the + * registered command read the same home setup + * just bootstrapped — they don't inherit shell + * env. Operators can also export it from their + * shell. (Round-11 Fix 18 added DKG_HOME-as- + * yaml; Round-19 Fix 25 added json precedence + * via the wrong parser; Round-21 Fix 27 + * replaced that with a real daemon-config + * translator.) * DKG_API — daemon base URL (default http://localhost:9200) * DKG_TOKEN — bearer token (no default; read-only tools * still need it in most setups) @@ -75,8 +103,29 @@ function readIfExists(filePath: string): string | null { } } -/** Walk upwards from `start` looking for `.dkg/config.yaml`. */ +/** + * Locate a workspace-shape `.dkg/config.yaml`. When `DKG_HOME` is + * set, this checks `/config.yaml` directly and returns + * `null` if it doesn't exist (the cwd-walk is suppressed). When + * `DKG_HOME` is unset, walks upwards from `start` looking for the + * spec-canonical `.dkg/config.yaml`. + * + * Codex Round-21 Fix 27: this helper now ONLY handles the + * workspace-shape yaml. The setup-home daemon-config path (where + * `dkg mcp setup` writes `/config.json` with apiPort / + * contextGraphs / auth, NOT the node.api/node.token/project shape + * loadConfig parses) is handled by `loadConfigFromDkgHome` in a + * dedicated translator. Round-19 Fix 25 incorrectly tried to + * parse the daemon-config JSON with the workspace-yaml extractor + * — every field name mismatched, so the translation extracted + * nothing and the post-setup path 401'd. + */ function findConfigFile(start: string): string | null { + const dkgHome = process.env.DKG_HOME?.trim() || null; + if (dkgHome) { + const candidate = path.join(dkgHome, 'config.yaml'); + return fs.existsSync(candidate) ? candidate : null; + } let dir = path.resolve(start); const root = path.parse(dir).root; while (true) { @@ -89,6 +138,113 @@ function findConfigFile(start: string): string | null { } } +/** + * Codex Round-21 Fix 27: translate a setup-home daemon config into + * the `DkgConfig` shape that `loadConfig` returns. + * + * `dkg mcp setup`'s `writeDkgConfig` writes a daemon config to + * `/config.json` with a different shape than the + * workspace agent config that `loadConfig` traditionally parses: + * + * daemon config (config.json): + * { apiPort, nodeRole, contextGraphs, auth: { enabled }, … } + * + * workspace agent config (config.yaml): + * { node: { api, token, tokenFile }, project, agent: { uri }, … } + * + * Round-19 Fix 25 tried to read config.json with the yaml-shape + * extractor — every field name was wrong, so cfg ended up with + * empty token + localhost:9200 + null project, and every write + * 401'd despite the FIX 16 → FIX 18 → FIX 25 chain that was + * meant to make the round-trip work. + * + * This translator does the actual mapping: + * - `api` ← `http://localhost:` (default 9200) + * - `token` ← first non-comment line of `/auth.token` + * - `defaultProject` ← `contextGraphs[0]` + * + * Returns `null` when `/config.json` doesn't exist (so + * loadConfig can fall through to the path-B yaml branch). + * + * Env vars (DKG_API / DKG_TOKEN / DKG_PROJECT / DKG_AGENT_URI) + * still override the file values per the operator-precedence + * contract — operators with custom shell exports get the same + * behaviour they did before. + */ +function loadConfigFromDkgHome(dkgHome: string): DkgConfig | null { + const jsonPath = path.join(dkgHome, 'config.json'); + if (!fs.existsSync(jsonPath)) return null; + + let daemonConfig: Record = {}; + try { + const raw = fs.readFileSync(jsonPath, 'utf-8'); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + daemonConfig = parsed as Record; + } + } catch (err) { + // Malformed JSON is non-fatal; fall through to env-only defaults + // below with sourcePath still set so diagnostics can show the + // operator which file failed to parse. + process.stderr.write( + `[mcp-dkg] warning: could not parse ${jsonPath}: ${ + err instanceof Error ? err.message : String(err) + }\n`, + ); + } + + const apiPort = typeof daemonConfig.apiPort === 'number' ? daemonConfig.apiPort : 9200; + const fileApi = `http://localhost:${apiPort}`; + const contextGraphs = Array.isArray(daemonConfig.contextGraphs) + ? daemonConfig.contextGraphs + : []; + const fileDefaultProject = + contextGraphs.length > 0 && typeof contextGraphs[0] === 'string' + ? (contextGraphs[0] as string) + : null; + + // Auth token from the dedicated `/auth.token` file + // (one non-comment line, same format as the existing + // resolveTokenFromFile helper handles). + const tokenPath = path.join(dkgHome, 'auth.token'); + let fileToken = ''; + if (fs.existsSync(tokenPath)) { + const tokenContent = readIfExists(tokenPath); + if (tokenContent) { + const line = tokenContent + .split('\n') + .find((l) => l.trim() && !l.startsWith('#')); + if (line) fileToken = line.trim(); + } + } + + // Operator env-var overrides win over file values (matches the + // existing loadConfig precedence semantics for operator-set + // overrides — env wins for things the file doesn't pin or that + // the operator explicitly wants to redirect). + const envApi = asString(process.env.DKG_API) ?? asString(process.env.DEVNET_API); + const envToken = + asString(process.env.DKG_TOKEN) ?? + asString(process.env.DEVNET_TOKEN) ?? + asString(process.env.DKG_AUTH); + const envProject = asString(process.env.DKG_PROJECT); + const envAgent = asString(process.env.DKG_AGENT_URI); + + return { + api: envApi ?? fileApi, + token: envToken ?? fileToken, + defaultProject: envProject ?? fileDefaultProject, + agentUri: envAgent ?? null, + capture: { + autoShare: true, + defaultPrivacy: 'team', + subGraph: 'chat', + assertion: 'chat-log', + }, + sourcePath: jsonPath, + }; +} + function resolveTokenFromFile(filePath: string): string | null { const raw = readIfExists(filePath); if (raw == null) return null; @@ -119,6 +275,24 @@ function asPrivacy(v: unknown): 'private' | 'team' | 'public' { * + defaults, which is fine for tools that don't need auth. */ export function loadConfig(cwd: string = process.cwd()): DkgConfig { + // Codex Round-21 Fix 27: when DKG_HOME is set AND points at a + // setup-home (config.json present), translate the daemon config + // shape into DkgConfig. Round-19 Fix 25 incorrectly tried to + // parse the daemon JSON with the workspace-yaml extractor — + // every field name mismatched and the post-setup path 401'd. + // The dedicated translator handles api / token / defaultProject + // derivation correctly. Returns null when no config.json exists, + // which lets us fall through to the path-B yaml branch below. + const dkgHome = process.env.DKG_HOME?.trim() || null; + if (dkgHome) { + const fromDkgHome = loadConfigFromDkgHome(dkgHome); + if (fromDkgHome) return fromDkgHome; + // Else fall through: DKG_HOME is set but config.json doesn't + // exist there — operator may have hand-written a workspace + // shape config.yaml at that path. The findConfigFile() + // DKG_HOME branch above will pick that up. + } + const envApi = asString(process.env.DKG_API) ?? asString(process.env.DEVNET_API); const envToken = asString(process.env.DKG_TOKEN) ?? asString(process.env.DEVNET_TOKEN) ?? asString(process.env.DKG_AUTH); const envProject = asString(process.env.DKG_PROJECT); @@ -132,13 +306,20 @@ export function loadConfig(cwd: string = process.cwd()): DkgConfig { const raw = readIfExists(configPath); if (raw) { try { + // Workspace-canonical yaml format. Round-21 Fix 27 reverted + // the Round-19 Fix 25 format-aware dispatch — the daemon + // config.json path is handled upstream by + // loadConfigFromDkgHome, so by the time we get here, the + // file is always yaml-shape (either a workspace + // .dkg/config.yaml or a hand-written DKG_HOME/config.yaml). const parsed = parseYaml(raw); if (parsed && typeof parsed === 'object') { fromFile = parsed as Record; } } catch (err) { - // Malformed YAML is not fatal — we just ignore it and log to stderr - // so the user sees the problem without blocking the server startup. + // Malformed YAML is not fatal — we just ignore it and log to + // stderr so the user sees the problem without blocking the + // server startup. process.stderr.write( `[mcp-dkg] warning: could not parse ${configPath}: ${ err instanceof Error ? err.message : String(err) diff --git a/packages/mcp-dkg/src/tools/assertions.ts b/packages/mcp-dkg/src/tools/assertions.ts index cdf6bd491..78e348f68 100644 --- a/packages/mcp-dkg/src/tools/assertions.ts +++ b/packages/mcp-dkg/src/tools/assertions.ts @@ -105,18 +105,38 @@ export function registerAssertionTools( 'Step 2 of the canonical write flow: append RDF quads into an ' + 'existing Working Memory assertion. Writes are additive (set-merge); ' + 'callers that want replace semantics should call `dkg_assertion_discard` ' + - 'first or mint a unique assertion name per snapshot.', + 'first or mint a unique assertion name per snapshot.\n\n' + + 'IMPORTANT — quad shape: each quad has subject/predicate/object/graph. ' + + 'Subjects and predicates are ALWAYS URIs (no spaces). The `object` field ' + + 'accepts EITHER a URI (no surrounding quotes) OR a literal string ' + + 'WRAPPED IN DOUBLE QUOTES. Most common mistake: passing free-text ' + + 'literals without quotes — those get parsed as URIs and fail on the ' + + 'embedded spaces.', inputSchema: { - name: z.string().describe('Existing assertion name'), + name: z.string().describe('Existing assertion name (e.g. "my-notes-2026-05-07")'), quads: z .array( z.object({ - subject: z.string().describe('Subject URI'), - predicate: z.string().describe('Predicate URI'), - object: z - .string() - .describe('Object URI or literal value (raw, including any quoting)'), - graph: z.string().optional().describe('Optional named graph URI'), + subject: z.string().describe( + 'Subject URI. Plain string like "urn:my-thing" or "did:dkg:agent/abc". ' + + 'Angle brackets are tolerated and stripped (`` → `urn:foo`). ' + + 'MUST NOT contain spaces — URIs are space-free by spec.', + ), + predicate: z.string().describe( + 'Predicate URI. Same rules as subject. Common predicates: ' + + '"rdfs:label", "rdf:type", "schema:name", or any custom URI.', + ), + object: z.string().describe( + 'Object value. EITHER a URI (same rules as subject — plain or ' + + 'angle-bracketed, no spaces) OR a literal string WRAPPED IN ' + + 'DOUBLE QUOTES.\n' + + ' URI example: "urn:other-thing" or ""\n' + + ' Literal example: "\\"Hello world with spaces\\"" ← double quotes mandatory for literals\n' + + ' Typed literal: "\\"42\\"^^"\n' + + ' Lang-tagged: "\\"hello\\"@en"\n' + + 'A literal without surrounding quotes will be parsed as a URI and FAIL on spaces.', + ), + graph: z.string().optional().describe('Optional named graph URI (same rules as subject)'), }), ) .min(1) diff --git a/packages/mcp-dkg/test/config.test.ts b/packages/mcp-dkg/test/config.test.ts new file mode 100644 index 000000000..7ab977b14 --- /dev/null +++ b/packages/mcp-dkg/test/config.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadConfig } from '../src/config.js'; + +/** + * Tests for `loadConfig()`'s DKG_HOME precedence. Codex Round-11 + * Fix 18: when `DKG_HOME` is set, the loader reads + * `/config.yaml` directly (no cwd-walk fallback). When + * `DKG_HOME` is unset, the existing cwd-walk for `.dkg/config.yaml` + * is preserved as the spec-canonical workspace path. + * + * Round-9 Fix 16 propagates `DKG_HOME` into the MCP entry's `env` + * field so spawned MCP servers (in GUI clients that don't inherit + * shell env) read the same home setup just bootstrapped. Without + * Fix 18, that propagation was inert at runtime — `loadConfig` + * ignored `DKG_HOME`. + */ +describe('loadConfig — DKG_HOME precedence (Codex Round-11 Fix 18)', () => { + let tmpRoot: string; + let originalDkgHome: string | undefined; + let originalCwd: string; + + beforeEach(() => { + tmpRoot = mkdtempSync(join(tmpdir(), 'mcp-dkg-config-test-')); + originalDkgHome = process.env.DKG_HOME; + delete process.env.DKG_HOME; + originalCwd = process.cwd(); + }); + + afterEach(() => { + if (originalDkgHome !== undefined) process.env.DKG_HOME = originalDkgHome; + else delete process.env.DKG_HOME; + try { + process.chdir(originalCwd); + } catch { + // Best-effort restore — the original cwd may have been deleted by a + // sibling test that used the same pattern. Leave the next test's + // beforeEach to set its own working dir. + } + rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('Codex Round-11 Fix 18: DKG_HOME set + /config.yaml exists → loads from there', () => { + // Pre-fix: loadConfig ignored DKG_HOME entirely, only walking + // `.dkg/config.yaml` from cwd. Round-9 Fix 16's `env: { + // DKG_HOME }` propagation was inert at runtime. Post-fix: when + // DKG_HOME is set, config is read from `/config.yaml` + // directly. + const home = join(tmpRoot, 'fake-dkg-home'); + mkdirSync(home, { recursive: true }); + writeFileSync( + join(home, 'config.yaml'), + 'node:\n api: http://x:9001\n', + ); + process.env.DKG_HOME = home; + + const cfg = loadConfig(); + expect(cfg.api).toBe('http://x:9001'); + expect(cfg.sourcePath).toBe(join(home, 'config.yaml')); + }); + + it('Codex Round-11 Fix 18: DKG_HOME set + no config.yaml at that path → no fallback to cwd-walk', () => { + // The no-fallback contract: when DKG_HOME is set explicitly, + // a missing config.yaml at that path returns null + // (sourcePath: null), NOT a silent fall-through to a cwd-walk. + // Falling back would mask a missing-config issue and + // re-introduce the cwd-dependence Fix 16 was meant to break. + // + // Setup: DKG_HOME=, then chdir into a workspace + // that DOES have `.dkg/config.yaml`. Pre-fix, the cwd-walk + // would have found and loaded the workspace config. Post-fix, + // the empty DKG_HOME wins → sourcePath: null. + const home = join(tmpRoot, 'empty-dkg-home'); + mkdirSync(home, { recursive: true }); + process.env.DKG_HOME = home; + + const workspace = join(tmpRoot, 'workspace'); + mkdirSync(join(workspace, '.dkg'), { recursive: true }); + writeFileSync( + join(workspace, '.dkg', 'config.yaml'), + 'node:\n api: http://workspace:9999\n', + ); + process.chdir(workspace); + + const cfg = loadConfig(); + // sourcePath null → no config file located. + expect(cfg.sourcePath).toBeNull(); + // The workspace config was NOT silently loaded. + expect(cfg.api).not.toBe('http://workspace:9999'); + }); + + it('Codex Round-11 Fix 18: DKG_HOME unset → existing cwd-walk for .dkg/config.yaml preserved', () => { + // Regression guard for the spec-canonical workspace path. With + // DKG_HOME unset, walking upwards from cwd looking for + // `.dkg/config.yaml` MUST still work. + const workspace = join(tmpRoot, 'workspace'); + mkdirSync(join(workspace, '.dkg'), { recursive: true }); + writeFileSync( + join(workspace, '.dkg', 'config.yaml'), + 'node:\n api: http://workspace:9999\n', + ); + process.chdir(workspace); + + const cfg = loadConfig(); + expect(cfg.api).toBe('http://workspace:9999'); + expect(cfg.sourcePath).toBe(join(workspace, '.dkg', 'config.yaml')); + }); + + it('Codex Round-11 Fix 18: round-trip — mcp-setup-style env propagation reads from the bootstrapped home', () => { + // Integration check: simulate what happens when a GUI client + // spawns the registered MCP entry. `dkg mcp setup` (Round-9 + // Fix 16) writes `env: { DKG_HOME }` into the MCP entry; the + // client's spawn injects DKG_HOME into the server process's + // env; the server's loadConfig then reads from . + // This tests that whole loop works end-to-end inside + // loadConfig's own contract. + const setupHome = join(tmpRoot, 'setup-home'); + mkdirSync(setupHome, { recursive: true }); + // `project` is a TOP-LEVEL field per loadConfig's contract + // (`fromFile.contextGraph` || `fromFile.project`); only `api` + // and `token` are nested under `node`. + writeFileSync( + join(setupHome, 'config.yaml'), + 'node:\n api: http://setup:9100\nproject: setup-project\n', + ); + // Simulate the spawn-time env injection. + process.env.DKG_HOME = setupHome; + + // chdir somewhere unrelated to verify cwd is not consulted. + process.chdir(tmpRoot); + + const cfg = loadConfig(); + expect(cfg.api).toBe('http://setup:9100'); + expect(cfg.defaultProject).toBe('setup-project'); + expect(cfg.sourcePath).toBe(join(setupHome, 'config.yaml')); + }); + + // ── Codex Round-21 Fix 27: translate setup-home daemon config ── + + it('Codex Round-21 Fix 27: DKG_HOME + setup-home config.json + auth.token → translates to DkgConfig (api / token / defaultProject)', async () => { + // Round-19 Fix 25 incorrectly tried to parse / + // config.json as workspace-shape yaml. The shapes are + // different — daemon config has `apiPort` / `contextGraphs` / + // `auth: { enabled }`, NOT `node.api` / `node.token` / + // `project` — so every extracted field fell through to env + // defaults and the GUI-spawned MCP server returned empty + // token + localhost:9200 + null project despite the FIX 16 → + // FIX 18 → FIX 25 chain. Round-21 Fix 27 replaces the + // mistranslation with a real translator: `api` derived from + // `apiPort`, `token` from /auth.token's first non- + // comment line, `defaultProject` from `contextGraphs[0]`. + const home = join(tmpRoot, 'setup-home'); + mkdirSync(home, { recursive: true }); + // Write a daemon config in the actual shape that + // `dkg mcp setup`'s writeDkgConfig produces. + writeFileSync( + join(home, 'config.json'), + JSON.stringify({ + name: 'mcp-agent-test', + apiPort: 9001, + nodeRole: 'edge', + contextGraphs: ['my-ctx'], + auth: { enabled: true }, + }), + ); + // And the dedicated auth.token file (the real source of the + // bearer token — NOT the daemon config). + writeFileSync(join(home, 'auth.token'), 'my-token\n'); + process.env.DKG_HOME = home; + + const cfg = loadConfig(); + // api derived from apiPort. + expect(cfg.api).toBe('http://localhost:9001'); + // token from auth.token file (one non-comment line). + expect(cfg.token).toBe('my-token'); + // defaultProject from contextGraphs[0]. + expect(cfg.defaultProject).toBe('my-ctx'); + // sourcePath points at the JSON for diagnostics. + expect(cfg.sourcePath).toBe(join(home, 'config.json')); + }); + + it('Codex Round-21 Fix 27: auth.token with comment lines + token → token correctly extracted', async () => { + // The auth.token file format allows `#`-prefixed comment + // lines (mirrors the existing resolveTokenFromFile helper). + // Pin that the translator skips comments and picks the first + // real non-empty line. + const home = join(tmpRoot, 'commented-token-home'); + mkdirSync(home, { recursive: true }); + writeFileSync( + join(home, 'config.json'), + JSON.stringify({ apiPort: 9200, contextGraphs: ['ctx'] }), + ); + writeFileSync( + join(home, 'auth.token'), + '# auto-generated by dkg mcp setup\n# do not edit\nreal-token-here\n', + ); + process.env.DKG_HOME = home; + + const cfg = loadConfig(); + expect(cfg.token).toBe('real-token-here'); + }); + + it('Codex Round-21 Fix 27: auth.token missing + DKG_HOME has config.json → token is empty string (graceful)', async () => { + // No crash when auth.token is missing — just empty token. + // Operators on fully-open dev setups (or pre-auth installs) + // hit this case naturally. + const home = join(tmpRoot, 'no-token-home'); + mkdirSync(home, { recursive: true }); + writeFileSync( + join(home, 'config.json'), + JSON.stringify({ apiPort: 9200, contextGraphs: [] }), + ); + process.env.DKG_HOME = home; + + const cfg = loadConfig(); + expect(cfg.token).toBe(''); + expect(cfg.defaultProject).toBeNull(); + }); + + it('Codex Round-21 Fix 27: DKG_HOME set + DKG_TOKEN env var set → env wins (operator override)', async () => { + // Operator-precedence contract: env vars override file + // values. An operator who sets `DKG_TOKEN=...` from their + // shell wants that to win, regardless of what's at the home. + const home = join(tmpRoot, 'env-override-home'); + mkdirSync(home, { recursive: true }); + writeFileSync( + join(home, 'config.json'), + JSON.stringify({ apiPort: 9200, contextGraphs: [] }), + ); + writeFileSync(join(home, 'auth.token'), 'file-token\n'); + process.env.DKG_HOME = home; + process.env.DKG_TOKEN = 'env-token'; + try { + const cfg = loadConfig(); + expect(cfg.token).toBe('env-token'); + } finally { + delete process.env.DKG_TOKEN; + } + }); + + it('Codex Round-21 Fix 27: DKG_HOME + only config.yaml (no config.json) → falls back to yaml (Path B)', async () => { + // Round-11 Fix 18's original yaml-only contract preserved. + // When the operator hand-writes a workspace-shape + // /config.yaml without going through + // `dkg mcp setup`, loadConfig falls through to the regular + // yaml-parse path (loadConfigFromDkgHome returns null → + // findConfigFile picks up the yaml). + const home = join(tmpRoot, 'yaml-only-home'); + mkdirSync(home, { recursive: true }); + writeFileSync( + join(home, 'config.yaml'), + 'node:\n api: http://yaml:9300\n', + ); + process.env.DKG_HOME = home; + + const cfg = loadConfig(); + expect(cfg.api).toBe('http://yaml:9300'); + expect(cfg.sourcePath).toBe(join(home, 'config.yaml')); + }); + + it('Codex Round-21 Fix 27: DKG_HOME + neither file exists → sourcePath null + env defaults preserved', async () => { + // Behavior preserved from Round-11 Fix 18: empty home → + // sourcePath null + defaults. No crash, no cwd-walk. + const home = join(tmpRoot, 'empty-home'); + mkdirSync(home, { recursive: true }); + process.env.DKG_HOME = home; + + const cfg = loadConfig(); + expect(cfg.sourcePath).toBeNull(); + expect(cfg.api).toBe('http://localhost:9200'); + }); +});