Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 15 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand All @@ -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
Expand All @@ -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" }
Expand Down Expand Up @@ -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" }
```
Expand Down Expand Up @@ -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`).

Expand Down
2 changes: 1 addition & 1 deletion docs/idea/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 24 additions & 8 deletions docs/idea/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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.
2 changes: 1 addition & 1 deletion src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
77 changes: 76 additions & 1 deletion src/cli/init.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
});
48 changes: 33 additions & 15 deletions src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -63,23 +70,24 @@ 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;
try {
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)}`,
);
}

Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading