@thedesignagent/mcp 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @thedesignagent/mcp
2
2
 
3
- MCP server for [TheDesignAgent](https://thedesignagent.ai): UX and visual judgment for UI that coding agents build.
3
+ MCP server and CLI for [TheDesignAgent](https://thedesignagent.ai): UX and visual judgment for UI that coding agents build.
4
4
 
5
5
  Your agent gets a build brief before it builds a screen, and a scored review after: does the screen serve the user's job, does it follow UX heuristics, does it match your design system. Reviews use a real screenshot when the page is running.
6
6
 
@@ -32,6 +32,14 @@ Use the hosted server if you only need briefs and UX reviews. Use this package w
32
32
 
33
33
  Get an API key (it starts with `tda_`) at [thedesignagent.ai/dashboard/api-keys](https://thedesignagent.ai/dashboard/api-keys).
34
34
 
35
+ You can put the key in each client's config as shown below, or save it once for every client and the CLI:
36
+
37
+ ```
38
+ npx -y --package=@thedesignagent/mcp thedesignagent login
39
+ ```
40
+
41
+ That writes `~/.thedesignagent/credentials`, readable only by you. `THEDESIGNAGENT_API_KEY` still wins when it's set, so you can then leave the `env` lines out of the configs below.
42
+
35
43
  ### Claude Code (recommended: the plugin)
36
44
 
37
45
  The plugin includes this server, plus skills that teach the agent the brief → build → review loop and `/tda:brief` and `/tda:review` commands:
@@ -105,6 +113,134 @@ The package has two commands, so pass `--package` and name `thedesignagent-mcp`
105
113
 
106
114
  After the first successful `Discover`, write `{ "project_id": "<id>" }` to `.thedesignagent` and commit it, so every agent on the repo shares the same project context.
107
115
 
116
+ ## Command line and CI
117
+
118
+ The same package has a `thedesignagent` command for terminals, headless agent runs and CI. `npx thedesignagent` runs it with no install.
119
+
120
+ ```
121
+ npx thedesignagent check http://localhost:3000/checkout --threshold 7
122
+ npx thedesignagent check app/checkout/page.tsx --checks ux
123
+ npx thedesignagent brief "Build the forecast approval queue"
124
+ ```
125
+
126
+ | Command | What it does |
127
+ | --- | --- |
128
+ | `check [url\|file]` | With no target, checks every page listed in `.thedesignagent` (below). Otherwise scores a page from a screenshot (`visual`) or a source file (`ux`). On a URL, `--checks visual,ux --code <file>` runs both (the UX review reads the page's source). `--task` says what the screen is for. |
129
+ | `brief "<task>"` | Writes a build brief to `.thedesignagent-brief.md` (`--out -` prints it). Point any agent at it from AGENTS.md or CLAUDE.md. |
130
+ | `login [key]` | Saves your API key. Reads it from stdin when piped. |
131
+
132
+ `check` options: `--threshold <n>` exits 1 when a check's overall score is below `n`; `--json` prints the full result (scores, findings, recommendations, screenshot path) for scripts and agents.
133
+
134
+ Exit codes: `0` ok, `1` below threshold, `2` error (key, network, usage), `3` the page redirected to a login screen (no review ran, nothing charged).
135
+
136
+ Both commands find the project from `.thedesignagent` in the repo, or `--project <id>`, or `THEDESIGNAGENT_PROJECT_ID`. Commit `.thedesignagent`: CI clones often use a different remote URL than your machine, so the fallback (a hash of the remote URL) can point at a different project. A project's first brief needs a project model, which your coding agent builds on its first `Discover` call; after that the CLI's briefs work anywhere.
137
+
138
+ ### Checking a set of pages
139
+
140
+ List the pages to check in `.thedesignagent`, and `check` with no target checks them all:
141
+
142
+ ```json
143
+ {
144
+ "project_id": "proj_...",
145
+ "check": {
146
+ "base_url": "http://localhost:3000",
147
+ "threshold": 7,
148
+ "pages": [
149
+ { "path": "/checkout", "task": "Pay for the items in the cart" },
150
+ { "path": "/orders", "code": "app/orders/page.tsx", "checks": ["visual", "ux"], "threshold": 7.5 }
151
+ ]
152
+ }
153
+ }
154
+ ```
155
+
156
+ ```
157
+ npx thedesignagent check
158
+ npx thedesignagent check --base-url https://my-app-git-feature.vercel.app
159
+ ```
160
+
161
+ `--base-url` (or `THEDESIGNAGENT_BASE_URL`) points the same pages at a preview deploy. A page's own `threshold` overrides the shared one, and `--threshold` overrides both. `code` paths are relative to the repo root. The run exits 1 if any page is below its threshold, and 3 if a page redirected to login.
162
+
163
+ ### Only the pages a branch changed
164
+
165
+ `check --changed` diffs the branch against `origin/main` (in GitHub Actions, the pull request's base branch; `--base <ref>` to choose) and checks the Next.js App Router pages it touched. A changed file counts toward the nearest `page.tsx` above it, so editing `app/orders/_components/table.tsx` re-checks `/orders`. Route groups like `(shop)` are handled. Dynamic routes (`[id]`) have no URL to visit, so list a concrete page for them in `.thedesignagent` (with `code` pointing at the page file) and it's checked when that file changes.
166
+
167
+ ```
168
+ npx thedesignagent check --changed --base-url https://my-app-git-feature.vercel.app --threshold 7
169
+ ```
170
+
171
+ ### Headless agent runs
172
+
173
+ Ask the agent to run the check and fix what it finds:
174
+
175
+ ```
176
+ claude -p "Build the checkout page. Then run 'npx thedesignagent check http://localhost:3000/checkout --json' and fix every critical and high finding."
177
+ codex exec "Build the checkout page. Then run 'npx thedesignagent check http://localhost:3000/checkout --json' and fix every critical and high finding."
178
+ grok -p "Build the checkout page. Then run 'npx thedesignagent check http://localhost:3000/checkout --json' and fix every critical and high finding."
179
+ ```
180
+
181
+ Or have the agent write the brief first: `npx thedesignagent brief "Build the checkout page" && claude -p "Read .thedesignagent-brief.md, then build the checkout page."`
182
+
183
+ ### GitHub Actions
184
+
185
+ Fail a build when a page scores below 7. Add your key as the repository secret `THEDESIGNAGENT_API_KEY`. GitHub's Ubuntu runners include Chrome.
186
+
187
+ ```yaml
188
+ name: Design check
189
+ on: pull_request
190
+
191
+ jobs:
192
+ design-check:
193
+ runs-on: ubuntu-latest
194
+ steps:
195
+ - uses: actions/checkout@v4
196
+ - uses: actions/setup-node@v4
197
+ with:
198
+ node-version: 20
199
+ - run: npm ci
200
+ - run: npm run build
201
+ - run: npm start &
202
+ - run: npx -y wait-on -t 120000 http://localhost:3000/checkout
203
+ - run: npx -y thedesignagent check http://localhost:3000/checkout --threshold 7
204
+ env:
205
+ THEDESIGNAGENT_API_KEY: ${{ secrets.THEDESIGNAGENT_API_KEY }}
206
+ ```
207
+
208
+ #### On pull requests: changed pages only, with a PR comment
209
+
210
+ Check only the pages the pull request touched and post the scores as one comment, updated on every push:
211
+
212
+ ```yaml
213
+ name: Design check
214
+ on: pull_request
215
+
216
+ permissions:
217
+ contents: read
218
+ pull-requests: write
219
+
220
+ jobs:
221
+ design-check:
222
+ runs-on: ubuntu-latest
223
+ steps:
224
+ - uses: actions/checkout@v4
225
+ with:
226
+ fetch-depth: 0
227
+ - uses: actions/setup-node@v4
228
+ with:
229
+ node-version: 20
230
+ - run: npm ci
231
+ - run: npm run build
232
+ - run: npm start &
233
+ - run: npx -y wait-on -t 120000 http://localhost:3000
234
+ - run: npx -y thedesignagent check --changed --base-url http://localhost:3000 --threshold 7 --pr-comment
235
+ env:
236
+ THEDESIGNAGENT_API_KEY: ${{ secrets.THEDESIGNAGENT_API_KEY }}
237
+ GITHUB_TOKEN: ${{ github.token }}
238
+ ```
239
+
240
+ `fetch-depth: 0` lets `--changed` diff against the base branch. The comment lists each page's scores against the threshold, with the top findings and fixes folded under each. If commenting fails (for example without `pull-requests: write`), the check still runs and still sets the exit code.
241
+
242
+ The score table and the critical and high findings appear on the run's summary page. For pages behind login, set `THEDESIGNAGENT_AUTH_SEED` to an AuthSeed object, `{ "cookies": [...] }` (the format `thedesignagent-auth` saves under `~/.thedesignagent/auth/`), ideally for a test account.
243
+
108
244
  ## Screenshots
109
245
 
110
246
  `Visual` screenshots `render_url` with your local Chrome, Edge, Brave or Chromium. If it can't find one, set `CHROME_PATH` to the browser binary.
@@ -139,7 +275,9 @@ Check what's saved with `thedesignagent-auth status <url>`.
139
275
 
140
276
  | Variable | Required | Purpose |
141
277
  | --- | --- | --- |
142
- | `THEDESIGNAGENT_API_KEY` | Yes | Your `tda_` API key |
278
+ | `THEDESIGNAGENT_API_KEY` | Yes, unless you ran `thedesignagent login` | Your `tda_` API key |
279
+ | `THEDESIGNAGENT_PROJECT_ID` | No | Project for the CLI when there's no `.thedesignagent` file |
280
+ | `THEDESIGNAGENT_AUTH_SEED` | No | Session cookies as JSON, for screenshots behind login in CI |
143
281
  | `CHROME_PATH` | No | Browser binary for screenshots, if it isn't found automatically |
144
282
  | `ANTHROPIC_API_KEY` | No | Enables a fallback review through Claude when TheDesignAgent's API is unreachable |
145
283
 
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,771 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * thedesignagent: TheDesignAgent from a terminal, a headless agent run or CI.
4
+ *
5
+ * check <url|file> Score a page (screenshot) or a source file. Exit 1 below --threshold.
6
+ * brief "<task>" Write a build brief to .thedesignagent-brief.md for any agent to read.
7
+ * login [key] Save your tda_ API key to ~/.thedesignagent/credentials.
8
+ *
9
+ * Exit codes: 0 ok · 1 below threshold · 2 error · 3 page is behind login (nothing charged).
10
+ */
11
+ import { appendFileSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
12
+ import { createHash } from 'node:crypto';
13
+ import { execSync } from 'node:child_process';
14
+ import { createRequire } from 'node:module';
15
+ import { join, resolve } from 'node:path';
16
+ import { parseArgs } from 'node:util';
17
+ import { createInterface } from 'node:readline';
18
+ import { captureScreenshot } from './screenshot.js';
19
+ import { loadStoredAuth } from './auth-store.js';
20
+ import { loadApiKey, looksLikeApiKey, saveApiKey, MISSING_KEY_HELP } from './credentials.js';
21
+ const API_BASE = process.env.THEDESIGNAGENT_API_BASE ?? 'https://bxqifcmkydlsgwgcpqda.supabase.co/functions/v1';
22
+ const EDGE_TIMEOUT_MS = 280_000;
23
+ const MAX_ARTIFACT_CHARS = 60_000;
24
+ const BRIEF_FILE = '.thedesignagent-brief.md';
25
+ const EXIT = { ok: 0, belowThreshold: 1, error: 2, authRequired: 3 };
26
+ const USAGE = `Usage: thedesignagent <command> [options]
27
+
28
+ Commands:
29
+ check [url|file] Score a page or a source file; with no target, every page in .thedesignagent
30
+ brief "<task>" Write a build brief for the screen you're about to build
31
+ login [key] Save your API key (or pipe it on stdin)
32
+
33
+ check options:
34
+ --task <text> What the screen is for (default: a generic review)
35
+ --checks <list> visual, ux or visual,ux (default: visual for a URL, ux for a file)
36
+ --code <file> Source of the page; needed for --checks ux on a URL
37
+ --threshold <n> Exit 1 if a check's overall score is below n (0-10)
38
+ --base-url <url> With no target: where the configured pages live (e.g. a preview deploy)
39
+ --changed Check only the pages this branch changed (Next.js app/ routes)
40
+ --pr-comment In GitHub Actions: post the results as one PR comment, updated on every run
41
+ --base <ref> Branch to diff against with --changed (default: origin/main, or the PR base in GitHub Actions)
42
+ --json Print the result as JSON
43
+
44
+ brief options:
45
+ --out <file> Where to write the brief (default: ${BRIEF_FILE}; "-" for stdout)
46
+ --project-model <file> Project model JSON, for a project's first brief
47
+ --json Print the raw result as JSON
48
+
49
+ Both use the project_id in .thedesignagent, or --project <id>, or THEDESIGNAGENT_PROJECT_ID.
50
+
51
+ Exit codes: 0 ok, 1 below threshold, 2 error, 3 page is behind login (nothing charged).`;
52
+ // ---- Small helpers --------------------------------------------------------
53
+ class CliError extends Error {
54
+ code;
55
+ constructor(message, code = EXIT.error) {
56
+ super(message);
57
+ this.code = code;
58
+ }
59
+ }
60
+ function version() {
61
+ try {
62
+ return createRequire(import.meta.url)('../package.json').version;
63
+ }
64
+ catch {
65
+ return 'unknown';
66
+ }
67
+ }
68
+ function requireKey() {
69
+ const { key } = loadApiKey();
70
+ if (!key)
71
+ throw new CliError(MISSING_KEY_HELP);
72
+ return key;
73
+ }
74
+ function gitRoot() {
75
+ try {
76
+ return execSync('git rev-parse --show-toplevel', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null;
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ let cachedConfig;
83
+ // .thedesignagent in the working directory or the repo root.
84
+ function loadConfig() {
85
+ if (cachedConfig !== undefined)
86
+ return cachedConfig;
87
+ cachedConfig = null;
88
+ for (const dir of [process.cwd(), gitRoot()]) {
89
+ if (!dir)
90
+ continue;
91
+ const file = join(dir, '.thedesignagent');
92
+ if (!existsSync(file) || !statSync(file).isFile())
93
+ continue;
94
+ try {
95
+ cachedConfig = { config: JSON.parse(readFileSync(file, 'utf-8')), dir };
96
+ }
97
+ catch {
98
+ throw new CliError(`${file} is not valid JSON. Expected { "project_id": "proj_..." }.`);
99
+ }
100
+ break;
101
+ }
102
+ return cachedConfig;
103
+ }
104
+ function resolveProject(flag) {
105
+ const explicit = flag ?? process.env.THEDESIGNAGENT_PROJECT_ID;
106
+ if (explicit)
107
+ return { project_id: explicit };
108
+ const id = loadConfig()?.config.project_id;
109
+ if (id)
110
+ return { project_id: id };
111
+ // Same recipe as the agent skills. CI clones often use a different remote
112
+ // URL (https vs ssh), which hashes differently, so commit .thedesignagent.
113
+ try {
114
+ const remote = execSync('git remote get-url origin', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
115
+ if (remote)
116
+ return { repo_hash: createHash('sha256').update(remote).digest('hex') };
117
+ }
118
+ catch { /* not a git repo */ }
119
+ throw new CliError('No project found. Run this in a repo with a .thedesignagent file, or pass --project <id>.');
120
+ }
121
+ async function callEdge(path, body) {
122
+ let response;
123
+ try {
124
+ response = await fetch(`${API_BASE}/${path}`, {
125
+ method: 'POST',
126
+ headers: { Authorization: `Bearer ${requireKey()}`, 'Content-Type': 'application/json' },
127
+ body: JSON.stringify(body),
128
+ signal: AbortSignal.timeout(EDGE_TIMEOUT_MS),
129
+ });
130
+ }
131
+ catch (e) {
132
+ if (e instanceof CliError)
133
+ throw e;
134
+ throw new CliError(`Couldn't reach TheDesignAgent: ${e instanceof Error ? e.message : String(e)}`);
135
+ }
136
+ if (response.ok)
137
+ return await response.json();
138
+ let err = {};
139
+ try {
140
+ err = await response.json();
141
+ }
142
+ catch { /* non-JSON error body */ }
143
+ const message = err.message ?? `HTTP ${response.status}`;
144
+ if (response.status === 401 || response.status === 403) {
145
+ throw new CliError(`Authentication failed: ${message}\nCheck your API key (THEDESIGNAGENT_API_KEY or "thedesignagent login").`);
146
+ }
147
+ if (response.status === 402)
148
+ throw new CliError(message);
149
+ if (response.status === 429)
150
+ throw new CliError(`Rate limit reached: ${message}`);
151
+ throw new CliError(`${path} failed (${response.status}): ${message}${err.detail ? `\nDetail: ${err.detail}` : ''}`);
152
+ }
153
+ function isUrl(target) {
154
+ return /^https?:\/\//i.test(target);
155
+ }
156
+ function readArtifact(path) {
157
+ const abs = resolve(path);
158
+ if (!existsSync(abs) || !statSync(abs).isFile())
159
+ throw new CliError(`File not found: ${path}`);
160
+ const text = readFileSync(abs, 'utf-8');
161
+ return text.length > MAX_ARTIFACT_CHARS ? text.slice(0, MAX_ARTIFACT_CHARS) + '\n/* truncated */' : text;
162
+ }
163
+ function parseChecks(raw, target) {
164
+ if (!raw)
165
+ return isUrl(target) ? ['visual'] : ['ux'];
166
+ const names = raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
167
+ for (const n of names) {
168
+ if (n !== 'visual' && n !== 'ux')
169
+ throw new CliError(`Unknown check "${n}". Use visual, ux or visual,ux.`);
170
+ }
171
+ return [...new Set(names)];
172
+ }
173
+ function parseThreshold(raw) {
174
+ if (raw === undefined)
175
+ return null;
176
+ if (raw.trim() === '')
177
+ throw new CliError('--threshold is empty. Pass a number from 0 to 10.');
178
+ const n = Number(raw);
179
+ if (!Number.isFinite(n) || n < 0 || n > 10)
180
+ throw new CliError(`--threshold must be a number from 0 to 10, got "${raw}".`);
181
+ return n;
182
+ }
183
+ function envAuthSeed() {
184
+ const raw = process.env.THEDESIGNAGENT_AUTH_SEED;
185
+ if (!raw)
186
+ return undefined;
187
+ try {
188
+ return JSON.parse(raw);
189
+ }
190
+ catch {
191
+ throw new CliError('THEDESIGNAGENT_AUTH_SEED is not valid JSON.');
192
+ }
193
+ }
194
+ function defaultTask(target) {
195
+ return isUrl(target) ? `Review the page at ${new URL(target).pathname}` : `Review the UI in ${target}`;
196
+ }
197
+ function validateSpec(spec) {
198
+ if (isUrl(spec.target) && spec.checks.includes('ux') && !spec.code) {
199
+ throw new CliError(`The ux check reviews source code, not a screenshot. Pass the page's source with --code <file> (or "code" in .thedesignagent), or use --checks visual. (${spec.target})`);
200
+ }
201
+ }
202
+ async function checkPage(spec, identity, json) {
203
+ const url = isUrl(spec.target) ? spec.target : undefined;
204
+ const artifact = url
205
+ ? (spec.code ? readArtifact(spec.code) : `The rendered page at ${url}. Judge it from the screenshot.`)
206
+ : readArtifact(spec.target);
207
+ let screenshot;
208
+ if (url && spec.checks.includes('visual')) {
209
+ status(json, `Screenshotting ${url}…`);
210
+ const seed = envAuthSeed() ?? loadStoredAuth(url) ?? undefined;
211
+ const shot = await captureScreenshot(url, seed).catch((e) => {
212
+ throw new CliError(`Screenshot failed: ${e instanceof Error ? e.message : String(e)}`);
213
+ });
214
+ if (shot.authRequired) {
215
+ return {
216
+ result: { target: spec.target, screenshot: null, authRequired: true, finalUrl: shot.finalUrl, outcomes: [] },
217
+ identity,
218
+ };
219
+ }
220
+ screenshot = { base64: shot.base64, localPath: shot.localPath };
221
+ }
222
+ const outcomes = [];
223
+ let callIdentity = identity;
224
+ for (const check of spec.checks) {
225
+ status(json, `Running ${check} review${url ? ` of ${url}` : ''}…`);
226
+ const result = check === 'visual'
227
+ ? await callEdge('visual', {
228
+ task: spec.task, artifact, ...callIdentity,
229
+ ...(screenshot && { screenshot: screenshot.base64 }),
230
+ ...(url && { render_url: url }),
231
+ })
232
+ : await callEdge('ux', { task: spec.task, artifact, ...callIdentity });
233
+ // Later calls use the project the first one resolved, so an unknown id
234
+ // or a repo_hash doesn't create a second project.
235
+ if (result.project_id)
236
+ callIdentity = { project_id: result.project_id };
237
+ const score = (check === 'visual' ? result.visual_score : result.ux_score) ?? NaN;
238
+ outcomes.push({
239
+ check,
240
+ score,
241
+ passed: spec.threshold === null ? null : score >= spec.threshold,
242
+ summary: result.summary,
243
+ scores: result.scores ?? {},
244
+ findings: result.findings ?? [],
245
+ recommendations: result.recommendations ?? [],
246
+ degradations: result.degradations ?? [],
247
+ });
248
+ }
249
+ return {
250
+ result: { target: spec.target, screenshot: screenshot?.localPath ?? null, authRequired: false, outcomes },
251
+ identity: callIdentity,
252
+ };
253
+ }
254
+ function loginHelp(r) {
255
+ return [
256
+ `${r.target} redirected to a login page (${r.finalUrl}). No review ran and nothing was charged.`,
257
+ 'Save a session first:',
258
+ ` npx -y --package=@thedesignagent/mcp thedesignagent-auth capture ${r.target}`,
259
+ 'In CI, set THEDESIGNAGENT_AUTH_SEED to an AuthSeed object ({ "cookies": [...] }), or check a preview with protection bypass.',
260
+ ].join('\n');
261
+ }
262
+ // Pages from .thedesignagent: { "check": { "base_url", "threshold", "pages": [...] } }.
263
+ function configuredSpecs(opts, thresholdFlag) {
264
+ const loaded = loadConfig();
265
+ const cfg = loaded?.config.check;
266
+ if (!cfg?.pages?.length) {
267
+ throw new CliError([
268
+ 'Nothing to check. Pass a URL or file, or list pages in .thedesignagent:',
269
+ ' { "project_id": "proj_...", "check": { "base_url": "http://localhost:3000", "threshold": 7,',
270
+ ' "pages": [ { "path": "/checkout", "task": "Pay for the cart" } ] } }',
271
+ ].join('\n'));
272
+ }
273
+ const base = opts['base-url'] ?? process.env.THEDESIGNAGENT_BASE_URL ?? cfg.base_url;
274
+ const flagChecks = opts.checks ? parseChecks(opts.checks, 'http://x') : undefined;
275
+ return cfg.pages.map((page, i) => {
276
+ if (!page?.path)
277
+ throw new CliError(`.thedesignagent check.pages[${i}] needs a "path".`);
278
+ const isAbsolute = isUrl(page.path);
279
+ if (!isAbsolute && !base) {
280
+ throw new CliError(`No base URL for "${page.path}". Set check.base_url in .thedesignagent, or pass --base-url.`);
281
+ }
282
+ const target = isAbsolute ? page.path : new URL(page.path, base).href;
283
+ const configThreshold = page.threshold ?? cfg.threshold;
284
+ return {
285
+ target,
286
+ task: page.task ?? defaultTask(target),
287
+ code: page.code && loaded ? resolve(loaded.dir, page.code) : undefined,
288
+ checks: flagChecks ?? page.checks ?? ['visual'],
289
+ threshold: thresholdFlag ?? (typeof configThreshold === 'number' ? configThreshold : null),
290
+ };
291
+ });
292
+ }
293
+ // ---- --changed: map a branch's changed files to the routes they render ----
294
+ const PAGE_FILE = /^page\.(tsx|jsx|ts|js|mdx)$/;
295
+ function changedFiles(base) {
296
+ const ref = base
297
+ ?? (process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : 'origin/main');
298
+ try {
299
+ return execSync(`git diff --name-only --diff-filter=d ${JSON.stringify(ref)}...HEAD`, { stdio: ['ignore', 'pipe', 'pipe'] })
300
+ .toString().split('\n').map(l => l.trim()).filter(Boolean);
301
+ }
302
+ catch {
303
+ throw new CliError(`Couldn't diff against ${ref}. Fetch it first (in CI: actions/checkout with fetch-depth: 0), or pass --base <ref>.`);
304
+ }
305
+ }
306
+ // Next.js App Router: app/(group)/orders/page.tsx -> /orders. Dynamic
307
+ // segments ([id]) have no URL to visit, so they're skipped unless
308
+ // .thedesignagent lists a concrete page for them.
309
+ function routeForAppDir(segments) {
310
+ const parts = [];
311
+ for (const seg of segments) {
312
+ if (seg.startsWith('(') && seg.endsWith(')'))
313
+ continue;
314
+ if (seg.startsWith('@') || seg.startsWith('_'))
315
+ return null;
316
+ if (seg.startsWith('['))
317
+ return null;
318
+ parts.push(seg);
319
+ }
320
+ return '/' + parts.join('/');
321
+ }
322
+ // For each changed file, the nearest enclosing App Router page: a change to
323
+ // app/orders/_components/table.tsx re-checks /orders.
324
+ function pagesForChangedFiles(root, files) {
325
+ const found = new Map();
326
+ for (const file of files) {
327
+ if (!/\.(tsx|jsx|ts|js|mdx|css|scss)$/.test(file))
328
+ continue;
329
+ const parts = file.split('/');
330
+ const appIdx = parts.findIndex((p, i) => p === 'app' && (i === 0 || (i === 1 && parts[0] === 'src')));
331
+ if (appIdx === -1)
332
+ continue;
333
+ for (let end = parts.length - 1; end > appIdx; end--) {
334
+ const dir = parts.slice(0, end);
335
+ const page = ['tsx', 'jsx', 'ts', 'js', 'mdx'].map(ext => [...dir, `page.${ext}`].join('/'))
336
+ .find(candidate => existsSync(join(root, candidate)));
337
+ if (!page)
338
+ continue;
339
+ const route = routeForAppDir(dir.slice(appIdx + 1));
340
+ if (route && !found.has(route))
341
+ found.set(route, { route, pageFile: page });
342
+ break;
343
+ }
344
+ }
345
+ return [...found.values()];
346
+ }
347
+ function changedSpecs(opts, thresholdFlag) {
348
+ const root = gitRoot();
349
+ if (!root)
350
+ throw new CliError('--changed needs a git repository.');
351
+ const files = changedFiles(opts.base);
352
+ const loaded = loadConfig();
353
+ const cfg = loaded?.config.check;
354
+ const base = opts['base-url'] ?? process.env.THEDESIGNAGENT_BASE_URL ?? cfg?.base_url;
355
+ if (!base)
356
+ throw new CliError('No base URL. Pass --base-url, set THEDESIGNAGENT_BASE_URL, or set check.base_url in .thedesignagent.');
357
+ const flagChecks = opts.checks ? parseChecks(opts.checks, 'http://x') : undefined;
358
+ const changed = new Set(files);
359
+ const specs = new Map();
360
+ const add = (route, page, pageFile) => {
361
+ const target = isUrl(route) ? route : new URL(route, base).href;
362
+ if (specs.has(target))
363
+ return;
364
+ const t = page?.threshold ?? cfg?.threshold;
365
+ specs.set(target, {
366
+ target,
367
+ task: page?.task ?? defaultTask(target),
368
+ code: page?.code && loaded ? resolve(loaded.dir, page.code) : pageFile ? join(root, pageFile) : undefined,
369
+ checks: flagChecks ?? page?.checks ?? ['visual'],
370
+ threshold: thresholdFlag ?? (typeof t === 'number' ? t : null),
371
+ });
372
+ };
373
+ // Listed pages whose source changed, then routes found from the diff
374
+ // (using the listed page's task and threshold when the route matches).
375
+ for (const page of cfg?.pages ?? []) {
376
+ if (page.code && changed.has(page.code.replace(/^\.\//, '')))
377
+ add(page.path, page, undefined);
378
+ }
379
+ for (const { route, pageFile } of pagesForChangedFiles(root, files)) {
380
+ add(route, cfg?.pages?.find(p => p.path === route), pageFile);
381
+ }
382
+ return [...specs.values()];
383
+ }
384
+ async function runCheck(target, opts) {
385
+ const thresholdFlag = parseThreshold(opts.threshold);
386
+ if (target && opts.changed)
387
+ throw new CliError('Pass a target or --changed, not both.');
388
+ const specs = opts.changed
389
+ ? changedSpecs(opts, thresholdFlag)
390
+ : target
391
+ ? [{
392
+ target,
393
+ task: opts.task ?? defaultTask(target),
394
+ code: opts.code,
395
+ checks: parseChecks(opts.checks, target),
396
+ threshold: thresholdFlag,
397
+ }]
398
+ : configuredSpecs(opts, thresholdFlag);
399
+ specs.forEach(validateSpec);
400
+ if (!specs.length) {
401
+ if (opts['pr-comment'])
402
+ await postPrComment(`${COMMENT_MARKER}\n### TheDesignAgent design check: no UI pages changed`, opts.pr);
403
+ if (opts.json)
404
+ console.log(JSON.stringify({ ok: true, passed: true, project_id: null, pages: [] }, null, 2));
405
+ else
406
+ console.log('No changed UI pages to check.');
407
+ return EXIT.ok;
408
+ }
409
+ let identity = resolveProject(opts.project);
410
+ requireKey();
411
+ const results = [];
412
+ for (const spec of specs) {
413
+ const { result, identity: next } = await checkPage(spec, identity, opts.json);
414
+ identity = next;
415
+ results.push(result);
416
+ }
417
+ const failed = results.some(r => r.outcomes.some(o => o.passed === false));
418
+ const loginBlocked = results.filter(r => r.authRequired);
419
+ const anyThreshold = specs.some(s => s.threshold !== null);
420
+ const passed = anyThreshold ? !failed && loginBlocked.length === 0 : null;
421
+ if (opts.json) {
422
+ const pageJson = (r, spec) => ({
423
+ target: r.target,
424
+ threshold: spec.threshold,
425
+ screenshot: r.screenshot,
426
+ ...(r.authRequired && { error: 'auth_required', message: loginHelp(r), final_url: r.finalUrl }),
427
+ checks: r.outcomes,
428
+ });
429
+ const body = target
430
+ // One target keeps the flat shape.
431
+ ? { ok: !results[0].authRequired, passed, project_id: identity.project_id ?? null, ...pageJson(results[0], specs[0]) }
432
+ : { ok: loginBlocked.length === 0, passed, project_id: identity.project_id ?? null, pages: results.map((r, i) => pageJson(r, specs[i])) };
433
+ console.log(JSON.stringify(body, null, 2));
434
+ }
435
+ else {
436
+ results.forEach((r, i) => {
437
+ if (r.authRequired)
438
+ console.error(loginHelp(r));
439
+ else
440
+ console.log(formatCheck(r.target, r.outcomes, specs[i].threshold, r.screenshot ?? undefined));
441
+ if (i < results.length - 1)
442
+ console.log('');
443
+ });
444
+ }
445
+ results.forEach((r, i) => { if (!r.authRequired)
446
+ writeStepSummary(r.target, r.outcomes, specs[i].threshold); });
447
+ if (opts['pr-comment'])
448
+ await postPrComment(prCommentBody(results, specs), opts.pr);
449
+ if (failed)
450
+ return EXIT.belowThreshold;
451
+ if (loginBlocked.length)
452
+ return EXIT.authRequired;
453
+ return EXIT.ok;
454
+ }
455
+ const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low'];
456
+ const severityRank = (s) => { const i = SEVERITY_ORDER.indexOf(s); return i === -1 ? SEVERITY_ORDER.length : i; };
457
+ function fmt(n) {
458
+ return n === null || n === undefined || Number.isNaN(n) ? 'n/a' : n.toFixed(1);
459
+ }
460
+ function verdict(o, threshold) {
461
+ if (o.passed === null)
462
+ return '';
463
+ return o.passed ? ` pass (threshold ${threshold})` : ` FAIL (threshold ${threshold})`;
464
+ }
465
+ function formatCheck(target, outcomes, threshold, shotPath) {
466
+ const lines = [`TheDesignAgent · ${target}`, ''];
467
+ for (const o of outcomes) {
468
+ lines.push(`${o.check} ${fmt(o.score)}/10${verdict(o, threshold)}`);
469
+ lines.push(' ' + Object.entries(o.scores).map(([k, v]) => `${k} ${fmt(v)}`).join(' · '));
470
+ if (o.degradations.length)
471
+ lines.push(` degraded: ${o.degradations.join(', ')}`);
472
+ lines.push('', ` ${o.summary}`, '');
473
+ const top = [...o.findings]
474
+ .sort((a, b) => severityRank(a.severity) - severityRank(b.severity))
475
+ .slice(0, 5);
476
+ for (const f of top) {
477
+ const loc = f.location ? ` (${f.location})` : '';
478
+ lines.push(` [${f.severity}] ${f.dimension ?? f.track ?? 'general'}: ${f.description}${loc}`);
479
+ }
480
+ if (o.recommendations.length) {
481
+ lines.push('', ' Fix first:');
482
+ for (const r of o.recommendations.slice(0, 3))
483
+ lines.push(` - ${r.change}`);
484
+ }
485
+ lines.push('');
486
+ }
487
+ if (shotPath)
488
+ lines.push(`Screenshot: ${shotPath}`);
489
+ return lines.join('\n').trimEnd();
490
+ }
491
+ // GitHub Actions shows this file on the run's summary page.
492
+ function writeStepSummary(target, outcomes, threshold) {
493
+ const path = process.env.GITHUB_STEP_SUMMARY;
494
+ if (!path)
495
+ return;
496
+ const rows = outcomes.map(o => {
497
+ const result = o.passed === null ? '' : o.passed ? 'pass' : 'fail';
498
+ return `| ${o.check} | ${fmt(o.score)} | ${result} |`;
499
+ });
500
+ const findings = outcomes.flatMap(o => o.findings
501
+ .filter(f => f.severity === 'critical' || f.severity === 'high')
502
+ .map(f => `- **${o.check} · ${f.severity}**: ${f.description}`));
503
+ const md = [
504
+ `### TheDesignAgent: ${target}`,
505
+ '',
506
+ `| Check | Score | ${threshold === null ? '' : `Threshold ${threshold}`} |`,
507
+ '| --- | --- | --- |',
508
+ ...rows,
509
+ '',
510
+ ...(findings.length ? ['**Critical and high findings**', '', ...findings, ''] : []),
511
+ ].join('\n');
512
+ try {
513
+ appendFileSync(path, md + '\n');
514
+ }
515
+ catch { /* summary is best-effort */ }
516
+ }
517
+ // ---- --pr-comment: one comment per pull request, updated on every run ----
518
+ const COMMENT_MARKER = '<!-- thedesignagent-check -->';
519
+ function prCommentBody(results, specs) {
520
+ const failed = results.some(r => r.outcomes.some(o => o.passed === false));
521
+ const blocked = results.filter(r => r.authRequired);
522
+ const headline = failed
523
+ ? '❌ Below threshold'
524
+ : blocked.length ? `⚠️ ${blocked.length} page${blocked.length > 1 ? 's' : ''} couldn't be checked (login)`
525
+ : specs.some(s => s.threshold !== null) ? '✅ Passed' : 'Scores';
526
+ const pagePath = (target) => { try {
527
+ return new URL(target).pathname;
528
+ }
529
+ catch {
530
+ return target;
531
+ } };
532
+ const rows = [];
533
+ results.forEach((r, i) => {
534
+ if (r.authRequired) {
535
+ rows.push(`| \`${pagePath(r.target)}\` | | | ${specs[i].threshold ?? ''} | login required |`);
536
+ return;
537
+ }
538
+ for (const o of r.outcomes) {
539
+ const result = o.passed === null ? '' : o.passed ? 'pass' : '**fail**';
540
+ rows.push(`| \`${pagePath(r.target)}\` | ${o.check} | ${fmt(o.score)} | ${specs[i].threshold ?? ''} | ${result} |`);
541
+ }
542
+ });
543
+ const details = [];
544
+ for (const r of results) {
545
+ for (const o of r.outcomes) {
546
+ const top = [...o.findings].sort((a, b) => severityRank(a.severity) - severityRank(b.severity)).slice(0, 5);
547
+ if (!top.length && !o.recommendations.length)
548
+ continue;
549
+ details.push(`<details><summary><code>${pagePath(r.target)}</code> · ${o.check} ${fmt(o.score)}</summary>`, '', o.summary, '', ...top.map(f => `- **${f.severity}** ${f.dimension ?? f.track ?? 'general'}: ${f.description}${f.location ? ` (${f.location})` : ''}`), ...(o.recommendations.length ? ['', '**Fix first**', ...o.recommendations.slice(0, 3).map(rc => `- ${rc.change}`)] : []), '', '</details>');
550
+ }
551
+ }
552
+ return [
553
+ COMMENT_MARKER,
554
+ `### TheDesignAgent design check: ${headline}`,
555
+ '',
556
+ '| Page | Check | Score | Threshold | Result |',
557
+ '| --- | --- | --- | --- | --- |',
558
+ ...rows,
559
+ '',
560
+ ...details,
561
+ ].join('\n');
562
+ }
563
+ function pullRequestNumber(flag) {
564
+ if (flag)
565
+ return Number(flag) || null;
566
+ const eventPath = process.env.GITHUB_EVENT_PATH;
567
+ if (!eventPath || !existsSync(eventPath))
568
+ return null;
569
+ try {
570
+ const event = JSON.parse(readFileSync(eventPath, 'utf-8'));
571
+ return event.pull_request?.number ?? event.number ?? null;
572
+ }
573
+ catch {
574
+ return null;
575
+ }
576
+ }
577
+ // Best-effort: a failed comment never changes the check's exit code.
578
+ async function postPrComment(body, prFlag) {
579
+ const token = process.env.GITHUB_TOKEN;
580
+ const repo = process.env.GITHUB_REPOSITORY;
581
+ const pr = pullRequestNumber(prFlag);
582
+ if (!token || !repo || !pr) {
583
+ console.error('--pr-comment: skipped. It needs GITHUB_TOKEN, GITHUB_REPOSITORY and a pull_request event (or --pr <number>).');
584
+ return;
585
+ }
586
+ const api = process.env.GITHUB_API_URL ?? 'https://api.github.com';
587
+ const headers = {
588
+ Authorization: `Bearer ${token}`,
589
+ Accept: 'application/vnd.github+json',
590
+ 'X-GitHub-Api-Version': '2022-11-28',
591
+ 'Content-Type': 'application/json',
592
+ };
593
+ try {
594
+ let existing = null;
595
+ for (let page = 1; page <= 10 && existing === null; page++) {
596
+ const res = await fetch(`${api}/repos/${repo}/issues/${pr}/comments?per_page=100&page=${page}`, { headers });
597
+ if (!res.ok)
598
+ throw new Error(`listing comments: HTTP ${res.status}`);
599
+ const comments = await res.json();
600
+ existing = comments.find(c => c.body?.startsWith(COMMENT_MARKER))?.id ?? null;
601
+ if (comments.length < 100)
602
+ break;
603
+ }
604
+ const res = existing
605
+ ? await fetch(`${api}/repos/${repo}/issues/comments/${existing}`, { method: 'PATCH', headers, body: JSON.stringify({ body }) })
606
+ : await fetch(`${api}/repos/${repo}/issues/${pr}/comments`, { method: 'POST', headers, body: JSON.stringify({ body }) });
607
+ if (!res.ok)
608
+ throw new Error(`${existing ? 'updating' : 'posting'} comment: HTTP ${res.status}`);
609
+ }
610
+ catch (e) {
611
+ const hint = String(e).includes('403') ? ' Give the job "permissions: pull-requests: write".' : '';
612
+ console.error(`--pr-comment: couldn't comment on PR #${pr}: ${e instanceof Error ? e.message : String(e)}.${hint}`);
613
+ }
614
+ }
615
+ function status(json, msg) {
616
+ // Progress goes to stderr so --json stdout stays parseable.
617
+ if (!json && process.stderr.isTTY)
618
+ console.error(msg);
619
+ }
620
+ async function runBrief(task, opts) {
621
+ const identity = resolveProject(opts.project);
622
+ let projectModel;
623
+ if (opts.projectModel) {
624
+ try {
625
+ projectModel = JSON.parse(readFileSync(resolve(opts.projectModel), 'utf-8'));
626
+ }
627
+ catch (e) {
628
+ throw new CliError(`Couldn't read --project-model: ${e instanceof Error ? e.message : String(e)}`);
629
+ }
630
+ }
631
+ status(opts.json, 'Getting a build brief…');
632
+ const result = await callEdge('discovery', {
633
+ task, ...identity, ...(projectModel && { projectModel }),
634
+ });
635
+ if (opts.json) {
636
+ console.log(JSON.stringify(result, null, 2));
637
+ return result.status === 'ok' ? EXIT.ok : EXIT.error;
638
+ }
639
+ if (result.status !== 'ok') {
640
+ const why = result.status === 'needs_setup'
641
+ ? 'This project has no stored model yet.'
642
+ : 'The stored project model is out of date with your schema.';
643
+ console.error([
644
+ why,
645
+ 'Run one brief from your coding agent (it scans the repo and builds the model),',
646
+ 'or build the model yourself and pass it with --project-model <file>. The extraction task:',
647
+ '',
648
+ result.extraction_task ?? '(none returned)',
649
+ ].join('\n'));
650
+ return EXIT.error;
651
+ }
652
+ const md = [
653
+ `# Build brief: ${task}`,
654
+ '',
655
+ `Project ${result.project_id}${result.active_job ? ` · active job: ${result.active_job}` : ''}`,
656
+ `Generated by TheDesignAgent ${new Date().toISOString().slice(0, 10)}. Follow it while building this screen.`,
657
+ '',
658
+ result.brief ?? '',
659
+ '',
660
+ ].join('\n');
661
+ const out = opts.out ?? BRIEF_FILE;
662
+ if (out === '-') {
663
+ process.stdout.write(md);
664
+ }
665
+ else {
666
+ writeFileSync(resolve(out), md);
667
+ console.log(`Wrote ${out}. Point your agent at it, e.g. add "Read ${out} before building UI." to AGENTS.md or CLAUDE.md.`);
668
+ }
669
+ if (!identity.project_id && result.project_id) {
670
+ console.error(`Tip: save { "project_id": "${result.project_id}" } to .thedesignagent and commit it, so CI and every agent share this project.`);
671
+ }
672
+ return EXIT.ok;
673
+ }
674
+ // ---- login ----------------------------------------------------------------
675
+ async function readKeyInteractively() {
676
+ if (!process.stdin.isTTY) {
677
+ const chunks = [];
678
+ for await (const chunk of process.stdin)
679
+ chunks.push(chunk);
680
+ return Buffer.concat(chunks).toString('utf-8').trim();
681
+ }
682
+ console.error('Get a key at https://thedesignagent.ai/dashboard/api-keys');
683
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
684
+ // Hide what's typed: the key is a secret and terminals get recorded.
685
+ const rlOut = rl;
686
+ let prompted = false;
687
+ rlOut._writeToOutput = (s) => {
688
+ if (!prompted) {
689
+ rlOut.output.write(s);
690
+ prompted = true;
691
+ }
692
+ };
693
+ rl.on('SIGINT', () => {
694
+ rl.close();
695
+ process.stderr.write('\n');
696
+ process.exit(130);
697
+ });
698
+ const key = await new Promise(res => rl.question('Paste your API key: ', res));
699
+ rl.close();
700
+ process.stderr.write('\n');
701
+ return key.trim();
702
+ }
703
+ async function runLogin(keyArg) {
704
+ const key = (keyArg ?? await readKeyInteractively()).trim();
705
+ if (!looksLikeApiKey(key)) {
706
+ throw new CliError('That doesn\'t look like a TheDesignAgent key. Keys start with tda_.');
707
+ }
708
+ const path = saveApiKey(key);
709
+ console.log(`Saved to ${path} (readable only by you).`);
710
+ if (process.env.THEDESIGNAGENT_API_KEY) {
711
+ console.error('Note: THEDESIGNAGENT_API_KEY is set in this shell and takes precedence over the saved key.');
712
+ }
713
+ return EXIT.ok;
714
+ }
715
+ // ---- main -----------------------------------------------------------------
716
+ async function main() {
717
+ const { values, positionals } = parseArgs({
718
+ allowPositionals: true,
719
+ options: {
720
+ task: { type: 'string' },
721
+ checks: { type: 'string' },
722
+ code: { type: 'string' },
723
+ threshold: { type: 'string' },
724
+ json: { type: 'boolean' },
725
+ project: { type: 'string' },
726
+ 'base-url': { type: 'string' },
727
+ changed: { type: 'boolean' },
728
+ 'pr-comment': { type: 'boolean' },
729
+ pr: { type: 'string' },
730
+ base: { type: 'string' },
731
+ out: { type: 'string' },
732
+ 'project-model': { type: 'string' },
733
+ help: { type: 'boolean', short: 'h' },
734
+ version: { type: 'boolean', short: 'v' },
735
+ },
736
+ });
737
+ if (values.version) {
738
+ console.log(version());
739
+ return EXIT.ok;
740
+ }
741
+ const [command, ...rest] = positionals;
742
+ if (values.help || !command) {
743
+ console.log(USAGE);
744
+ return command || values.help ? EXIT.ok : EXIT.error;
745
+ }
746
+ switch (command) {
747
+ case 'check':
748
+ if (rest.length > 1)
749
+ throw new CliError('Usage: thedesignagent check [url|file] [--threshold n] [--json]');
750
+ return runCheck(rest[0], values);
751
+ case 'brief':
752
+ if (!rest.length)
753
+ throw new CliError('Usage: thedesignagent brief "<what you are about to build>"');
754
+ return runBrief(rest.join(' '), { ...values, projectModel: values['project-model'] });
755
+ case 'login':
756
+ return runLogin(rest[0]);
757
+ default:
758
+ throw new CliError(`Unknown command "${command}".\n\n${USAGE}`);
759
+ }
760
+ }
761
+ // Set exitCode rather than calling process.exit(): on macOS, stdout to a pipe
762
+ // is async, and exiting early truncates large --json output.
763
+ const wantsJson = process.argv.includes('--json');
764
+ main().then(code => { process.exitCode = code; }, (e) => {
765
+ // parseArgs throws TypeError with a readable message for bad flags.
766
+ const message = e instanceof Error ? e.message : String(e);
767
+ if (wantsJson)
768
+ console.log(JSON.stringify({ ok: false, error: 'error', message }, null, 2));
769
+ console.error(message);
770
+ process.exitCode = e instanceof CliError ? e.code : EXIT.error;
771
+ });
@@ -0,0 +1,8 @@
1
+ export declare const CREDENTIALS_PATH: string;
2
+ export declare function looksLikeApiKey(key: string): boolean;
3
+ export declare function loadApiKey(): {
4
+ key?: string;
5
+ source: 'env' | 'file' | 'none';
6
+ };
7
+ export declare function saveApiKey(key: string): string;
8
+ export declare const MISSING_KEY_HELP: string;
@@ -0,0 +1,44 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ // One key file for every client: MCP hosts with no standard way to prompt for
5
+ // a key (Agent Plugins, Cursor, Copilot) and the CLI both read it. The env var
6
+ // still wins, so CI and existing MCP configs behave as before.
7
+ export const CREDENTIALS_PATH = join(homedir(), '.thedesignagent', 'credentials');
8
+ export function looksLikeApiKey(key) {
9
+ return /^tda_[A-Za-z0-9_-]{8,}$/.test(key);
10
+ }
11
+ export function loadApiKey() {
12
+ const fromEnv = process.env.THEDESIGNAGENT_API_KEY?.trim();
13
+ if (fromEnv)
14
+ return { key: fromEnv, source: 'env' };
15
+ try {
16
+ if (!existsSync(CREDENTIALS_PATH))
17
+ return { source: 'none' };
18
+ const parsed = JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf-8'));
19
+ if (typeof parsed.api_key === 'string' && parsed.api_key.trim()) {
20
+ return { key: parsed.api_key.trim(), source: 'file' };
21
+ }
22
+ }
23
+ catch {
24
+ // Unreadable or malformed: treat as missing; `login` rewrites it.
25
+ }
26
+ return { source: 'none' };
27
+ }
28
+ export function saveApiKey(key) {
29
+ const dir = dirname(CREDENTIALS_PATH);
30
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
31
+ // Write a fresh 0600 file and rename it over the old one, so the key is
32
+ // never briefly readable through an existing file's looser mode.
33
+ const tmp = `${CREDENTIALS_PATH}.${process.pid}.tmp`;
34
+ writeFileSync(tmp, JSON.stringify({ api_key: key }, null, 2) + '\n', { mode: 0o600 });
35
+ chmodSync(tmp, 0o600);
36
+ renameSync(tmp, CREDENTIALS_PATH);
37
+ return CREDENTIALS_PATH;
38
+ }
39
+ export const MISSING_KEY_HELP = [
40
+ 'No TheDesignAgent API key found.',
41
+ 'Get one at https://thedesignagent.ai/dashboard/api-keys, then either:',
42
+ ' - run: npx -y --package=@thedesignagent/mcp thedesignagent login',
43
+ ' - or set the THEDESIGNAGENT_API_KEY environment variable.',
44
+ ].join('\n');
package/dist/index.js CHANGED
@@ -28,7 +28,10 @@ import { existsSync } from 'node:fs';
28
28
  import { dirname, join } from 'node:path';
29
29
  import { captureScreenshot } from './screenshot.js';
30
30
  import { loadStoredAuth } from './auth-store.js';
31
- const API_KEY = process.env.THEDESIGNAGENT_API_KEY;
31
+ import { loadApiKey, MISSING_KEY_HELP } from './credentials.js';
32
+ // Read per call: a key saved with `thedesignagent login` after the server
33
+ // started still takes effect without a restart.
34
+ const apiKey = () => loadApiKey().key;
32
35
  const API_BASE = process.env.THEDESIGNAGENT_API_BASE ?? 'https://bxqifcmkydlsgwgcpqda.supabase.co/functions/v1';
33
36
  // Discover/Ux route through the hosted gateway so mcp_call_logs telemetry
34
37
  // captures local-stdio calls too. Visual stays direct because it needs local
@@ -41,7 +44,7 @@ async function callGatewayTool(name, args) {
41
44
  response = await fetch(`${GATEWAY_BASE}/mcp`, {
42
45
  method: 'POST',
43
46
  headers: {
44
- Authorization: `Bearer ${API_KEY}`,
47
+ Authorization: `Bearer ${apiKey()}`,
45
48
  'Content-Type': 'application/json',
46
49
  'Accept': 'application/json',
47
50
  },
@@ -341,9 +344,9 @@ function formatDiscoveryResult(result) {
341
344
  }
342
345
  // ---- Tool handlers --------------------------------------------------------
343
346
  async function handleUxCall(args) {
344
- if (!API_KEY) {
347
+ if (!apiKey()) {
345
348
  return {
346
- content: [{ type: 'text', text: 'THEDESIGNAGENT_API_KEY is not set. Add it to your MCP server config.' }],
349
+ content: [{ type: 'text', text: MISSING_KEY_HELP }],
347
350
  isError: true,
348
351
  };
349
352
  }
@@ -421,9 +424,9 @@ async function handleUxCall(args) {
421
424
  }
422
425
  }
423
426
  async function handleVisualCall(args) {
424
- if (!API_KEY) {
427
+ if (!apiKey()) {
425
428
  return {
426
- content: [{ type: 'text', text: 'THEDESIGNAGENT_API_KEY is not set.' }],
429
+ content: [{ type: 'text', text: MISSING_KEY_HELP }],
427
430
  isError: true,
428
431
  };
429
432
  }
@@ -439,7 +442,7 @@ async function handleVisualCall(args) {
439
442
  try {
440
443
  const response = await fetch(`${API_BASE}/visual`, {
441
444
  method: 'POST',
442
- headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
445
+ headers: { Authorization: `Bearer ${apiKey()}`, 'Content-Type': 'application/json' },
443
446
  body: JSON.stringify({
444
447
  task: args.task,
445
448
  artifact: args.artifact,
@@ -465,7 +468,7 @@ async function handleVisualCall(args) {
465
468
  const err = await response.json();
466
469
  if (response.status === 401 || response.status === 403) {
467
470
  return {
468
- content: [{ type: 'text', text: `Authentication failed (${response.status}): ${err.message}\nCheck your THEDESIGNAGENT_API_KEY.` }],
471
+ content: [{ type: 'text', text: `Authentication failed (${response.status}): ${err.message}\nCheck your API key (THEDESIGNAGENT_API_KEY or "thedesignagent login").` }],
469
472
  isError: true,
470
473
  };
471
474
  }
@@ -492,9 +495,9 @@ async function handleVisualCall(args) {
492
495
  }
493
496
  }
494
497
  async function handleDiscoverCall(args) {
495
- if (!API_KEY) {
498
+ if (!apiKey()) {
496
499
  return {
497
- content: [{ type: 'text', text: 'THEDESIGNAGENT_API_KEY is not set.' }],
500
+ content: [{ type: 'text', text: MISSING_KEY_HELP }],
498
501
  isError: true,
499
502
  };
500
503
  }
@@ -534,7 +537,7 @@ const evalInputSchema = {
534
537
  repo_hash: z.string().optional().describe('SHA256 of git remote origin URL (fallback if no .thedesignagent)'),
535
538
  codebase_context: z.string().optional().describe('Optional surrounding code context'),
536
539
  };
537
- const server = new McpServer({ name: 'TheDesignAgent', version: '0.3.1' });
540
+ const server = new McpServer({ name: 'TheDesignAgent', version: '0.4.0' });
538
541
  server.registerTool('Discover', {
539
542
  description: `Get a build brief calibrated to your project, persona, and the specific screen you're building.
540
543
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@thedesignagent/mcp",
3
- "version": "0.3.1",
4
- "description": "MCP server for TheDesignAgent — UX and visual judgment layer for agent-generated UI",
3
+ "version": "0.4.0",
4
+ "description": "MCP server and CLI for TheDesignAgent \u2014 UX and visual judgment layer for agent-generated UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "homepage": "https://thedesignagent.ai",
@@ -14,11 +14,14 @@
14
14
  "design-system",
15
15
  "claude-code",
16
16
  "codex",
17
- "ai-agents"
17
+ "ai-agents",
18
+ "cli",
19
+ "ci"
18
20
  ],
19
21
  "bin": {
20
22
  "thedesignagent-mcp": "dist/index.js",
21
- "thedesignagent-auth": "dist/auth-capture.js"
23
+ "thedesignagent-auth": "dist/auth-capture.js",
24
+ "thedesignagent": "dist/cli.js"
22
25
  },
23
26
  "files": [
24
27
  "dist"