@thedesignagent/mcp 0.2.5 → 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.
@@ -113,19 +249,35 @@ Screenshots are saved to `~/.thedesignagent/screenshots/` for 24 hours, so you c
113
249
 
114
250
  ### Pages behind login
115
251
 
116
- Capture a logged-in session once per app:
252
+ When `Visual` lands on a login page it stops before reviewing (nothing is charged) and tells your agent how to save a session. Sessions are stored per site under `~/.thedesignagent/auth/` (readable only by you) and applied to every later screenshot of that site.
253
+
254
+ There are two ways to save one:
255
+
256
+ **A login recipe (automatic).** A small script in your repo, `.thedesignagent-login.mjs`, prints either a URL that logs in when opened (a magic link, a dev-only login route) or cookies as JSON. Pipe it into `login`:
117
257
 
118
258
  ```
119
- npx -y --package=@thedesignagent/mcp thedesignagent-auth capture http://localhost:3000
259
+ node .thedesignagent-login.mjs | npx -y --package=@thedesignagent/mcp thedesignagent-auth login http://localhost:3000/dashboard
120
260
  ```
121
261
 
122
- A browser opens; log in, then press Enter (or close the browser). The session is saved under `~/.thedesignagent/auth/` and reused for that origin. When `Visual` hits a login redirect, it tells the agent to ask you to run this command.
262
+ It logs in headlessly, checks the page no longer redirects to login, and saves. The Claude Code plugin's `/tda:setup` writes a recipe for Supabase apps.
263
+
264
+ **Capture by hand.** Opens a browser window; log in and it saves and closes by itself once you leave the login page:
265
+
266
+ ```
267
+ npx -y --package=@thedesignagent/mcp thedesignagent-auth capture http://localhost:3000/dashboard
268
+ ```
269
+
270
+ It uses a dedicated browser profile, so later re-captures are often instant. Agents can run it in the background: it ends with `TDA_AUTH_SAVED <origin>` or `TDA_AUTH_FAILED <reason>`, and gives up after 10 minutes.
271
+
272
+ Check what's saved with `thedesignagent-auth status <url>`.
123
273
 
124
274
  ## Environment variables
125
275
 
126
276
  | Variable | Required | Purpose |
127
277
  | --- | --- | --- |
