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/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/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