From 9c5a754113378ff0c3036289a25183113223e958 Mon Sep 17 00:00:00 2001 From: ivndev001 Date: Wed, 2 Sep 2026 14:24:37 +0000 Subject: [PATCH 1/2] feat(config): read .dz/ui-debugger/ui-debugger-mcp.json before the root file (#64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repo that consolidated its agent config under .dz/ could not boot the tool. The two-candidate resolution now lives in ONE place — src/config/load.ts — exported as CONFIG_CANDIDATES plus resolveConfigPath(cwd): - loadConfig / loadWorkspaceDir / configFingerprint (content, not path) all resolve through it: .dz/ first, root .ui-debugger-mcp.json as legacy fallback. - init writes the starter config at .dz/... when a .dz/ dir already exists, else at root; only-if-absent applies to whichever candidate is found. - both present -> .dz/ wins and the tool prints ONE line naming the ignored root file (init: stdout; server boot: stderr — stdout is the stdio MCP JSON-RPC channel and must stay machine-only). - a bad .dz/ copy is a ConfigError exactly as a bad root copy is; root is NOT read in that case. Schema untouched. .mcp.json snippet printing unchanged. Tests written first (red on the parent commit): candidate order, .dz-first reads, both-present notice, only-if-absent, bad-.dz-errors-without-root, and the fingerprint keying on the resolved .dz/ copy. One existing expectation updated: look.test.ts pinned the literal ".ui-debugger-mcp.json" inside visionUnavailableMessage; the message now says "the project config" since a single root filename is no longer the whole truth. Same intent (names where the vision model is set), new truthful wording — flagged here rather than smuggled in silently. Docs left true: docs/idea/config.md (Resolution order + init + config sections), src/cli/help.ts, README.md, CLAUDE.md, CONTRIBUTING.md, docs/idea/architecture.md, docs/idea/models.md, docs/reference.md. Closes #64 Co-Authored-By: Claude Code --- CLAUDE.md | 18 ++++--- CONTRIBUTING.md | 2 +- README.md | 24 ++++++---- docs/idea/architecture.md | 2 +- docs/idea/config.md | 32 +++++++++---- docs/idea/models.md | 3 +- docs/reference.md | 5 +- src/adapters/browser/launch.ts | 2 +- src/adapters/factory.ts | 2 +- src/agent/belt/look.test.ts | 5 +- src/agent/belt/look.ts | 2 +- src/cli/help.ts | 2 +- src/cli/init.test.ts | 77 ++++++++++++++++++++++++++++- src/cli/init.ts | 48 +++++++++++++------ src/config/fingerprint.test.ts | 28 +++++++++-- src/config/fingerprint.ts | 12 +++-- src/config/load.test.ts | 85 ++++++++++++++++++++++++++++++++- src/config/load.ts | 69 ++++++++++++++++++++------ src/config/schema.ts | 5 +- src/main.ts | 14 ++++-- src/mcp/tools/output.ts | 2 +- src/mcp/tools/start-debug.ts | 2 +- src/services/debug-service.ts | 9 ++-- src/services/login.ts | 2 +- src/services/session-builder.ts | 2 +- 25 files changed, 369 insertions(+), 85 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9ba7321..c17299c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,8 @@ code, asks again. Loop until the UI works. No human clicking. - `desktop/` — X11/Wayland windows. Covers desktop app AND mobile emulator. ✅ shipped. - `android/` — ADB + uiautomator. ✅ shipped. - `src/session/` — cwd-keyed session, per-project workspace, state. -- `src/config/` — load `.ui-debugger-mcp.json`, resolve model/targets. +- `src/config/` — load the project config (`.dz/ui-debugger/ui-debugger-mcp.json` + first, root `.ui-debugger-mcp.json` fallback), resolve model/targets. - `src/services/` — business logic. Thin handlers, logic here. ## Targets (one project, many) @@ -88,8 +89,9 @@ unfiltered tail of 50 returned zero API traffic. Failed requests are never hidde ## CLI (bin: `ui-debugger-mcp`) - no args → run the stdio MCP server (default). -- `init` → scaffold: create `./tmp/ui-debugger-mcp/`, write a starter - `.ui-debugger-mcp.json` (deepseek/glm defaults + `web` stub) if absent, add `tmp/` +- `init` → scaffold: create `./tmp/ui-debugger-mcp/`, write a starter config + (deepseek/glm defaults + `web` stub) if absent — at `.dz/ui-debugger/ui-debugger-mcp.json` + when a `.dz/` dir exists, else root `.ui-debugger-mcp.json` — add `tmp/` to `.gitignore`, print the `.mcp.json` snippet. NEVER writes the API key. - `status` → the active run for this cwd: session id, target, goal, server pid (+ alive?), verdict, finding counts. Reads `state.json` + `findings.json`. No API key. @@ -102,9 +104,13 @@ project (cwd) → no run selector needed. ## Config split - `.mcp.json` — how to LAUNCH server (command, model API key + base url). Gitignored. Secret. -- `.ui-debugger-mcp.json` — how to DEBUG this app (models, targets, urls). Committed. +- Project config — how to DEBUG this app (models, targets, urls). Committed. + Two candidate locations, one shape: `.dz/ui-debugger/ui-debugger-mcp.json` + (checked FIRST; wins when both exist, with a one-line notice naming the ignored + root file) or root `.ui-debugger-mcp.json` (legacy). A bad `.dz/` copy errors + without reading root. -`.ui-debugger-mcp.json` shape: +Project config shape: ``` models: { driver, vision, summary? } per-role; defaults: deepseek (text), glm (image) targets: @@ -236,7 +242,7 @@ diagnostic, the secret must NEVER enter the model's context or the logs. - `docs/idea/agent-loop.md` — story → findings loop. - `docs/idea/mcp-tools.md` — two tool layers, SQL-like params, in-repo system prompts. - `docs/idea/models.md` — the three actors (smart agent / fast guy / vision guy), `look`, why CDP. -- `docs/idea/config.md` — `.mcp.json` + `.ui-debugger-mcp.json`. +- `docs/idea/config.md` — `.mcp.json` + the project config (`.dz/…` or root). - `docs/idea/workspace.md` — per-project space + logs. ## Note diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 02fb3fe..d0ac0f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,7 +91,7 @@ src/ prompts/ — system prompts (versioned in-repo, provider-agnostic) adapters/ — target control (browser/desktop/android) + shared contract session/ — cwd-keyed session state - config/ — load + validate .ui-debugger-mcp.json (Zod) + config/ — load + validate the project config (Zod; .dz/ first, root fallback) services/ — business logic; handlers stay thin docs/ idea/ — design docs (read before changing architecture) diff --git a/README.md b/README.md index 3261f39..5b9c755 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,10 @@ It's also published in the official [MCP Registry](https://modelcontextprotocol. `io.github.developerz-ai/ui-debugger-mcp` — any client that browses the registry (instead of a hand-written `.mcp.json` entry) can find and install it by that name. -Then add a per-project `.ui-debugger-mcp.json` describing the app to debug -(models, targets, urls). The fastest way is the `init` command: +Then add a per-project config describing the app to debug (models, targets, +urls) — `.dz/ui-debugger/ui-debugger-mcp.json` if your repo keeps agent config +under `.dz/` (checked first), else the root `.ui-debugger-mcp.json` (legacy). +The fastest way is the `init` command: ```bash npx @developerz.ai/ui-debugger-mcp@latest init # in your project root @@ -137,15 +139,18 @@ npx @developerz.ai/ui-debugger-mcp@latest init # in your project root [`docs/idea/config.md`](docs/idea/config.md)): - creates the workspace dir `./tmp/ui-debugger-mcp/` -- writes a starter `.ui-debugger-mcp.json` (default deepseek/glm models, a `web` - target stub) if one doesn't already exist +- writes a starter config (default deepseek/glm models, a `web` target stub) if + one doesn't already exist — at `.dz/ui-debugger/ui-debugger-mcp.json` when the + repo already has a `.dz/` dir, else at the root `.ui-debugger-mcp.json` - adds `tmp/` to `.gitignore` - prints the `.mcp.json` snippet to paste (it never writes your API key) Config files: - `.mcp.json` → **how to launch** the server (command + secret key). Gitignored. -- `.ui-debugger-mcp.json` → **how to debug this app** (models, targets). Committed. +- `.dz/ui-debugger/ui-debugger-mcp.json` (or root `.ui-debugger-mcp.json`) → + **how to debug this app** (models, targets). Committed. When both exist the + `.dz/` copy wins and the tool prints a notice naming the ignored root file. Every key of both files is documented in [`docs/idea/config.md`](docs/idea/config.md); every tool's exact input/output shape is in [`docs/idea/mcp-tools.md`](docs/idea/mcp-tools.md). @@ -160,7 +165,7 @@ in your repo and it debugs that repo. npx @developerz.ai/ui-debugger-mcp@latest init ``` -This creates `./tmp/ui-debugger-mcp/`, writes a starter `.ui-debugger-mcp.json`, +This creates `./tmp/ui-debugger-mcp/`, writes a starter config (`.dz/…` or root), and prints the `.mcp.json` snippet to paste. ```jsonc @@ -180,7 +185,8 @@ and prints the `.mcp.json` snippet to paste. ``` ```jsonc -// 3. Edit .ui-debugger-mcp.json — set your app's URL +// 3. Edit the project config (.dz/ui-debugger/ui-debugger-mcp.json or root +// .ui-debugger-mcp.json) — set your app's URL { "targets": { "web": { "adapter": "browser", "url": "http://localhost:3000" } @@ -330,7 +336,7 @@ ui-debugger-mcp stop # gracefully end the run (frees the browser + profile) **Chrome not found** The web adapter launches Chrome via the system PATH. Install Chrome/Chromium, or -set `executablePath` in `.ui-debugger-mcp.json`: +set `executablePath` in the project config (`.dz/…` or root): ```jsonc "web": { "adapter": "browser", "url": "...", "executablePath": "/usr/bin/chromium-browser" } ``` @@ -362,7 +368,7 @@ sorts last. Only the **5 most recent** sessions are kept — each new run prunes rest, so evidence from a run six ago is gone by design. **Run fails instantly: "… is not a valid model ID"** -The model string in `.ui-debugger-mcp.json` is not a catalog id. OpenRouter takes +The model string in the project config is not a catalog id. OpenRouter takes `provider/model` with optional `:floor` / `:nitro` routing suffixes — a `#…` suffix is rejected outright. Use plain ids (`deepseek/deepseek-v4-flash`). diff --git a/docs/idea/architecture.md b/docs/idea/architecture.md index 8175be4..795f015 100644 --- a/docs/idea/architecture.md +++ b/docs/idea/architecture.md @@ -33,7 +33,7 @@ | Debug agent | Small model in a Vercel AI SDK loop. Owns the clicking. | | Adapters | Drive a target behind one shared contract. | | Workspace | Per-project scratch: profile, screenshots, logs, findings. | -| Config | `.ui-debugger-mcp.json` (project) + `.mcp.json` (launch). | +| Config | `.dz/ui-debugger/ui-debugger-mcp.json` or root `.ui-debugger-mcp.json` (project) + `.mcp.json` (launch). | ## Why the brain is inside the server diff --git a/docs/idea/config.md b/docs/idea/config.md index d52930a..9c32d9c 100644 --- a/docs/idea/config.md +++ b/docs/idea/config.md @@ -40,18 +40,31 @@ npx @developerz.ai/ui-debugger-mcp@latest init # run in the project root `init` (idempotent — won't clobber existing files): 1. creates the workspace dir `./tmp/ui-debugger-mcp/` -2. writes a starter `.ui-debugger-mcp.json` — deepseek/glm model defaults + a - `web` target stub (`http://localhost:3000`) — only if absent +2. writes a starter config — deepseek/glm model defaults + a `web` target stub + (`http://localhost:3000`) — only if absent, at `.dz/ui-debugger/ui-debugger-mcp.json` + when the repo already has a `.dz/` dir, else at the root `.ui-debugger-mcp.json` 3. adds `tmp/` to `.gitignore` 4. prints the `.mcp.json` snippet to paste (never writes your API key) Then edit targets/urls to match the app. The dir + config are all the server needs to start a session for that project. -## `.ui-debugger-mcp.json` — how to debug this app (committed) +## Project config — how to debug this app (committed) Per-project. Lives in the repo, travels with it. Describes the app + targets. +Two candidate locations, one shape (see the `Resolution order` below): + +1. **`.dz/ui-debugger/ui-debugger-mcp.json`** — for repos that consolidate their + agent config under `.dz/`. Checked FIRST; wins when both exist. +2. **`.ui-debugger-mcp.json`** (repo root) — the original location, now the + legacy fallback. + +When both exist the `.dz/` copy wins and the tool prints one line naming the +ignored root file — so root edits that silently do nothing are never a mystery. +A `.dz/` copy that fails to parse is an error, exactly as a bad root copy is; +the root file is NOT read in that case. + ```jsonc { "models": { // per-role, swappable — see models.md @@ -118,7 +131,7 @@ once instead of finding out the expensive way. | | `notes` (this) | `goal` (`start_debug`) | |---|---|---| | Scope | the target — **every** run against this app | one run | -| Lives in | `.ui-debugger-mcp.json`, committed | the call | +| Lives in | the project config (`.dz/…` or root), committed | the call | | Says | what is always true | what to do this time | One fact per line. They become a `## Known about this app` section of the composed @@ -219,7 +232,10 @@ Rules: ## Resolution order 1. message from the smart agent (overrides per session) -2. `.ui-debugger-mcp.json` (project) +2. project file — `.dz/ui-debugger/ui-debugger-mcp.json` first, falling back to + the root `.ui-debugger-mcp.json` (legacy). Both present → `.dz/` wins, with a + one-line notice naming the ignored root file; a bad `.dz/` copy errors without + reading root. 3. env (`OPENAI_API_KEY`, `OPENAI_BASE_URL`) 4. built-in defaults — managed + headless web, OpenRouter base url, and: - `driver` → `deepseek/deepseek-v4-flash` (text) @@ -231,8 +247,8 @@ All Zod-validated. Bad config fails fast and loud. ## Why split - Secrets (`.mcp.json`) stay out of the repo. -- Debug settings (`.ui-debugger-mcp.json`) stay in the repo, so every dev / agent - that opens the project debugs it the same way. +- Debug settings (the project config, either location) stay in the repo, so + every dev / agent that opens the project debugs it the same way. - Matches the gold-standards rule: write project knowledge down, per project. ## Providers — OpenAI-compatible routers @@ -257,5 +273,5 @@ We talk to **any OpenAI-compatible endpoint**: one `OPENAI_BASE_URL` + | `vision` | `qwen/qwen3-vl-32b-instruct` | multimodal — describes screenshots, judges looks | | `summary` | `deepseek/deepseek-v4-flash` | compress findings for the smart agent | -Override any role in `.ui-debugger-mcp.json`. Cheap fast model drives; the +Override any role in the project config. Cheap fast model drives; the vision model is spent only when eyes are needed. No code change to swap. diff --git a/docs/idea/models.md b/docs/idea/models.md index 9e0c30c..a03a252 100644 --- a/docs/idea/models.md +++ b/docs/idea/models.md @@ -63,7 +63,8 @@ one does the moving and asks — *"is the cup to my left?"* — and acts on the ## Config — model roles (defaults: deepseek text, glm image) -`.ui-debugger-mcp.json`: +The project config (`.dz/ui-debugger/ui-debugger-mcp.json`, or root +`.ui-debugger-mcp.json`): ```jsonc "models": { "driver": "deepseek/deepseek-v4-flash", // fast guy — text, controls diff --git a/docs/reference.md b/docs/reference.md index f091063..c263974 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -64,7 +64,7 @@ Open a debug session: hand the driver agent a goal for a configured target. One | Param | Type | Required | Description | |-------|------|----------|-------------| -| `target` | `string` | yes | Target name from `.ui-debugger-mcp.json` (e.g. `"web"`). Use `describe` to list valid values. | +| `target` | `string` | yes | Target name from the project config (`.dz/…` or root `.ui-debugger-mcp.json`) (e.g. `"web"`). Use `describe` to list valid values. | | `goal` | `string` | yes | The story — what to accomplish in plain language (e.g. `"log in and add item 3 to cart"`). | | `criteria` | `string` | no | Explicit pass/fail rules, one per line. Omit to let the agent judge. | | `url` | `string` (URL) | no | Where to point the driver for this run (web targets) — e.g. a local dev server, a preview, or production. Overrides the target's configured url; required when the target has none. | @@ -87,7 +87,8 @@ another live server owns. start_debug { target: "web", goal: "re-check the audit table", replace: true } ``` -Personas are configured per web target in `.ui-debugger-mcp.json` — see +Personas are configured per web target in the project config (`.dz/…` or root +`.ui-debugger-mcp.json`) — see [`idea/config.md`](idea/config.md#auth--named-login-personas-web) for the shape, the field-matching order, and the redaction guarantees. diff --git a/src/adapters/browser/launch.ts b/src/adapters/browser/launch.ts index ebfbe58..c4b96a6 100644 --- a/src/adapters/browser/launch.ts +++ b/src/adapters/browser/launch.ts @@ -89,7 +89,7 @@ export function appendDebugLogin(target: string, debugLogin?: { param: string }) url = new URL(target); } catch { throw new AdapterError( - `cannot append debug-login param to relative target ${JSON.stringify(target)} — set the web target's \`url\` in .ui-debugger-mcp.json so it resolves to an absolute URL`, + `cannot append debug-login param to relative target ${JSON.stringify(target)} — set the web target's \`url\` in the project config (.dz/ui-debugger/ui-debugger-mcp.json or root .ui-debugger-mcp.json) so it resolves to an absolute URL`, ); } url.searchParams.set(debugLogin.param, 'true'); diff --git a/src/adapters/factory.ts b/src/adapters/factory.ts index 8d99f48..df0459a 100644 --- a/src/adapters/factory.ts +++ b/src/adapters/factory.ts @@ -18,7 +18,7 @@ import { DesktopAdapter } from './desktop/desktop-adapter.js'; * Create an adapter for a named target from the resolved config. * * @param targetName — the key in config.targets (e.g., "web") - * @param config — the resolved `.ui-debugger-mcp.json` config + * @param config — the resolved project config * @param profileDir — absolute path to persistent profile dir (for managed browser adapter) * @param onLog — optional sink for streaming console/network logs to findings store * @param timeoutMs — the run's remaining wall-clock budget for getting the target up; diff --git a/src/agent/belt/look.test.ts b/src/agent/belt/look.test.ts index c6e5947..282d925 100644 --- a/src/agent/belt/look.test.ts +++ b/src/agent/belt/look.test.ts @@ -268,7 +268,10 @@ test('visionUnavailableMessage names the model and tells the driver to stop call ); expect(msg).toContain("'glm-5.2'"); expect(msg).toContain('Do NOT call look again'); - expect(msg).toContain('.ui-debugger-mcp.json'); + // Names WHERE the vision model is set — "the project config" since the + // `.dz/`-first dual read landed (the root filename alone is no longer the + // whole truth). + expect(msg).toContain('project config'); }); test('createLookExecute latches VisionUnavailableError: later calls fail fast, no screenshot', async () => { diff --git a/src/agent/belt/look.ts b/src/agent/belt/look.ts index 1261026..9790577 100644 --- a/src/agent/belt/look.ts +++ b/src/agent/belt/look.ts @@ -179,7 +179,7 @@ export function visionUnavailableMessage(modelId: string, providerMessage: strin `look is unavailable for this run: vision model '${modelId}' rejected image input ` + `(provider said: ${JSON.stringify(truncate(providerMessage, 140))}). The configured ` + 'models.vision appears to be text-only — set it to a multimodal model in ' + - '.ui-debugger-mcp.json. Do NOT call look again this run; verify what you can from ' + + 'the project config. Do NOT call look again this run; verify what you can from ' + 'observe (tree/console/network) and state in your report summary that visual checks ' + 'could not be performed.' ); diff --git a/src/cli/help.ts b/src/cli/help.ts index 0cbed77..8a10a1f 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -12,7 +12,7 @@ USAGE: ${NAME} [subcommand] [options] SUBCOMMANDS: - init Scaffold a new project (.ui-debugger-mcp.json, workspace) + init Scaffold a new project (config in .dz/, or root legacy; workspace) status Print the active debug run's state + findings summary stop Signal the server to tear down the active run gracefully (no subcommand) Boot the stdio MCP server (default, for Claude use) diff --git a/src/cli/init.test.ts b/src/cli/init.test.ts index 09caf61..e50013b 100644 --- a/src/cli/init.test.ts +++ b/src/cli/init.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, expect, test } from 'bun:test'; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { ConfigSchema } from '../config/schema.js'; import { runInit } from './init.js'; @@ -121,3 +121,78 @@ test('the printed .mcp.json snippet pins @latest — a bare spec sticks on a cac expect(printed).toContain('"@developerz.ai/ui-debugger-mcp@latest"'); expect(printed).not.toContain('"@developerz.ai/ui-debugger-mcp"'); }); + +// --- dual-candidate config: `.dz/ui-debugger/ui-debugger-mcp.json` first, root +// `.ui-debugger-mcp.json` as the legacy fallback. + +const DZ_CONFIG = '.dz/ui-debugger/ui-debugger-mcp.json'; + +/** Write `contents` at a candidate-relative path under TMP, creating parents. */ +function writeCandidate(rel: string, contents: string): void { + const path = join(TMP, rel); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents, 'utf8'); +} + +/** Run init with console.log captured; returns the printed lines. */ +function captureInit(): string[] { + const lines: string[] = []; + const original = console.log; + console.log = (...args: unknown[]) => void lines.push(args.join(' ')); + try { + runInit(TMP); + } finally { + console.log = original; + } + return lines; +} + +test('writes the starter config under .dz/ when the repo already has a .dz/ dir', () => { + mkdirSync(join(TMP, '.dz'), { recursive: true }); + runInit(TMP); + expect(existsSync(join(TMP, DZ_CONFIG))).toBe(true); + // The legacy root file is not created alongside the .dz/ one. + expect(existsSync(join(TMP, '.ui-debugger-mcp.json'))).toBe(false); +}); + +test('writes the starter config at the root when no .dz/ dir exists', () => { + runInit(TMP); + expect(existsSync(join(TMP, '.ui-debugger-mcp.json'))).toBe(true); + expect(existsSync(join(TMP, '.dz'))).toBe(false); +}); + +test('the workspace read prefers the .dz/ copy when both exist', () => { + writeCandidate(DZ_CONFIG, JSON.stringify({ workspace: './ws-dz', targets: {} })); + writeFileSync( + join(TMP, '.ui-debugger-mcp.json'), + JSON.stringify({ workspace: './ws-root', targets: {} }), + 'utf8', + ); + runInit(TMP); + expect(existsSync(join(TMP, 'ws-dz'))).toBe(true); + expect(existsSync(join(TMP, 'ws-root'))).toBe(false); +}); + +test('both present: .dz/ wins, is never overwritten, and exactly one notice names the ignored root file', () => { + const dzContents = '{"workspace":"./ws-dz","targets":{}}\n'; + writeCandidate(DZ_CONFIG, dzContents); + writeFileSync( + join(TMP, '.ui-debugger-mcp.json'), + '{"workspace":"./ws-root","targets":{}}', + 'utf8', + ); + + const lines = captureInit(); + + expect(readFileSync(join(TMP, DZ_CONFIG), 'utf8')).toBe(dzContents); + const notices = lines.filter((l) => l.includes('.ui-debugger-mcp.json') && l.includes('ignor')); + expect(notices.length).toBe(1); + expect(notices[0]).toContain(DZ_CONFIG); +}); + +test('only-if-absent honoured for the found candidate: an existing .dz/ copy is never overwritten', () => { + const original = '{"custom":true}\n'; + writeCandidate(DZ_CONFIG, original); + runInit(TMP); + expect(readFileSync(join(TMP, DZ_CONFIG), 'utf8')).toBe(original); +}); diff --git a/src/cli/init.ts b/src/cli/init.ts index b1fd5fb..84feeec 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -6,14 +6,21 @@ * Steps: * 1. mkdir the workspace dir (an existing config's `workspace` wins over the * `./tmp/ui-debugger-mcp` default — see {@link existingWorkspace}) - * 2. write .ui-debugger-mcp.json (only if absent) + * 2. write the project config (only if absent) at the location + * {@link resolveConfigPath} picks: `.dz/ui-debugger/ui-debugger-mcp.json` + * when the repo has a `.dz/` dir, else the root `.ui-debugger-mcp.json` * 3. add the workspace dir to .gitignore (only if the line is missing) * 4. print .mcp.json snippet (never writes the API key) */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { join, relative, resolve, sep } from 'node:path'; -import { CONFIG_FILENAME, DEFAULT_MODELS, DEFAULT_WORKSPACE } from '../config/load.js'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { + DEFAULT_MODELS, + DEFAULT_WORKSPACE, + ignoredRootConfig, + resolveConfigPath, +} from '../config/load.js'; import { InitError } from '../errors.js'; /** Starter project config written on `init` (only if absent). */ @@ -63,15 +70,16 @@ const MCP_JSON_SNIPPET = `{ const DEFAULT_IGNORE_LINE = 'tmp/'; /** - * Read the `workspace` field of an already-present `.ui-debugger-mcp.json`, if any. - * Deliberately lenient — not a full `ConfigSchema` parse — because `init` only needs - * to know where to mkdir/gitignore, not validate the whole file; a config with an + * Read the `workspace` field of an already-present project config (`.dz/` copy + * first, root fallback — the loader's candidate order), if any. Deliberately + * lenient — not a full `ConfigSchema` parse — because `init` only needs to know + * where to mkdir/gitignore, not validate the whole file; a config with an * unrelated schema error must not block re-running `init`. Falls back to - * {@link DEFAULT_WORKSPACE} when the file is absent, unparseable, or has no string - * `workspace` field. + * {@link DEFAULT_WORKSPACE} when the file is absent, unparseable, or has no + * string `workspace` field. */ function existingWorkspace(cwd: string): string { - const configPath = join(cwd, CONFIG_FILENAME); + const configPath = resolveConfigPath(cwd); if (!existsSync(configPath)) return DEFAULT_WORKSPACE; let raw: string; @@ -79,7 +87,7 @@ function existingWorkspace(cwd: string): string { raw = readFileSync(configPath, 'utf8'); } catch (e) { throw new InitError( - `Failed to read ${CONFIG_FILENAME}: ${e instanceof Error ? e.message : String(e)}`, + `Failed to read ${relative(cwd, configPath)}: ${e instanceof Error ? e.message : String(e)}`, ); } @@ -128,19 +136,29 @@ export function runInit(cwd: string = process.cwd()): void { } console.log(`✓ workspace ${workspaceDir}`); - // 2. write .ui-debugger-mcp.json (only if absent) - const configPath = join(cwd, '.ui-debugger-mcp.json'); + // 2. write the project config (only if absent) — resolveConfigPath owns the + // location: an existing `.dz/` or root copy is found, else `.dz/` when the repo + // already has a `.dz/` dir, else the root file. + const configPath = resolveConfigPath(cwd); + const configName = relative(cwd, configPath); if (existsSync(configPath)) { - console.log(` (skip) .ui-debugger-mcp.json already exists`); + console.log(` (skip) ${configName} already exists`); + const ignoredRoot = ignoredRootConfig(cwd); + if (ignoredRoot) { + console.log(` (notice) ${ignoredRoot} also exists — ignored, ${configName} wins`); + } } else { try { + // The `.dz/` candidate needs its parent dir; for the root candidate this + // is a no-op on the (existing) project root. + mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, `${STARTER_CONFIG}\n`, 'utf8'); } catch (e) { throw new InitError( - `Failed to write .ui-debugger-mcp.json: ${e instanceof Error ? e.message : String(e)}`, + `Failed to write ${configName}: ${e instanceof Error ? e.message : String(e)}`, ); } - console.log(`✓ created .ui-debugger-mcp.json`); + console.log(`✓ created ${configName}`); } // 3. add the workspace dir to .gitignore (only if the line is missing) diff --git a/src/config/fingerprint.test.ts b/src/config/fingerprint.test.ts index 4deca0a..5925941 100644 --- a/src/config/fingerprint.test.ts +++ b/src/config/fingerprint.test.ts @@ -1,9 +1,9 @@ import { afterEach, expect, test } from 'bun:test'; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { configFingerprint, makeConfigWatch } from './fingerprint.js'; -import { CONFIG_FILENAME } from './load.js'; +import { CONFIG_CANDIDATES, CONFIG_FILENAME } from './load.js'; const dirs: string[] = []; @@ -14,6 +14,13 @@ function project(contents?: string): string { return dir; } +/** Write `contents` at a candidate-relative path, creating parent dirs. */ +function writeCandidate(dir: string, candidate: string, contents: string): void { + const path = join(dir, candidate); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, contents); +} + afterEach(() => { for (const dir of dirs.splice(0)) { chmodSync(dir, 0o700); @@ -58,3 +65,18 @@ test('a config created after boot counts as drift', () => { writeFileSync(join(dir, CONFIG_FILENAME), '{"targets":{}}'); expect(changed()).toBe(true); }); + +// Dual candidates: the fingerprint follows the resolved `.dz/`-first order and +// keys on CONTENT, so editing a shadowed root copy is not drift. +test('fingerprints the .dz/ copy when both exist; editing the shadowed root file is not drift', () => { + const dir = project('{"targets":{"root":true}}'); + writeCandidate(dir, CONFIG_CANDIDATES[0], '{"targets":{"dz":true}}'); + const changed = makeConfigWatch(dir); + expect(changed()).toBe(false); + + writeFileSync(join(dir, CONFIG_FILENAME), '{"targets":{"root-edited":true}}'); + expect(changed()).toBe(false); // root is ignored while the .dz/ copy exists + + writeCandidate(dir, CONFIG_CANDIDATES[0], '{"targets":{"dz-edited":true}}'); + expect(changed()).toBe(true); +}); diff --git a/src/config/fingerprint.ts b/src/config/fingerprint.ts index 81602f3..427e664 100644 --- a/src/config/fingerprint.ts +++ b/src/config/fingerprint.ts @@ -1,5 +1,5 @@ /** - * Detect that `.ui-debugger-mcp.json` changed after the server read it. + * Detect that the project config changed after the server read it. * * Config is resolved ONCE at boot (`main.ts`): it decides the workspace layout, * the provider, the per-role models and the self-look probe, all of which are @@ -19,13 +19,17 @@ import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { CONFIG_FILENAME } from './load.js'; +import { resolveConfigPath } from './load.js'; /** * Content fingerprint of the project's config file — `null` when it is absent or * unreadable (a project running on defaults has nothing to drift from). * + * Follows the boot loader's candidate order (`resolveConfigPath`: `.dz/` first, + * root fallback) and keys on CONTENT, not path: identical bytes at either + * location are the same config, so relocating the file with no changes is not + * drift, while editing a shadowed root copy is invisible — by design. + * * Contents, not mtime: editors and formatters rewrite files without changing * anything that matters, and a spurious "restart me" is its own kind of noise. * @@ -38,7 +42,7 @@ import { CONFIG_FILENAME } from './load.js'; export function configFingerprint(cwd: string): string | null { let raw: string; try { - raw = readFileSync(join(cwd, CONFIG_FILENAME), 'utf8'); + raw = readFileSync(resolveConfigPath(cwd), 'utf8'); } catch (err) { // Absent config = nothing to drift from. Anything else (permissions, I/O) is a // bad assumption, and the house rule is to crash on those rather than run on. diff --git a/src/config/load.test.ts b/src/config/load.test.ts index 35f9f5c..4894838 100644 --- a/src/config/load.test.ts +++ b/src/config/load.test.ts @@ -1,15 +1,18 @@ import { expect, test } from 'bun:test'; -import { mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { ConfigError } from '../errors.js'; import { + CONFIG_CANDIDATES, CONFIG_FILENAME, DEFAULT_MODELS, DEFAULT_WORKSPACE, + ignoredRootConfig, loadConfig, loadWorkspaceDir, OPENROUTER_BASE_URL, + resolveConfigPath, } from './load.js'; const minimal = { @@ -26,6 +29,21 @@ function tmpProject(config?: unknown): string { return dir; } +/** The `.dz/` consolidation candidate — first in resolution order. */ +const DZ_CANDIDATE = CONFIG_CANDIDATES[0]; + +/** + * Write `config` (object → JSON, or raw string) at a candidate-relative path + * inside `dir`, creating parent dirs (`/.dz/ui-debugger/`) as needed. + */ +function writeCandidate(dir: string, candidate: string, config: unknown): string { + const path = join(dir, candidate); + mkdirSync(dirname(path), { recursive: true }); + const body = typeof config === 'string' ? config : JSON.stringify(config); + writeFileSync(path, body); + return dir; +} + const key = { OPENAI_API_KEY: 'sk-test' }; test('fills model and workspace defaults when the project omits them', () => { @@ -96,3 +114,66 @@ test('loadWorkspaceDir surfaces ConfigError on an invalid config (no silent defa const bad = { targets: { web: { adapter: 'browser', url: 'not-a-url', headless: true } } }; expect(() => loadWorkspaceDir(tmpProject(bad))).toThrow(ConfigError); }); + +// --- dual-candidate resolution: `.dz/ui-debugger/ui-debugger-mcp.json` first, +// root `.ui-debugger-mcp.json` as the legacy fallback. + +test('resolveConfigPath prefers an existing .dz/ copy over the root file', () => { + let dir = tmpProject(); + writeCandidate(dir, DZ_CANDIDATE, minimal); + expect(resolveConfigPath(dir)).toBe(join(dir, DZ_CANDIDATE)); + + dir = tmpProject(minimal); // only the root copy exists + expect(resolveConfigPath(dir)).toBe(join(dir, CONFIG_FILENAME)); +}); + +test('resolveConfigPath targets .dz/ for a fresh config when a .dz/ dir exists', () => { + // No config anywhere yet: the write target follows the repo's `.dz/` convention. + let dir = tmpProject(); + mkdirSync(join(dir, '.dz'), { recursive: true }); + expect(resolveConfigPath(dir)).toBe(join(dir, DZ_CANDIDATE)); + + dir = tmpProject(); // no `.dz/` dir → the historical root filename + expect(resolveConfigPath(dir)).toBe(join(dir, CONFIG_FILENAME)); +}); + +test('loadConfig reads the .dz/ copy first — root values are ignored when both exist', () => { + const dir = writeCandidate(tmpProject({ ...minimal, workspace: './ws-root' }), DZ_CANDIDATE, { + ...minimal, + workspace: './ws-dz', + }); + const cfg = loadConfig({ cwd: dir, env: key }); + expect(cfg.workspace).toBe('./ws-dz'); +}); + +test('a bad .dz/ copy errors without falling back to a valid root file', () => { + const dir = writeCandidate(tmpProject(minimal), DZ_CANDIDATE, '{ not json'); + let caught: unknown; + try { + loadConfig({ cwd: dir, env: key }); + } catch (e) { + caught = e; + } + // No catch = loadConfig succeeded = root was read. The error must name .dz/. + expect(caught).toBeInstanceOf(ConfigError); + expect((caught as ConfigError).message).toContain(DZ_CANDIDATE); +}); + +test('loadWorkspaceDir prefers the .dz/ copy and errors on a bad one (no silent root read)', () => { + const dir = writeCandidate(tmpProject({ ...minimal, workspace: './ws-root' }), DZ_CANDIDATE, { + ...minimal, + workspace: './ws-dz', + }); + expect(loadWorkspaceDir(dir)).toBe('./ws-dz'); + + const bad = writeCandidate(tmpProject(minimal), DZ_CANDIDATE, '{ not json'); + expect(() => loadWorkspaceDir(bad)).toThrow(ConfigError); +}); + +test('ignoredRootConfig names the shadowed root file only when both candidates exist', () => { + expect(ignoredRootConfig(writeCandidate(tmpProject(minimal), DZ_CANDIDATE, minimal))).toBe( + CONFIG_FILENAME, + ); + expect(ignoredRootConfig(tmpProject(minimal))).toBeNull(); + expect(ignoredRootConfig(writeCandidate(tmpProject(), DZ_CANDIDATE, minimal))).toBeNull(); +}); diff --git a/src/config/load.ts b/src/config/load.ts index 5757a93..2ada216 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -1,8 +1,10 @@ /** * Config loader + resolution order. * - * Reads `./.ui-debugger-mcp.json` (cwd), Zod-validates it, and resolves a fully - * defaulted `ResolvedConfig`. Layering follows `idea/config.md`: + * Resolves the project config (`.dz/ui-debugger/ui-debugger-mcp.json` first, + * falling back to the root `.ui-debugger-mcp.json` — see {@link CONFIG_CANDIDATES}), + * Zod-validates it, and resolves a fully defaulted `ResolvedConfig`. + * Layering follows `idea/config.md`: * * built-in defaults < project file < env * @@ -13,17 +15,26 @@ * runtime concern, not the file loader's — out of scope here. * * Bad config fails fast and loud via `ConfigError` — never a silent fallback. + * That includes a bad `.dz/` copy: it errors, never falls through to root. */ import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, relative } from 'node:path'; import { ConfigError } from '../errors.js'; import type { Target } from './schema.js'; import { ConfigSchema } from './schema.js'; -/** Committed, per-project debug config filename, resolved against the cwd. */ +/** Committed, per-project debug config filename (the legacy root location). */ export const CONFIG_FILENAME = '.ui-debugger-mcp.json'; +/** + * Where the per-repo config may live, in resolution order: the `.dz/` + * consolidation location first, the repo-root file as the legacy fallback. + * Every config read/write resolves through {@link resolveConfigPath}, so this + * array is the ONE place the order lives. + */ +export const CONFIG_CANDIDATES = ['.dz/ui-debugger/ui-debugger-mcp.json', CONFIG_FILENAME] as const; + /** Default base url — OpenRouter. Override with env `OPENAI_BASE_URL`. */ export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; @@ -71,6 +82,35 @@ export interface LoadOptions { env?: Record; } +/** + * The config path this project actually uses, resolved against `cwd`: + * + * - the first candidate FILE that exists — `.dz/` wins when both do + * - when neither exists, the path a fresh `init` should WRITE: the `.dz/` + * location if the repo already has a `.dz/` dir, else the root file. + */ +export function resolveConfigPath(cwd: string): string { + for (const candidate of CONFIG_CANDIDATES) { + const path = join(cwd, candidate); + if (existsSync(path)) return path; + } + return join(cwd, existsSync(join(cwd, '.dz')) ? CONFIG_CANDIDATES[0] : CONFIG_FILENAME); +} + +/** + * The shadowed root filename when BOTH candidates exist — what callers print as + * the one-line "you have two configs" notice (`.dz/` wins). `null` otherwise. + */ +export function ignoredRootConfig(cwd: string): string | null { + if (!existsSync(join(cwd, CONFIG_CANDIDATES[0]))) return null; + return existsSync(join(cwd, CONFIG_FILENAME)) ? CONFIG_FILENAME : null; +} + +/** The candidate as the user writes it (relative to `cwd`) — for messages. */ +function configName(cwd: string, path: string): string { + return relative(cwd, path) || CONFIG_FILENAME; +} + /** * Load, validate, and resolve the project config. Throws `ConfigError` if the * file is missing, not JSON, fails the schema, or `OPENAI_API_KEY` is unset. @@ -78,11 +118,12 @@ export interface LoadOptions { export function loadConfig(opts: LoadOptions = {}): ResolvedConfig { const cwd = opts.cwd ?? process.cwd(); const env = opts.env ?? process.env; - const path = join(cwd, CONFIG_FILENAME); + const path = resolveConfigPath(cwd); if (!existsSync(path)) { throw new ConfigError( - `\`${CONFIG_FILENAME}\` not found in ${cwd}. Run \`ui-debugger-mcp init\` to scaffold it.`, + `No project config in ${cwd} — tried \`${CONFIG_CANDIDATES[0]}\` then \`${CONFIG_FILENAME}\`. ` + + 'Run `ui-debugger-mcp init` to scaffold it.', ); } @@ -91,11 +132,11 @@ export function loadConfig(opts: LoadOptions = {}): ResolvedConfig { raw = readFileSync(path, 'utf8'); } catch (e) { throw new ConfigError( - `Failed to read \`${CONFIG_FILENAME}\`: ${e instanceof Error ? e.message : String(e)}`, + `Failed to read \`${configName(cwd, path)}\`: ${e instanceof Error ? e.message : String(e)}`, ); } - const project = parseProject(raw); + const project = parseProject(raw, configName(cwd, path)); return { models: { @@ -119,7 +160,7 @@ export function loadConfig(opts: LoadOptions = {}): ResolvedConfig { * `stop` at the default workspace and report "no run" for a custom-workspace run. */ export function loadWorkspaceDir(cwd: string = process.cwd()): string { - const path = join(cwd, CONFIG_FILENAME); + const path = resolveConfigPath(cwd); if (!existsSync(path)) return DEFAULT_WORKSPACE; let raw: string; @@ -127,20 +168,20 @@ export function loadWorkspaceDir(cwd: string = process.cwd()): string { raw = readFileSync(path, 'utf8'); } catch (e) { throw new ConfigError( - `Failed to read \`${CONFIG_FILENAME}\`: ${e instanceof Error ? e.message : String(e)}`, + `Failed to read \`${configName(cwd, path)}\`: ${e instanceof Error ? e.message : String(e)}`, ); } - return parseProject(raw).workspace ?? DEFAULT_WORKSPACE; + return parseProject(raw, configName(cwd, path)).workspace ?? DEFAULT_WORKSPACE; } /** Parse JSON + Zod-validate the raw file contents into a typed config. */ -function parseProject(raw: string) { +function parseProject(raw: string, name: string) { let data: unknown; try { data = JSON.parse(raw); } catch (e) { throw new ConfigError( - `\`${CONFIG_FILENAME}\` is not valid JSON: ${e instanceof Error ? e.message : String(e)}`, + `\`${name}\` is not valid JSON: ${e instanceof Error ? e.message : String(e)}`, ); } @@ -149,7 +190,7 @@ function parseProject(raw: string) { const issues = result.error.issues .map((i) => `${i.path.map(String).join('.') || '(root)'}: ${i.message}`) .join('; '); - throw new ConfigError(`\`${CONFIG_FILENAME}\` is invalid: ${issues}`); + throw new ConfigError(`\`${name}\` is invalid: ${issues}`); } return result.data; } diff --git a/src/config/schema.ts b/src/config/schema.ts index ae005e5..a9c6ad9 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,5 +1,6 @@ /** - * Zod schema for `.ui-debugger-mcp.json` — the committed, per-project debug config. + * Zod schema for the committed, per-project debug config — same shape at either + * location: `.dz/ui-debugger/ui-debugger-mcp.json` or root `.ui-debugger-mcp.json`. * Mirrors `.ui-debugger-mcp.example.json`. Validated at the config boundary; bad * config fails fast and loud (see `ConfigError`). */ @@ -156,7 +157,7 @@ export const TargetSchema = z.discriminatedUnion('adapter', [ AndroidTargetSchema, ]); -/** Top-level `.ui-debugger-mcp.json` shape. Targets keyed by name (web, desktop, mobile, …). */ +/** Top-level project config shape. Targets keyed by name (web, desktop, mobile, …). */ export const ConfigSchema = z.strictObject({ models: ModelsSchema.partial().optional(), workspace: z.string().optional(), diff --git a/src/main.ts b/src/main.ts index a7a0aa9..653a1d5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,7 @@ import { runStatus, runStop } from './cli/control.js'; import { printUsage, runHelp, runVersion } from './cli/help.js'; import { runInit } from './cli/init.js'; import { makeConfigWatch } from './config/fingerprint.js'; -import { loadConfig } from './config/load.js'; +import { CONFIG_CANDIDATES, ignoredRootConfig, loadConfig } from './config/load.js'; import { NAME, VERSION } from './index.js'; import { startStdioServer } from './mcp/server.js'; import { outerTools } from './mcp/tools/index.js'; @@ -53,9 +53,17 @@ async function main(): Promise { } try { - // Load project config (cwd-keyed) - const config = loadConfig(); const cwd = process.cwd(); + // Load project config (cwd-keyed) + const config = loadConfig({ cwd }); + // Both candidates on disk: name the ignored root copy ONCE, on stderr — + // stdout is the stdio MCP JSON-RPC channel and must stay machine-only. + const ignoredRoot = ignoredRootConfig(cwd); + if (ignoredRoot) { + console.error( + `${NAME}: using ${CONFIG_CANDIDATES[0]} — ignoring the root ${ignoredRoot} (remove it to stop this notice)`, + ); + } // Bootstrap workspace directories (chrome-user-data/, sessions/). // A relative workspace anchors at the project root; an absolute (shared) one diff --git a/src/mcp/tools/output.ts b/src/mcp/tools/output.ts index d24d1d3..03fa9b4 100644 --- a/src/mcp/tools/output.ts +++ b/src/mcp/tools/output.ts @@ -49,7 +49,7 @@ const TargetInfoSchema = z.object({ .optional() .describe( 'Named auth personas — the valid start_debug({as}) values for this target. Names only; ' + - 'the credentials stay in .ui-debugger-mcp.json and never reach the driver.', + 'the credentials stay in the project config and never reach the driver.', ), notes: z .string() diff --git a/src/mcp/tools/start-debug.ts b/src/mcp/tools/start-debug.ts index f9508af..6931b87 100644 --- a/src/mcp/tools/start-debug.ts +++ b/src/mcp/tools/start-debug.ts @@ -40,7 +40,7 @@ export function startDebugTool(service: DebugApi): McpTool { .string() .min(1) .describe( - 'Configured target name (a key in .ui-debugger-mcp.json targets, e.g. "web"). See describe.', + 'Configured target name (a key in the project config targets, e.g. "web"). See describe.', ), goal: z .string() diff --git a/src/services/debug-service.ts b/src/services/debug-service.ts index 8b7f2aa..ad338eb 100644 --- a/src/services/debug-service.ts +++ b/src/services/debug-service.ts @@ -26,7 +26,7 @@ * already closed) and never participates in the one-run gate. */ -import { CONFIG_FILENAME, type ResolvedConfig } from '../config/load.js'; +import { CONFIG_CANDIDATES, CONFIG_FILENAME, type ResolvedConfig } from '../config/load.js'; import type { Target } from '../config/schema.js'; import { ConfigError, @@ -121,7 +121,7 @@ export interface TargetInfo { headless?: boolean; /** * Named auth personas configured for this target — the valid `start_debug({as})` - * values. NAMES ONLY; the credentials never leave `.ui-debugger-mcp.json`. + * values. NAMES ONLY; the credentials never leave the project config. * Absent when the target has no `auth` block. */ personas?: string[]; @@ -192,7 +192,7 @@ export interface DebugServiceDeps { /** Injected clock (epoch ms) for session ids + the run deadline; defaults to `Date.now`. */ now?: () => number; /** - * Has `.ui-debugger-mcp.json` changed since boot? Config is resolved once and + * Has the project config changed since boot? Config is resolved once and * wired into long-lived objects, so a changed file means every later run would * silently use the OLD settings — see `config/fingerprint.ts`. Defaults to * "never changed" (unit tests hold no file). @@ -434,7 +434,8 @@ export class DebugService implements DebugApi { #assertConfigFresh(): void { if (!this.#configChanged()) return; throw new ConfigError( - `${CONFIG_FILENAME} changed on disk after this ui-debugger-mcp server started, so a run now ` + + `The project config (\`${CONFIG_CANDIDATES[0]}\`, or root \`${CONFIG_FILENAME}\`) changed on disk after this ` + + 'ui-debugger-mcp server started, so a run now ' + 'would still use the OLD settings (models, targets, urls are read once at boot). ' + 'Restart the MCP server to pick the new config up — in Claude Code, /mcp → reconnect ' + 'ui-debugger, or restart the session.', diff --git a/src/services/login.ts b/src/services/login.ts index 39b98a4..d0405cd 100644 --- a/src/services/login.ts +++ b/src/services/login.ts @@ -77,7 +77,7 @@ export function resolveAuth( : `target '${targetName}' has no 'auth' block`; throw new ConfigError( `unknown auth persona '${as}' for target '${targetName}' — ${known}. ` + - `Add it under targets.${targetName}.auth in .ui-debugger-mcp.json, or omit 'as' to run signed out.`, + `Add it under targets.${targetName}.auth in the project config (.dz/ui-debugger/ui-debugger-mcp.json or root .ui-debugger-mcp.json), or omit 'as' to run signed out.`, ); } diff --git a/src/services/session-builder.ts b/src/services/session-builder.ts index abf73ec..cebe91d 100644 --- a/src/services/session-builder.ts +++ b/src/services/session-builder.ts @@ -183,7 +183,7 @@ export function resolveRunTarget(target: Target, name: string, url: string | und } if (target.adapter === 'browser' && !target.url) { throw new ConfigError( - `web target '${name}' has no URL — pass 'url' to start_debug or set it in .ui-debugger-mcp.json`, + `web target '${name}' has no URL — pass 'url' to start_debug or set it in the project config (.dz/ui-debugger/ui-debugger-mcp.json or root .ui-debugger-mcp.json)`, ); } return target; From f4cb7059582e6243a17ffdd2fda393507f2882e9 Mon Sep 17 00:00:00 2001 From: ivndev001 Date: Wed, 2 Sep 2026 15:00:15 +0000 Subject: [PATCH 2/2] chore(scope): revert the message-string sweep outside #64's stack list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #64 scoped the dual-read to src/config/load.ts (one place) plus its callers (init, fingerprint, main's one-line notice) and six named docs. The bare reword of every ".ui-debugger-mcp.json" mention across adapters, agent belt, mcp tool descriptions, services and two unnamed docs was a mechanical sweep that made the PR unreviewable — reverted 12 files: src/adapters/factory.ts, src/adapters/browser/launch.ts, src/agent/belt/look.ts + look.test.ts, src/mcp/tools/output.ts, src/mcp/tools/start-debug.ts, src/config/schema.ts ("schema untouched"), src/services/debug-service.ts, src/services/login.ts, src/services/session-builder.ts, docs/idea/models.md, docs/reference.md Untouched red-CI note: the session-builder auth-e2e timeout is a pre-existing flake (main run 31235917066 failed its sibling at 30009ms before this branch existed; 10/10 green on origin/main locally) — no test or timeout changes made here. Co-Authored-By: Claude Code --- docs/idea/models.md | 3 +-- docs/reference.md | 5 ++--- src/adapters/browser/launch.ts | 2 +- src/adapters/factory.ts | 2 +- src/agent/belt/look.test.ts | 5 +---- src/agent/belt/look.ts | 2 +- src/config/schema.ts | 5 ++--- src/mcp/tools/output.ts | 2 +- src/mcp/tools/start-debug.ts | 2 +- src/services/debug-service.ts | 9 ++++----- src/services/login.ts | 2 +- src/services/session-builder.ts | 2 +- 12 files changed, 17 insertions(+), 24 deletions(-) diff --git a/docs/idea/models.md b/docs/idea/models.md index a03a252..9e0c30c 100644 --- a/docs/idea/models.md +++ b/docs/idea/models.md @@ -63,8 +63,7 @@ one does the moving and asks — *"is the cup to my left?"* — and acts on the ## Config — model roles (defaults: deepseek text, glm image) -The project config (`.dz/ui-debugger/ui-debugger-mcp.json`, or root -`.ui-debugger-mcp.json`): +`.ui-debugger-mcp.json`: ```jsonc "models": { "driver": "deepseek/deepseek-v4-flash", // fast guy — text, controls diff --git a/docs/reference.md b/docs/reference.md index c263974..f091063 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -64,7 +64,7 @@ Open a debug session: hand the driver agent a goal for a configured target. One | Param | Type | Required | Description | |-------|------|----------|-------------| -| `target` | `string` | yes | Target name from the project config (`.dz/…` or root `.ui-debugger-mcp.json`) (e.g. `"web"`). Use `describe` to list valid values. | +| `target` | `string` | yes | Target name from `.ui-debugger-mcp.json` (e.g. `"web"`). Use `describe` to list valid values. | | `goal` | `string` | yes | The story — what to accomplish in plain language (e.g. `"log in and add item 3 to cart"`). | | `criteria` | `string` | no | Explicit pass/fail rules, one per line. Omit to let the agent judge. | | `url` | `string` (URL) | no | Where to point the driver for this run (web targets) — e.g. a local dev server, a preview, or production. Overrides the target's configured url; required when the target has none. | @@ -87,8 +87,7 @@ another live server owns. start_debug { target: "web", goal: "re-check the audit table", replace: true } ``` -Personas are configured per web target in the project config (`.dz/…` or root -`.ui-debugger-mcp.json`) — see +Personas are configured per web target in `.ui-debugger-mcp.json` — see [`idea/config.md`](idea/config.md#auth--named-login-personas-web) for the shape, the field-matching order, and the redaction guarantees. diff --git a/src/adapters/browser/launch.ts b/src/adapters/browser/launch.ts index c4b96a6..ebfbe58 100644 --- a/src/adapters/browser/launch.ts +++ b/src/adapters/browser/launch.ts @@ -89,7 +89,7 @@ export function appendDebugLogin(target: string, debugLogin?: { param: string }) url = new URL(target); } catch { throw new AdapterError( - `cannot append debug-login param to relative target ${JSON.stringify(target)} — set the web target's \`url\` in the project config (.dz/ui-debugger/ui-debugger-mcp.json or root .ui-debugger-mcp.json) so it resolves to an absolute URL`, + `cannot append debug-login param to relative target ${JSON.stringify(target)} — set the web target's \`url\` in .ui-debugger-mcp.json so it resolves to an absolute URL`, ); } url.searchParams.set(debugLogin.param, 'true'); diff --git a/src/adapters/factory.ts b/src/adapters/factory.ts index df0459a..8d99f48 100644 --- a/src/adapters/factory.ts +++ b/src/adapters/factory.ts @@ -18,7 +18,7 @@ import { DesktopAdapter } from './desktop/desktop-adapter.js'; * Create an adapter for a named target from the resolved config. * * @param targetName — the key in config.targets (e.g., "web") - * @param config — the resolved project config + * @param config — the resolved `.ui-debugger-mcp.json` config * @param profileDir — absolute path to persistent profile dir (for managed browser adapter) * @param onLog — optional sink for streaming console/network logs to findings store * @param timeoutMs — the run's remaining wall-clock budget for getting the target up; diff --git a/src/agent/belt/look.test.ts b/src/agent/belt/look.test.ts index 282d925..c6e5947 100644 --- a/src/agent/belt/look.test.ts +++ b/src/agent/belt/look.test.ts @@ -268,10 +268,7 @@ test('visionUnavailableMessage names the model and tells the driver to stop call ); expect(msg).toContain("'glm-5.2'"); expect(msg).toContain('Do NOT call look again'); - // Names WHERE the vision model is set — "the project config" since the - // `.dz/`-first dual read landed (the root filename alone is no longer the - // whole truth). - expect(msg).toContain('project config'); + expect(msg).toContain('.ui-debugger-mcp.json'); }); test('createLookExecute latches VisionUnavailableError: later calls fail fast, no screenshot', async () => { diff --git a/src/agent/belt/look.ts b/src/agent/belt/look.ts index 9790577..1261026 100644 --- a/src/agent/belt/look.ts +++ b/src/agent/belt/look.ts @@ -179,7 +179,7 @@ export function visionUnavailableMessage(modelId: string, providerMessage: strin `look is unavailable for this run: vision model '${modelId}' rejected image input ` + `(provider said: ${JSON.stringify(truncate(providerMessage, 140))}). The configured ` + 'models.vision appears to be text-only — set it to a multimodal model in ' + - 'the project config. Do NOT call look again this run; verify what you can from ' + + '.ui-debugger-mcp.json. Do NOT call look again this run; verify what you can from ' + 'observe (tree/console/network) and state in your report summary that visual checks ' + 'could not be performed.' ); diff --git a/src/config/schema.ts b/src/config/schema.ts index a9c6ad9..ae005e5 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,6 +1,5 @@ /** - * Zod schema for the committed, per-project debug config — same shape at either - * location: `.dz/ui-debugger/ui-debugger-mcp.json` or root `.ui-debugger-mcp.json`. + * Zod schema for `.ui-debugger-mcp.json` — the committed, per-project debug config. * Mirrors `.ui-debugger-mcp.example.json`. Validated at the config boundary; bad * config fails fast and loud (see `ConfigError`). */ @@ -157,7 +156,7 @@ export const TargetSchema = z.discriminatedUnion('adapter', [ AndroidTargetSchema, ]); -/** Top-level project config shape. Targets keyed by name (web, desktop, mobile, …). */ +/** Top-level `.ui-debugger-mcp.json` shape. Targets keyed by name (web, desktop, mobile, …). */ export const ConfigSchema = z.strictObject({ models: ModelsSchema.partial().optional(), workspace: z.string().optional(), diff --git a/src/mcp/tools/output.ts b/src/mcp/tools/output.ts index 03fa9b4..d24d1d3 100644 --- a/src/mcp/tools/output.ts +++ b/src/mcp/tools/output.ts @@ -49,7 +49,7 @@ const TargetInfoSchema = z.object({ .optional() .describe( 'Named auth personas — the valid start_debug({as}) values for this target. Names only; ' + - 'the credentials stay in the project config and never reach the driver.', + 'the credentials stay in .ui-debugger-mcp.json and never reach the driver.', ), notes: z .string() diff --git a/src/mcp/tools/start-debug.ts b/src/mcp/tools/start-debug.ts index 6931b87..f9508af 100644 --- a/src/mcp/tools/start-debug.ts +++ b/src/mcp/tools/start-debug.ts @@ -40,7 +40,7 @@ export function startDebugTool(service: DebugApi): McpTool { .string() .min(1) .describe( - 'Configured target name (a key in the project config targets, e.g. "web"). See describe.', + 'Configured target name (a key in .ui-debugger-mcp.json targets, e.g. "web"). See describe.', ), goal: z .string() diff --git a/src/services/debug-service.ts b/src/services/debug-service.ts index ad338eb..8b7f2aa 100644 --- a/src/services/debug-service.ts +++ b/src/services/debug-service.ts @@ -26,7 +26,7 @@ * already closed) and never participates in the one-run gate. */ -import { CONFIG_CANDIDATES, CONFIG_FILENAME, type ResolvedConfig } from '../config/load.js'; +import { CONFIG_FILENAME, type ResolvedConfig } from '../config/load.js'; import type { Target } from '../config/schema.js'; import { ConfigError, @@ -121,7 +121,7 @@ export interface TargetInfo { headless?: boolean; /** * Named auth personas configured for this target — the valid `start_debug({as})` - * values. NAMES ONLY; the credentials never leave the project config. + * values. NAMES ONLY; the credentials never leave `.ui-debugger-mcp.json`. * Absent when the target has no `auth` block. */ personas?: string[]; @@ -192,7 +192,7 @@ export interface DebugServiceDeps { /** Injected clock (epoch ms) for session ids + the run deadline; defaults to `Date.now`. */ now?: () => number; /** - * Has the project config changed since boot? Config is resolved once and + * Has `.ui-debugger-mcp.json` changed since boot? Config is resolved once and * wired into long-lived objects, so a changed file means every later run would * silently use the OLD settings — see `config/fingerprint.ts`. Defaults to * "never changed" (unit tests hold no file). @@ -434,8 +434,7 @@ export class DebugService implements DebugApi { #assertConfigFresh(): void { if (!this.#configChanged()) return; throw new ConfigError( - `The project config (\`${CONFIG_CANDIDATES[0]}\`, or root \`${CONFIG_FILENAME}\`) changed on disk after this ` + - 'ui-debugger-mcp server started, so a run now ' + + `${CONFIG_FILENAME} changed on disk after this ui-debugger-mcp server started, so a run now ` + 'would still use the OLD settings (models, targets, urls are read once at boot). ' + 'Restart the MCP server to pick the new config up — in Claude Code, /mcp → reconnect ' + 'ui-debugger, or restart the session.', diff --git a/src/services/login.ts b/src/services/login.ts index d0405cd..39b98a4 100644 --- a/src/services/login.ts +++ b/src/services/login.ts @@ -77,7 +77,7 @@ export function resolveAuth( : `target '${targetName}' has no 'auth' block`; throw new ConfigError( `unknown auth persona '${as}' for target '${targetName}' — ${known}. ` + - `Add it under targets.${targetName}.auth in the project config (.dz/ui-debugger/ui-debugger-mcp.json or root .ui-debugger-mcp.json), or omit 'as' to run signed out.`, + `Add it under targets.${targetName}.auth in .ui-debugger-mcp.json, or omit 'as' to run signed out.`, ); } diff --git a/src/services/session-builder.ts b/src/services/session-builder.ts index cebe91d..abf73ec 100644 --- a/src/services/session-builder.ts +++ b/src/services/session-builder.ts @@ -183,7 +183,7 @@ export function resolveRunTarget(target: Target, name: string, url: string | und } if (target.adapter === 'browser' && !target.url) { throw new ConfigError( - `web target '${name}' has no URL — pass 'url' to start_debug or set it in the project config (.dz/ui-debugger/ui-debugger-mcp.json or root .ui-debugger-mcp.json)`, + `web target '${name}' has no URL — pass 'url' to start_debug or set it in .ui-debugger-mcp.json`, ); } return target;