128
- | `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 |
129
281
  | `CHROME_PATH` | No | Browser binary for screenshots, if it isn't found automatically |
130
282
  | `ANTHROPIC_API_KEY` | No | Enables a fallback review through Claude when TheDesignAgent's API is unreachable |
131
283
 
@@ -1,10 +1,40 @@
1
1
  #!/usr/bin/env node
2
+ /**
3
+ * thedesignagent-auth: save a logged-in session so `Visual` can screenshot
4
+ * pages behind login.
5
+ *
6
+ * capture <app-url> Opens a browser window; log in. Saves and closes on its own
7
+ * once you leave the login page (or press Enter / close it).
8
+ * Safe to run from an agent in the background.
9
+ * login <app-url> Non-interactive. Reads a login recipe's output on stdin:
10
+ * either a URL that logs in when opened (a magic link, a
11
+ * dev-only login route) or an AuthSeed JSON ({ cookies, ... }).
12
+ * Logs in headlessly, verifies, saves.
13
+ * status <app-url> Says whether a session is saved and when it expires.
14
+ *
15
+ * Every run ends with one machine-readable line for agents:
16
+ * TDA_AUTH_SAVED <origin> | TDA_AUTH_FAILED <reason> | TDA_AUTH_STATUS ...
17
+ */
2
18
  import { findChromeExecutable, isLoginUrl } from './screenshot.js';
3
- import { saveStoredAuth } from './auth-store.js';
19
+ import { authFilePath, loadStoredAuth, saveStoredAuth, BROWSER_PROFILE_DIR } from './auth-store.js';
20
+ import { existsSync } from 'node:fs';
4
21
  import { createInterface } from 'node:readline';
22
+ const CAPTURE_TIMEOUT_MS = Number(process.env.TDA_CAPTURE_TIMEOUT_MS ?? 10 * 60 * 1000);
23
+ const POLL_MS = 1500;
5
24
  function printUsage() {
6
- console.error('Usage: thedesignagent-auth capture <url>');
7
- console.error('Example: thedesignagent-auth capture http://localhost:3000');
25
+ console.error('Usage:');
26
+ console.error(' thedesignagent-auth capture <app-url> log in by hand in a browser window');
27
+ console.error(' thedesignagent-auth login <app-url> log in from a recipe (URL or AuthSeed JSON on stdin)');
28
+ console.error(' thedesignagent-auth status <app-url> show whether a session is saved');
29
+ console.error('Example: node .thedesignagent-login.mjs | thedesignagent-auth login http://localhost:3000');
30
+ }
31
+ function fail(reason, detail) {
32
+ console.error('');
33
+ console.error(`✗ ${reason}`);
34
+ for (const line of detail ?? [])
35
+ console.error(` ${line}`);
36
+ console.log(`TDA_AUTH_FAILED ${reason}`);
37
+ process.exit(1);
8
38
  }
9
39
  async function snapshotSession(browser, page, hostname) {
10
40
  try {
@@ -19,144 +49,249 @@ async function snapshotSession(browser, page, hostname) {
19
49
  httpOnly: c.httpOnly,
20
50
  secure: c.secure,
21
51
  sameSite: (c.sameSite === 'Strict' || c.sameSite === 'Lax' || c.sameSite === 'None') ? c.sameSite : undefined,
52
+ ...(c.expires > 0 && { expires: c.expires }),
22
53
  }));
54
+ // Storage is unreadable on error pages (opaque origin): keep the cookies anyway.
23
55
  const storage = await page.evaluate(() => ({
24
56
  localStorage: Object.fromEntries(Object.keys(localStorage).map(k => [k, localStorage.getItem(k) ?? ''])),
25
57
  sessionStorage: Object.fromEntries(Object.keys(sessionStorage).map(k => [k, sessionStorage.getItem(k) ?? ''])),
26
- }));
27
- return {
28
- cookies,
29
- localStorage: storage.localStorage,
30
- sessionStorage: storage.sessionStorage,
31
- finalUrl: page.url(),
32
- };
58
+ })).catch(() => ({ localStorage: {}, sessionStorage: {} }));
59
+ return { cookies, localStorage: storage.localStorage, sessionStorage: storage.sessionStorage, finalUrl: page.url() };
33
60
  }
34
61
  catch {
35
62
  return null;
36
63
  }
37
64
  }
38
- function waitForEnter() {
39
- return new Promise(resolve => {
40
- if (!process.stdin.isTTY) {
41
- // No TTY (e.g. backgrounded by an agent) — never resolves. Browser-close will trigger instead.
42
- return;
43
- }
44
- const rl = createInterface({ input: process.stdin, output: process.stdout });
45
- rl.once('line', () => { rl.close(); resolve('enter'); });
46
- });
47
- }
48
- function waitForBrowserClose(browser) {
49
- return new Promise(resolve => {
50
- browser.on('disconnected', () => resolve('closed'));
51
- });
52
- }
53
- // Post-trigger verification: if the browser is still alive after Enter was
54
- // pressed, navigate back to the original URL and re-snapshot. Catches "user
55
- // pressed Enter too early" — the partial session would otherwise be saved
56
- // silently and fail on every subsequent visual call.
57
- async function verifyAndSnapshot(browser, page, url, trigger, fallback) {
58
- if (trigger !== 'enter' || !browser.connected)
59
- return fallback;
65
+ // Navigate back to the app URL and re-snapshot, so a session is only saved
66
+ // once the app itself accepts it (catches "confirmed before login finished").
67
+ async function verifySnapshot(browser, page, url) {
60
68
  try {
61
- console.log('Verifying session…');
62
- await page.goto(url.href, { waitUntil: 'networkidle0', timeout: 15000 });
63
- const snap = await snapshotSession(browser, page, url.hostname);
64
- return snap ?? fallback;
69
+ await page.goto(url.href, { waitUntil: 'networkidle2', timeout: 20000 });
70
+ return await snapshotSession(browser, page, url.hostname);
65
71
  }
66
72
  catch {
67
- return fallback;
73
+ return null;
68
74
  }
69
75
  }
70
- async function main() {
71
- const [, , subcommand, urlArg] = process.argv;
72
- if (subcommand !== 'capture' || !urlArg) {
73
- printUsage();
74
- process.exit(1);
75
- }
76
- let url;
77
- try {
78
- url = new URL(urlArg);
79
- }
80
- catch {
81
- console.error(`Invalid URL: ${urlArg}`);
82
- process.exit(1);
76
+ function saveSnapshot(url, snap, how) {
77
+ if (isLoginUrl(snap.finalUrl)) {
78
+ fail('session not accepted', [
79
+ `Opening ${url.href} still redirected to a login page: ${snap.finalUrl}`,
80
+ 'Login may not have finished (a second factor or enrolment step still pending?).',
81
+ ]);
83
82
  }
83
+ const seed = {
84
+ cookies: snap.cookies,
85
+ ...(Object.keys(snap.localStorage).length && { localStorage: snap.localStorage }),
86
+ ...(Object.keys(snap.sessionStorage).length && { sessionStorage: snap.sessionStorage }),
87
+ };
88
+ const savedPath = saveStoredAuth(url.href, seed);
89
+ console.log('');
90
+ console.log(`✓ Logged in (${how}); ${snap.cookies.length} cookies, ${Object.keys(snap.localStorage).length} localStorage entries`);
91
+ console.log(`✓ Saved to ${savedPath}. Visual now uses it for every page on ${url.origin}.`);
92
+ console.log(`TDA_AUTH_SAVED ${url.origin}`);
93
+ process.exit(0);
94
+ }
95
+ async function launch(opts) {
84
96
  const chromePath = findChromeExecutable();
85
- if (!chromePath) {
86
- console.error('Chrome/Chromium not found. Install Google Chrome (or set CHROME_PATH).');
87
- process.exit(1);
88
- }
97
+ if (!chromePath)
98
+ fail('no browser found', ['Install Google Chrome (or Edge, Brave, Chromium), or set CHROME_PATH.']);
89
99
  const puppeteer = (await import('puppeteer-core')).default;
90
- console.log(`▌TheDesignAgent▐ auth capture`);
91
- console.log(`Opening ${url.href} in a browser window…`);
92
- const browser = await puppeteer.launch({
100
+ return puppeteer.launch({
93
101
  executablePath: chromePath,
94
- headless: false,
102
+ headless: opts.headless,
95
103
  defaultViewport: { width: 1440, height: 900 },
104
+ // A dedicated profile keeps "remember me" and SSO sessions between
105
+ // captures, so re-capturing after expiry is often instant.
106
+ ...(opts.persistent && { userDataDir: BROWSER_PROFILE_DIR }),
96
107
  args: ['--no-sandbox', '--disable-setuid-sandbox'],
97
108
  });
109
+ }
110
+ // ── capture ──────────────────────────────────────────────────────────────────
111
+ function waitForEnter() {
112
+ return new Promise(resolve => {
113
+ if (!process.stdin.isTTY)
114
+ return; // backgrounded by an agent: rely on auto-detect / close
115
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
116
+ rl.once('line', () => { rl.close(); resolve('enter'); });
117
+ });
118
+ }
119
+ async function capture(url) {
120
+ const hadSavedSession = existsSync(authFilePath(url.href));
121
+ const browser = await launch({ headless: process.env.TDA_CAPTURE_HEADLESS === '1', persistent: true });
98
122
  const [page] = await browser.pages();
99
- await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 30000 });
100
- // Continuously snapshot the session as the user navigates, so we always have
101
- // a recent capture even if the browser is closed before they confirm.
123
+ await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => { });
124
+ console.log('▌TheDesignAgent▐ login capture');
125
+ console.log(`→ A browser window opened at ${url.href}. Log in to your app there.`);
126
+ console.log('→ It saves and closes by itself once you reach a logged-in page.');
127
+ if (process.stdin.isTTY)
128
+ console.log(' (Or press Enter here, or close the window, when you are done.)');
129
+ console.log('');
130
+ // Logged in = we saw a login page on this origin and have now left it (the
131
+ // page is back on the app, off any login path) for two polls in a row.
132
+ // If the dedicated profile was already logged in and the app loads straight
133
+ // away, that counts too when a session was saved before (a re-capture).
134
+ let sawLogin = isLoginUrl(page.url());
135
+ let stableOffLogin = 0;
136
+ const detected = new Promise(resolve => {
137
+ const timer = setInterval(() => {
138
+ let current;
139
+ try {
140
+ current = new URL(page.url());
141
+ }
142
+ catch {
143
+ return;
144
+ }
145
+ const onApp = current.origin === url.origin;
146
+ if (onApp && isLoginUrl(current.href)) {
147
+ sawLogin = true;
148
+ stableOffLogin = 0;
149
+ return;
150
+ }
151
+ if (onApp && (sawLogin || hadSavedSession))
152
+ stableOffLogin++;
153
+ else
154
+ stableOffLogin = 0;
155
+ if (stableOffLogin >= 2) {
156
+ clearInterval(timer);
157
+ resolve('detected');
158
+ }
159
+ }, POLL_MS);
160
+ browser.on('disconnected', () => clearInterval(timer));
161
+ });
102
162
  let lastSnapshot = null;
103
- const pollInterval = setInterval(async () => {
163
+ const snapTimer = setInterval(async () => {
104
164
  const snap = await snapshotSession(browser, page, url.hostname);
105
165
  if (snap)
106
166
  lastSnapshot = snap;
107
- }, 2000);
108
- const interactiveHint = process.stdin.isTTY
109
- ? '→ When you\'re logged in, press Enter in your terminal OR close the browser window.'
110
- : '→ When you\'re logged in, close the browser window. (No TTY detected.)';
111
- console.log('');
112
- console.log('→ Log in to your app in the browser window that just opened.');
113
- console.log(interactiveHint);
114
- console.log('');
115
- const trigger = await Promise.race([waitForEnter(), waitForBrowserClose(browser)]);
116
- clearInterval(pollInterval);
117
- const verifiedSnapshot = await verifyAndSnapshot(browser, page, url, trigger, lastSnapshot);
118
- if (trigger !== 'closed') {
167
+ }, POLL_MS);
168
+ const closed = new Promise(resolve => browser.on('disconnected', () => resolve('closed')));
169
+ const timedOut = new Promise(resolve => setTimeout(() => resolve('timeout'), CAPTURE_TIMEOUT_MS).unref());
170
+ const trigger = await Promise.race([detected, waitForEnter(), closed, timedOut]);
171
+ clearInterval(snapTimer);
172
+ if (trigger === 'timeout') {
119
173
  try {
120
174
  await browser.close();
121
175
  }
122
- catch { /* already closing */ }
176
+ catch { }
177
+ fail('timed out waiting for login', [`No login completed within ${Math.round(CAPTURE_TIMEOUT_MS / 60000)} minutes. Run it again when ready.`]);
123
178
  }
124
- if (!verifiedSnapshot) {
125
- console.error('');
126
- console.error('✗ No session captured — browser may have closed before login completed.');
127
- console.error(' Re-run the command and log in before closing the window.');
128
- process.exit(1);
179
+ const snap = trigger === 'closed' ? lastSnapshot : (await verifySnapshot(browser, page, url)) ?? lastSnapshot;
180
+ try {
181
+ await browser.close();
182
+ }
183
+ catch { }
184
+ if (!snap)
185
+ fail('no session captured', ['The window closed before a logged-in page loaded. Run it again.']);
186
+ saveSnapshot(url, snap, trigger === 'detected' ? 'detected automatically' : trigger === 'enter' ? 'Enter pressed' : 'window closed');
187
+ }
188
+ // ── login (from a recipe) ────────────────────────────────────────────────────
189
+ // Stream stdin: readFileSync(0) throws EAGAIN on a pipe whose writer hasn't
190
+ // finished yet (e.g. a recipe still waiting on a network call).
191
+ function readStdin() {
192
+ return new Promise((resolve, reject) => {
193
+ let data = '';
194
+ process.stdin.setEncoding('utf8');
195
+ process.stdin.on('data', chunk => { data += chunk; });
196
+ process.stdin.on('end', () => resolve(data));
197
+ process.stdin.on('error', reject);
198
+ });
199
+ }
200
+ async function login(url) {
201
+ if (process.stdin.isTTY)
202
+ fail('nothing on stdin', ['Pipe a login recipe into this command, e.g. node .thedesignagent-login.mjs | thedesignagent-auth login ' + url.origin]);
203
+ const input = (await readStdin()).trim();
204
+ if (!input)
205
+ fail('empty input', ['The login recipe printed nothing.']);
206
+ let seed = null;
207
+ let loginUrl = null;
208
+ if (input.startsWith('{')) {
209
+ try {
210
+ seed = JSON.parse(input);
211
+ }
212
+ catch {
213
+ fail('recipe output is not valid JSON');
214
+ }
215
+ }
216
+ else {
217
+ loginUrl = input.split('\n')[0].trim();
218
+ try {
219
+ new URL(loginUrl);
220
+ }
221
+ catch {
222
+ fail('recipe output is neither a URL nor JSON');
223
+ }
129
224
  }
130
- if (isLoginUrl(verifiedSnapshot.finalUrl)) {
131
- console.error('');
132
- console.error('✗ Session not saved — after capture, navigating to the target URL still');
133
- console.error(' redirected to a login page. This usually means:');
134
- console.error(' • Login was not fully completed (2FA still pending? form not submitted?)');
135
- console.error(' • You confirmed before reaching a logged-in page');
136
- console.error('');
137
- console.error(` Final URL after verification: ${verifiedSnapshot.finalUrl}`);
138
- console.error('');
139
- console.error(' Re-run the command. Make sure you reach a logged-in page in the');
140
- console.error(' browser before pressing Enter or closing the window.');
225
+ const browser = await launch({ headless: true, persistent: false });
226
+ try {
227
+ const page = await browser.newPage();
228
+ if (seed?.cookies?.length) {
229
+ await page.browserContext().setCookie(...seed.cookies.map(c => ({ ...c, domain: c.domain ?? url.hostname, path: c.path ?? '/' })));
230
+ }
231
+ if (seed?.localStorage || seed?.sessionStorage) {
232
+ await page.evaluateOnNewDocument((ls, ss) => {
233
+ try {
234
+ for (const [k, v] of ls)
235
+ localStorage.setItem(k, v);
236
+ for (const [k, v] of ss)
237
+ sessionStorage.setItem(k, v);
238
+ }
239
+ catch { }
240
+ }, Object.entries(seed.localStorage ?? {}), Object.entries(seed.sessionStorage ?? {}));
241
+ }
242
+ if (loginUrl) {
243
+ await page.goto(loginUrl, { waitUntil: 'networkidle2', timeout: 30000 }).catch(() => { });
244
+ }
245
+ const snap = await verifySnapshot(browser, page, url);
246
+ await browser.close();
247
+ if (!snap)
248
+ fail('could not load the app', [`Is the dev server running at ${url.origin}?`]);
249
+ saveSnapshot(url, snap, loginUrl ? 'login URL' : 'recipe cookies');
250
+ }
251
+ finally {
252
+ try {
253
+ await browser.close();
254
+ }
255
+ catch { }
256
+ }
257
+ }
258
+ // ── status ───────────────────────────────────────────────────────────────────
259
+ function status(url) {
260
+ const seed = loadStoredAuth(url.href);
261
+ if (!seed) {
262
+ console.log(`No saved session for ${url.origin}.`);
263
+ console.log(`TDA_AUTH_STATUS none ${url.origin}`);
264
+ return;
265
+ }
266
+ const expiries = (seed.cookies ?? []).map(c => c.expires).filter((e) => typeof e === 'number' && e > 0);
267
+ if (!expiries.length) {
268
+ console.log(`Session saved for ${url.origin} (session cookies, no expiry recorded).`);
269
+ console.log(`TDA_AUTH_STATUS saved ${url.origin}`);
270
+ return;
271
+ }
272
+ const soonest = new Date(Math.min(...expiries) * 1000);
273
+ const expired = soonest.getTime() < Date.now();
274
+ console.log(`Session saved for ${url.origin}; ${expired ? 'expired' : 'expires'} ${soonest.toISOString()}.`);
275
+ console.log(`TDA_AUTH_STATUS ${expired ? 'expired' : 'saved'} ${url.origin} ${soonest.toISOString()}`);
276
+ }
277
+ // ── main ─────────────────────────────────────────────────────────────────────
278
+ async function main() {
279
+ const [, , subcommand, urlArg] = process.argv;
280
+ if (!subcommand || !urlArg || !['capture', 'login', 'status'].includes(subcommand)) {
281
+ printUsage();
141
282
  process.exit(1);
142
283
  }
143
- const seed = {
144
- cookies: verifiedSnapshot.cookies,
145
- ...(Object.keys(verifiedSnapshot.localStorage).length && { localStorage: verifiedSnapshot.localStorage }),
146
- ...(Object.keys(verifiedSnapshot.sessionStorage).length && { sessionStorage: verifiedSnapshot.sessionStorage }),
147
- };
148
- const savedPath = saveStoredAuth(url.href, seed);
149
- console.log('');
150
- console.log(`✓ Trigger: ${trigger === 'closed' ? 'browser closed' : 'Enter pressed'}`);
151
- console.log(`✓ Captured ${verifiedSnapshot.cookies.length} cookies, ${Object.keys(verifiedSnapshot.localStorage).length} localStorage entries, ${Object.keys(verifiedSnapshot.sessionStorage).length} sessionStorage entries`);
152
- console.log(`✓ Final URL: ${verifiedSnapshot.finalUrl}`);
153
- console.log(`✓ Saved to: ${savedPath}`);
154
- console.log('');
155
- console.log(`▌TheDesignAgent▐ will now auto-apply this session for any URL on ${url.origin}.`);
156
- console.log(`To re-capture (after expiry or logout), run this command again.`);
157
- process.exit(0);
284
+ let url;
285
+ try {
286
+ url = new URL(urlArg);
287
+ }
288
+ catch {
289
+ fail(`invalid URL: ${urlArg}`);
290
+ }
291
+ if (subcommand === 'status')
292
+ return status(url);
293
+ if (subcommand === 'login')
294
+ return login(url);
295
+ return capture(url);
158
296
  }
159
- main().catch(err => {
160
- console.error('auth-capture failed:', err instanceof Error ? err.message : err);
161
- process.exit(1);
162
- });
297
+ main().catch(err => fail('auth command crashed', [err instanceof Error ? err.message : String(err)]));
@@ -1,5 +1,6 @@
1
1
  import type { AuthSeed } from './screenshot.js';
2
2
  export declare const AUTH_STORE_DIR: string;
3
+ export declare const BROWSER_PROFILE_DIR: string;
3
4
  export declare function authFilePath(url: string): string;
4
5
  export declare function loadStoredAuth(url: string): AuthSeed | null;
5
6
  export declare function saveStoredAuth(url: string, seed: AuthSeed): string;
@@ -1,7 +1,9 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ // Saved sessions are live login cookies: keep them readable by the owner only.
4
5
  export const AUTH_STORE_DIR = join(homedir(), '.thedesignagent', 'auth');
6
+ export const BROWSER_PROFILE_DIR = join(homedir(), '.thedesignagent', 'browser-profile');
5
7
  function safeFilename(origin) {
6
8
  return origin.replace(/^https?:\/\//, '').replace(/[^a-z0-9.-]/gi, '_') + '.json';
7
9
  }
@@ -20,8 +22,10 @@ export function loadStoredAuth(url) {
20
22
  }
21
23
  }
22
24
  export function saveStoredAuth(url, seed) {
23
- mkdirSync(AUTH_STORE_DIR, { recursive: true });
25
+ mkdirSync(AUTH_STORE_DIR, { recursive: true, mode: 0o700 });
26
+ chmodSync(AUTH_STORE_DIR, 0o700);
24
27
  const path = authFilePath(url);
25
- writeFileSync(path, JSON.stringify(seed, null, 2));
28
+ writeFileSync(path, JSON.stringify(seed, null, 2), { mode: 0o600 });
29
+ chmodSync(path, 0o600);
26
30
  return path;
27
31
  }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};