@pro-vi/designer 0.3.8 → 0.3.10

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
@@ -118,7 +118,7 @@ Writes `tasting.html` with variant tabs + 1/2/3 shortcuts + persistent notes, se
118
118
  ## Operations
119
119
 
120
120
  - `designer doctor` — diagnose setup state. Exits 2 on failure.
121
- - `designer health [--json]` — probe 17 UI anchors. Wire into cron to catch claude.ai UI regressions.
121
+ - `designer health [--json]` — probe every UI anchor designer depends on. Wire into cron to catch claude.ai UI regressions.
122
122
  - **Daily CI** in `.github/workflows/`: `daily-health.yml` runs the auth-required UI probe on a self-hosted macOS runner once per day; `ci.yml` typechecks + builds + does a Docker clean-room install smoke on every PR; `release-please.yml` opens a release PR on conventional commits, merging it tags + publishes via `release-publish.yml`. Selector regressions land as auto-opened PRs under the `selectors-drift` label.
123
123
 
124
124
  ## Known quirks
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // Cross-platform entry: works on macOS/Linux/Windows.
3
+ // Resolves the repo root from this file's location, prefers the compiled
4
+ // dist/cli.js, falls back to tsx+source for dev/clone-and-run mode.
5
+ import { spawnSync } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const BIN_DIR = path.dirname(fs.realpathSync(__filename));
12
+ const REPO_ROOT = path.resolve(BIN_DIR, '..');
13
+
14
+ const DIST_CLI = path.join(REPO_ROOT, 'dist', 'cli.js');
15
+ const SRC_CLI = path.join(REPO_ROOT, 'cli.ts');
16
+ const TSX_BIN = path.join(REPO_ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'tsx.cmd' : 'tsx');
17
+
18
+ const argv = process.argv.slice(2);
19
+
20
+ // Prefer compiled output (npm-installed users + post-build dev). Fall back to
21
+ // tsx-on-source (clone-and-run dev mode, before tsc emits dist/).
22
+ if (fs.existsSync(DIST_CLI)) {
23
+ const r = spawnSync(process.execPath, [DIST_CLI, ...argv], { stdio: 'inherit' });
24
+ process.exit(r.status ?? 1);
25
+ }
26
+
27
+ if (fs.existsSync(TSX_BIN) && fs.existsSync(SRC_CLI)) {
28
+ // shell:true lets Windows resolve the .cmd shim for tsx without a separate code path.
29
+ const r = spawnSync(TSX_BIN, [SRC_CLI, ...argv], { stdio: 'inherit', shell: process.platform === 'win32' });
30
+ process.exit(r.status ?? 1);
31
+ }
32
+
33
+ console.error('[designer] No runnable found.');
34
+ console.error(` Expected ${DIST_CLI} (compiled) or ${TSX_BIN} + ${SRC_CLI} (dev).`);
35
+ console.error(` Run: cd ${REPO_ROOT} && npm install && npm run build`);
36
+ process.exit(1);
package/dist/browser.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spawn } from 'node:child_process';
1
+ import { xspawn } from "./cross-platform.js";
2
2
  const BIN = process.env.DESIGNER_AGENT_BROWSER_BIN || 'agent-browser';
3
3
  const DEFAULT_SESSION = process.env.DESIGNER_SESSION_NAME || 'designer';
4
4
  const CDP = process.env.DESIGNER_CDP ?? '9222';
@@ -12,14 +12,15 @@ export function createBrowser({ session = DEFAULT_SESSION, headed = true, timeou
12
12
  function connectFlags() {
13
13
  if (!cdp)
14
14
  return [];
15
+ const scope = ['--session', `designer-cdp-${cdp.replace(/[^a-zA-Z0-9.-]/g, '_')}`];
15
16
  if (cdp === 'auto' || cdp === '1' || cdp === 'true')
16
- return ['--auto-connect'];
17
- return ['--cdp', cdp];
17
+ return [...scope, '--auto-connect'];
18
+ return [...scope, '--cdp', cdp];
18
19
  }
19
20
  function run(args, { input, parseJson = false } = {}) {
20
21
  return new Promise((resolve, reject) => {
21
22
  const finalArgs = [...connectFlags(), ...args];
22
- const child = spawn(BIN, finalArgs, { env: baseEnv, stdio: ['pipe', 'pipe', 'pipe'] });
23
+ const child = xspawn(BIN, finalArgs, { env: baseEnv, stdio: ['pipe', 'pipe', 'pipe'] });
23
24
  let stdout = '';
24
25
  let stderr = '';
25
26
  child.stdout.on('data', (d) => (stdout += d.toString()));
@@ -64,6 +65,15 @@ export function createBrowser({ session = DEFAULT_SESSION, headed = true, timeou
64
65
  activateTab: async (index) => {
65
66
  await run(['tab', String(index)]);
66
67
  },
68
+ reload: () => run(['reload']),
69
+ cookies: async () => {
70
+ const out = await run(['cookies', 'get', '--json']);
71
+ const env = JSON.parse(out);
72
+ if (env.success === false) {
73
+ throw new Error(`agent-browser cookies get failed: ${JSON.stringify(env.error)}`);
74
+ }
75
+ return env.data?.cookies ?? [];
76
+ },
67
77
  snapshot: ({ interactive = true, scope } = {}) => {
68
78
  const args = ['snapshot', '--json'];
69
79
  if (interactive)
@@ -98,9 +108,9 @@ export function createBrowser({ session = DEFAULT_SESSION, headed = true, timeou
98
108
  args.push('--full');
99
109
  return run(args);
100
110
  },
101
- eval: (js) => run(['eval', js]),
111
+ eval: (js) => run(['eval', '--stdin'], { input: js }),
102
112
  evalValue: async (js) => {
103
- const out = await run(['eval', js]);
113
+ const out = await run(['eval', '--stdin'], { input: js });
104
114
  try {
105
115
  return JSON.parse(out);
106
116
  }
@@ -1,10 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { spawn, spawnSync } from 'node:child_process';
4
+ import { spawn } from 'node:child_process';
5
+ import { defaultChromeBin, isChromeRunning, QUIT_CHROME_HINT } from "./cross-platform.js";
5
6
  const PORT = process.env.DESIGNER_CDP || '9222';
6
7
  const PROFILE = path.join(os.homedir(), '.chrome-designer-profile');
7
- const CHROME_BIN = process.env.CHROME_BIN || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
8
+ const CHROME_BIN = process.env.CHROME_BIN || defaultChromeBin();
8
9
  async function isCdpUp() {
9
10
  try {
10
11
  const res = await fetch(`http://127.0.0.1:${PORT}/json/version`, { signal: AbortSignal.timeout(1500) });
@@ -14,10 +15,6 @@ async function isCdpUp() {
14
15
  return false;
15
16
  }
16
17
  }
17
- function chromeRunning() {
18
- const r = spawnSync('pgrep', ['-f', 'Google Chrome.app/Contents/MacOS/Google Chrome'], { stdio: 'pipe' });
19
- return r.status === 0 && (r.stdout?.toString().trim().length ?? 0) > 0;
20
- }
21
18
  function sleep(ms) {
22
19
  return new Promise((r) => setTimeout(r, ms));
23
20
  }
@@ -27,8 +24,8 @@ export async function ensureCdpUp() {
27
24
  if (!fs.existsSync(PROFILE)) {
28
25
  throw new Error(`CDP not up on :${PORT} and no dedicated Chrome profile at ${PROFILE}. Run: designer setup`);
29
26
  }
30
- if (chromeRunning()) {
31
- throw new Error(`CDP not up on :${PORT} and a non-debug Chrome is already running. Quit Chrome (Cmd+Q) and retry, or run: designer setup`);
27
+ if (isChromeRunning()) {
28
+ throw new Error(`CDP not up on :${PORT} and a non-debug Chrome is already running. ${QUIT_CHROME_HINT} Then retry, or run: designer setup`);
32
29
  }
33
30
  if (!fs.existsSync(CHROME_BIN)) {
34
31
  throw new Error(`CDP not up on :${PORT} and Chrome not found at ${CHROME_BIN}. Set CHROME_BIN or install Chrome.`);
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env -S node --import tsx
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { spawn, spawnSync } from 'node:child_process';
5
4
  import { createHash } from 'node:crypto';
5
+ import { xspawn, xspawnSync, WHICH, IS_WIN } from "./cross-platform.js";
6
6
  import { DesignerController } from "./designer-controller.js";
7
7
  import { listSessions, getSession } from "./session-store.js";
8
8
  import { createBrowser } from "./browser.js";
@@ -12,6 +12,7 @@ import { runSetup } from "./setup.js";
12
12
  import { startMcpServer } from "./mcp-server.js";
13
13
  import { REPO_ROOT } from "./repo-root.js";
14
14
  import { runHealth } from "./ui-anchors.js";
15
+ import { PACKAGE_VERSION } from "./package-meta.js";
15
16
  const [, , cmd, ...rest] = process.argv;
16
17
  function parseFlags(args) {
17
18
  const out = { _: [] };
@@ -37,6 +38,10 @@ function parseFlags(args) {
37
38
  const flags = parseFlags(rest);
38
39
  const key = flags.key || 'default';
39
40
  async function main() {
41
+ if (cmd === '--version' || cmd === '-v' || cmd === 'version' || flags.version === true || flags.v === true) {
42
+ console.log(PACKAGE_VERSION);
43
+ return;
44
+ }
40
45
  if (flags.help === true || flags.h === true) {
41
46
  if (cmd && HELP[cmd]) {
42
47
  console.log(HELP[cmd]);
@@ -533,7 +538,7 @@ function checkDeps() {
533
538
  }
534
539
  async function checkAgentBrowser() {
535
540
  return new Promise((resolve) => {
536
- const c = spawn('agent-browser', ['--version'], { stdio: 'pipe' });
541
+ const c = xspawn('agent-browser', ['--version'], { stdio: 'pipe' });
537
542
  let v = '';
538
543
  c.stdout.on('data', (d) => (v += d.toString()));
539
544
  c.on('error', () => resolve({ name: 'agent-browser installed', status: 'fail', detail: 'binary not found on PATH; install from https://github.com/agent-browser/agent-browser' }));
@@ -553,7 +558,7 @@ async function checkCdp() {
553
558
  return {
554
559
  name: `CDP at port ${port}`,
555
560
  status: 'fail',
556
- detail: `not reachable. Run: ./scripts/designer-chrome.sh (launches Chrome with --remote-debugging-port=${port} in a dedicated profile)`
561
+ detail: `not reachable. Run: ${IS_WIN ? 'powershell scripts\\designer-chrome.ps1' : './scripts/designer-chrome.sh'} (launches Chrome with --remote-debugging-port=${port} in a dedicated profile)`
557
562
  };
558
563
  }
559
564
  }
@@ -619,11 +624,11 @@ function checkSkillInstalled() {
619
624
  return { name: 'designer-loop skill installed', status: 'ok', detail: skillDir };
620
625
  }
621
626
  async function checkMcpRegistered() {
622
- const which = spawnSync('which', ['claude'], { stdio: 'pipe' });
627
+ const which = xspawnSync(WHICH, ['claude'], { stdio: 'pipe' });
623
628
  if (which.status !== 0) {
624
629
  return { name: 'MCP registered with Claude Code', status: 'warn', detail: 'claude CLI not on PATH; install Claude Code to verify' };
625
630
  }
626
- const list = spawnSync('claude', ['mcp', 'list'], { stdio: 'pipe' });
631
+ const list = xspawnSync('claude', ['mcp', 'list'], { stdio: 'pipe' });
627
632
  if (list.status !== 0) {
628
633
  return { name: 'MCP registered with Claude Code', status: 'fail', detail: `\`claude mcp list\` exited ${list.status}` };
629
634
  }
@@ -0,0 +1,50 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from 'node:child_process';
4
+ import crossSpawn from 'cross-spawn';
5
+ export const IS_WIN = process.platform === 'win32';
6
+ export const IS_MAC = process.platform === 'darwin';
7
+ export const xspawn = crossSpawn;
8
+ export const xspawnSync = crossSpawn.sync;
9
+ export const WHICH = IS_WIN ? 'where' : 'which';
10
+ export function defaultChromeBin() {
11
+ if (IS_WIN) {
12
+ const candidates = [
13
+ path.join(process.env['ProgramFiles'] || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
14
+ path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
15
+ path.join(process.env['LOCALAPPDATA'] || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
16
+ ];
17
+ for (const c of candidates)
18
+ if (c && fs.existsSync(c))
19
+ return c;
20
+ return candidates[0] ?? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
21
+ }
22
+ if (IS_MAC)
23
+ return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
24
+ for (const c of ['/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser']) {
25
+ if (fs.existsSync(c))
26
+ return c;
27
+ }
28
+ return '/usr/bin/google-chrome';
29
+ }
30
+ export function isChromeRunning() {
31
+ if (IS_WIN) {
32
+ const r = nodeSpawnSync('tasklist', ['/FI', 'IMAGENAME eq chrome.exe', '/NH', '/FO', 'CSV'], { stdio: 'pipe' });
33
+ if (r.status !== 0)
34
+ return false;
35
+ const out = r.stdout?.toString() || '';
36
+ return out.toLowerCase().includes('chrome.exe');
37
+ }
38
+ if (IS_MAC) {
39
+ const r = nodeSpawnSync('pgrep', ['-f', 'Google Chrome.app/Contents/MacOS/Google Chrome'], { stdio: 'pipe' });
40
+ return r.status === 0 && (r.stdout?.toString().trim().length ?? 0) > 0;
41
+ }
42
+ const r = nodeSpawnSync('pgrep', ['-f', 'chrome'], { stdio: 'pipe' });
43
+ return r.status === 0 && (r.stdout?.toString().trim().length ?? 0) > 0;
44
+ }
45
+ export const QUIT_CHROME_HINT = IS_WIN
46
+ ? 'Close all Chrome windows (or end chrome.exe in Task Manager).'
47
+ : IS_MAC
48
+ ? 'Cmd+Q on the Chrome menu, then close Activity Monitor entries if any.'
49
+ : 'Close all Chrome windows or `pkill chrome`.';
50
+ export { nodeSpawn, nodeSpawnSync };
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import crypto from 'node:crypto';
5
- import { spawn } from 'node:child_process';
5
+ import { xspawn } from "./cross-platform.js";
6
6
  import { createBrowser } from "./browser.js";
7
7
  import { sessionDir, saveIteration } from "./artifact-store.js";
8
8
  import { upsertSession, appendHistory, getSession } from "./session-store.js";
@@ -574,7 +574,7 @@ export class DesignerController {
574
574
  const buf = Buffer.from(await res.arrayBuffer());
575
575
  fs.writeFileSync(tgzPath, buf);
576
576
  await new Promise((resolve, reject) => {
577
- const child = spawn('tar', ['-xzf', tgzPath, '-C', bundleDir], { stdio: 'pipe' });
577
+ const child = xspawn('tar', ['-xzf', tgzPath, '-C', bundleDir], { stdio: 'pipe' });
578
578
  let err = '';
579
579
  child.stderr.on('data', (d) => (err += d.toString()));
580
580
  child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`tar exited ${code}: ${err}`)));
@@ -7,7 +7,8 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
7
7
  import { z } from 'zod';
8
8
  import { DesignerController } from "./designer-controller.js";
9
9
  import { sessionDir } from "./artifact-store.js";
10
- const server = new McpServer({ name: 'designer', version: '0.3.0' });
10
+ import { PACKAGE_VERSION } from "./package-meta.js";
11
+ const server = new McpServer({ name: 'designer', version: PACKAGE_VERSION });
11
12
  const controllers = new Map();
12
13
  function getController(key) {
13
14
  const k = key || 'default';
@@ -0,0 +1,12 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { REPO_ROOT } from "./repo-root.js";
4
+ function readPackageMetadata() {
5
+ const raw = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8'));
6
+ return {
7
+ name: typeof raw.name === 'string' ? raw.name : 'designer',
8
+ version: typeof raw.version === 'string' ? raw.version : '0.0.0'
9
+ };
10
+ }
11
+ export const PACKAGE_METADATA = readPackageMetadata();
12
+ export const PACKAGE_VERSION = PACKAGE_METADATA.version;
package/dist/repo-root.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  function findRepoRoot() {
4
- let dir = path.dirname(new URL(import.meta.url).pathname);
5
+ let dir = path.dirname(fileURLToPath(import.meta.url));
5
6
  for (let i = 0; i < 8; i++) {
6
7
  if (fs.existsSync(path.join(dir, 'package.json')))
7
8
  return dir;
@@ -10,6 +11,6 @@ function findRepoRoot() {
10
11
  break;
11
12
  dir = parent;
12
13
  }
13
- throw new Error('repo-root: could not find package.json walking up from ' + new URL(import.meta.url).pathname);
14
+ throw new Error('repo-root: could not find package.json walking up from ' + fileURLToPath(import.meta.url));
14
15
  }
15
16
  export const REPO_ROOT = findRepoRoot();
package/dist/setup.js CHANGED
@@ -1,14 +1,15 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
- import { spawn, spawnSync } from 'node:child_process';
4
+ import { spawn } from 'node:child_process';
5
5
  import { createHash } from 'node:crypto';
6
6
  import { REPO_ROOT } from "./repo-root.js";
7
+ import { defaultChromeBin, isChromeRunning, xspawnSync, WHICH, IS_WIN, QUIT_CHROME_HINT } from "./cross-platform.js";
7
8
  import { createBrowser } from "./browser.js";
8
9
  const SKILL_SRC = path.join(REPO_ROOT, 'skills', 'designer-loop', 'SKILL.md');
9
10
  const SKILL_DEST_DIR = path.join(os.homedir(), '.claude', 'skills', 'designer-loop');
10
11
  const SKILL_DEST = path.join(SKILL_DEST_DIR, 'SKILL.md');
11
- const CHROME_BIN = process.env.CHROME_BIN || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
12
+ const CHROME_BIN = process.env.CHROME_BIN || defaultChromeBin();
12
13
  const DEFAULT_PORT = process.env.DESIGNER_CDP || '9222';
13
14
  const PROFILE = path.join(os.homedir(), '.chrome-designer-profile');
14
15
  function log(stage, status, msg) {
@@ -32,13 +33,14 @@ async function verifySignedIn(browser) {
32
33
  return browser.evalValue(js).catch(() => false);
33
34
  }
34
35
  function chromeRunning() {
35
- const r = spawnSync('pgrep', ['-f', 'Google Chrome.app/Contents/MacOS/Google Chrome'], { stdio: 'pipe' });
36
- return r.status === 0 && (r.stdout?.toString().trim().length ?? 0) > 0;
36
+ return isChromeRunning();
37
37
  }
38
38
  function cdpChromeProfileStatus(port) {
39
39
  if (!/^\d+$/.test(port))
40
40
  return 'unknown';
41
- const r = spawnSync('sh', ['-c', `ps -Axww -o command | grep -- '--remote-debugging-port=${port}' | grep -v grep`], { stdio: 'pipe' });
41
+ if (IS_WIN)
42
+ return 'unknown';
43
+ const r = xspawnSync('sh', ['-c', `ps -Axww -o command | grep -- '--remote-debugging-port=${port}' | grep -v grep`], { stdio: 'pipe' });
42
44
  if (r.status !== 0)
43
45
  return 'unknown';
44
46
  const out = r.stdout?.toString() ?? '';
@@ -107,7 +109,7 @@ async function step1NpmInstall() {
107
109
  else {
108
110
  log('deps', 'wait', 'running npm install...');
109
111
  }
110
- const r = spawnSync('npm', ['install'], { cwd: REPO_ROOT, stdio: 'inherit' });
112
+ const r = xspawnSync('npm', ['install'], { cwd: REPO_ROOT, stdio: 'inherit' });
111
113
  if (r.status !== 0) {
112
114
  log('deps', 'fail', `npm install exited ${r.status}`);
113
115
  return false;
@@ -116,9 +118,9 @@ async function step1NpmInstall() {
116
118
  return true;
117
119
  }
118
120
  function step2AgentBrowser() {
119
- const r = spawnSync('agent-browser', ['--version'], { stdio: 'pipe' });
121
+ const r = xspawnSync('agent-browser', ['--version'], { stdio: 'pipe' });
120
122
  if (r.status !== 0) {
121
- log('agent-browser', 'fail', 'not found on PATH. Install: brew install agent-browser OR npm i -g agent-browser');
123
+ log('agent-browser', 'fail', 'not found on PATH. Install: npm i -g agent-browser');
122
124
  return false;
123
125
  }
124
126
  log('agent-browser', 'ok', r.stdout?.toString().trim() || 'present');
@@ -144,11 +146,11 @@ async function step3Chrome(port) {
144
146
  }
145
147
  }
146
148
  if (chromeRunning()) {
147
- log('chrome', 'wait', 'A non-debug Chrome is running. Quit it (Cmd+Q on the Chrome menu, then close Activity Monitor entries if any). I am polling.');
149
+ log('chrome', 'wait', `A non-debug Chrome is running. ${QUIT_CHROME_HINT} I am polling.`);
148
150
  const quit = await pollUntil('chrome', () => !chromeRunning(), {
149
151
  intervalMs: 1000,
150
152
  timeoutMs: 5 * 60_000,
151
- reminder: 'Still waiting for Chrome to fully quit. Cmd+Q on Chrome.'
153
+ reminder: `Still waiting for Chrome to fully quit. ${QUIT_CHROME_HINT}`
152
154
  });
153
155
  if (!quit) {
154
156
  log('chrome', 'fail', 'Timed out waiting for Chrome to quit. Quit manually then re-run setup.');
@@ -160,7 +162,14 @@ async function step3Chrome(port) {
160
162
  log('chrome', 'fail', `Chrome not found at ${CHROME_BIN}. Set CHROME_BIN to override.`);
161
163
  return false;
162
164
  }
163
- const child = spawn(CHROME_BIN, ['--remote-debugging-port=' + port, '--user-data-dir=' + PROFILE, 'https://claude.ai/design'], {
165
+ const child = spawn(CHROME_BIN, [
166
+ '--remote-debugging-port=' + port,
167
+ '--user-data-dir=' + PROFILE,
168
+ '--no-first-run',
169
+ '--no-default-browser-check',
170
+ '--disable-search-engine-choice-screen',
171
+ 'https://claude.ai/design'
172
+ ], {
164
173
  detached: true,
165
174
  stdio: 'ignore'
166
175
  });
@@ -171,7 +180,8 @@ async function step3Chrome(port) {
171
180
  reminder: `Waiting for CDP at :${port}...`
172
181
  });
173
182
  if (!up) {
174
- log('chrome', 'fail', 'Chrome launched but CDP did not come up. Try `./scripts/designer-chrome.sh` manually.');
183
+ const fallback = IS_WIN ? 'scripts\\designer-chrome.ps1' : './scripts/designer-chrome.sh';
184
+ log('chrome', 'fail', `Chrome launched but CDP did not come up. Try \`${fallback}\` manually.`);
175
185
  return false;
176
186
  }
177
187
  log('chrome', 'ok', `CDP up on :${port}`);
@@ -181,7 +191,28 @@ async function step4SignIn(port) {
181
191
  const browser = createBrowser({ session: 'designer-setup', cdp: port });
182
192
  await browser.open('https://claude.ai/design').catch(() => undefined);
183
193
  await sleep(2500);
184
- const ok = await pollUntil('login', () => verifySignedIn(browser), {
194
+ const MAX_RECOVERY_NAVS = 4;
195
+ const hasAuthCookie = async () => {
196
+ try {
197
+ return (await browser.cookies()).some((c) => /^sessionKey/.test(c.name) && /claude\.ai$/.test(c.domain) && c.value.length > 20);
198
+ }
199
+ catch {
200
+ return false;
201
+ }
202
+ };
203
+ let recoveryNavs = 0;
204
+ const checkSignedIn = async () => {
205
+ if (await verifySignedIn(browser))
206
+ return true;
207
+ if (recoveryNavs < MAX_RECOVERY_NAVS && (await hasAuthCookie())) {
208
+ recoveryNavs++;
209
+ await browser.open('https://claude.ai/design').catch(() => undefined);
210
+ await sleep(3000);
211
+ return verifySignedIn(browser);
212
+ }
213
+ return false;
214
+ };
215
+ const ok = await pollUntil('login', checkSignedIn, {
185
216
  intervalMs: 2000,
186
217
  timeoutMs: 10 * 60_000,
187
218
  reminder: 'Sign in to Claude in the DEBUG Chrome window I just opened (a separate window with no extensions/bookmarks — NOT your normal Chrome; the two have separate cookie jars). Then return to claude.ai/design. I am polling.',
@@ -189,6 +220,9 @@ async function step4SignIn(port) {
189
220
  });
190
221
  if (!ok) {
191
222
  log('login', 'fail', 'Timed out waiting for a signed-in claude.ai/design session. Re-run setup when ready.');
223
+ const watched = await browser.url().catch(() => '(unreachable)');
224
+ const authCookie = await hasAuthCookie();
225
+ log('login', 'fail', `Watched tab: ${watched} | Claude auth cookie: ${authCookie ? 'present — login succeeded but the tab stayed stale; re-run: designer setup' : 'absent (or watched tab is off claude.ai origin) — see watched URL above'}`);
192
226
  return false;
193
227
  }
194
228
  const url = (await browser.url().catch(() => '')) || 'claude.ai/design';
@@ -217,25 +251,21 @@ function step5Skill() {
217
251
  return true;
218
252
  }
219
253
  function step6Mcp(port) {
220
- const claudeBin = spawnSync('which', ['claude'], { stdio: 'pipe' });
254
+ const claudeBin = xspawnSync(WHICH, ['claude'], { stdio: 'pipe' });
221
255
  if (claudeBin.status !== 0) {
222
256
  log('mcp', 'wait', 'claude CLI not on PATH; skipping MCP registration. Install Claude Code to register.');
223
257
  return true;
224
258
  }
225
- const list = spawnSync('claude', ['mcp', 'list'], { stdio: 'pipe' });
259
+ const list = xspawnSync('claude', ['mcp', 'list'], { stdio: 'pipe' });
226
260
  const stdout = list.stdout?.toString() || '';
227
261
  if (/(\s|^)designer\b/i.test(stdout)) {
228
262
  log('mcp', 'ok', 'Already registered.');
229
263
  return true;
230
264
  }
231
- const wrapper = path.join(REPO_ROOT, 'bin', 'designer');
232
- if (!fs.existsSync(wrapper)) {
233
- log('mcp', 'fail', `Missing wrapper ${wrapper}`);
234
- return false;
235
- }
236
- const cmd = ['mcp', 'add', '--scope', 'user', '--transport', 'stdio', 'designer', '--', 'env', `DESIGNER_CDP=${port}`, wrapper, 'mcp', 'serve'];
265
+ const envFlags = port === '9222' ? [] : ['-e', `DESIGNER_CDP=${port}`];
266
+ const cmd = ['mcp', 'add', '--scope', 'user', '--transport', 'stdio', ...envFlags, 'designer', '--', 'designer', 'mcp', 'serve'];
237
267
  log('mcp', 'wait', `Registering: claude ${cmd.join(' ')}`);
238
- const reg = spawnSync('claude', cmd, { stdio: 'inherit' });
268
+ const reg = xspawnSync('claude', cmd, { stdio: 'inherit' });
239
269
  if (reg.status !== 0) {
240
270
  log('mcp', 'fail', `claude mcp add exited ${reg.status}. Run manually:\n claude ${cmd.join(' ')}`);
241
271
  return false;
package/dist/tasting.js CHANGED
@@ -1,6 +1,27 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
+ import { IS_WIN, IS_MAC, xspawn, xspawnSync } from "./cross-platform.js";
5
+ function findPython() {
6
+ const candidates = IS_WIN ? ['py', 'python', 'python3'] : ['python3', 'python'];
7
+ for (const c of candidates) {
8
+ const r = xspawnSync(c, ['--version'], { stdio: 'pipe' });
9
+ if (r.status === 0)
10
+ return c;
11
+ }
12
+ return null;
13
+ }
14
+ function openUrl(url) {
15
+ if (IS_WIN) {
16
+ spawn('cmd', ['/c', 'start', '""', url], { stdio: 'ignore', detached: true }).unref();
17
+ }
18
+ else if (IS_MAC) {
19
+ spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
20
+ }
21
+ else {
22
+ spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
23
+ }
24
+ }
4
25
  export function writeTastingHtml({ projectDir, variants, outPath, title = 'Tasting' }) {
5
26
  const target = outPath || path.join(projectDir, 'tasting.html');
6
27
  const html = renderTastingHtml({ variants, title });
@@ -85,7 +106,11 @@ function escapeHtml(s) {
85
106
  const servers = new Map();
86
107
  export async function serveAndOpen(projectDir, { file = 'tasting.html', port } = {}) {
87
108
  const chosenPort = port || (await pickFreePort(8765));
88
- const child = spawn('python3', ['-m', 'http.server', String(chosenPort)], {
109
+ const python = findPython();
110
+ if (!python) {
111
+ throw new Error('tasting requires Python 3. Install python3 (macOS/Linux) or Python 3 from python.org (Windows).');
112
+ }
113
+ const child = xspawn(python, ['-m', 'http.server', String(chosenPort)], {
89
114
  cwd: projectDir,
90
115
  stdio: 'ignore',
91
116
  detached: true
@@ -93,7 +118,7 @@ export async function serveAndOpen(projectDir, { file = 'tasting.html', port } =
93
118
  child.unref();
94
119
  await sleep(500);
95
120
  const url = `http://127.0.0.1:${chosenPort}/${encodeURI(file)}`;
96
- spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
121
+ openUrl(url);
97
122
  const pid = child.pid ?? -1;
98
123
  servers.set(projectDir, { port: chosenPort, pid });
99
124
  return { url, port: chosenPort, pid };
@@ -9,6 +9,25 @@ async function hasButtonMatching(browser, pattern) {
9
9
  .catch(() => false));
10
10
  }
11
11
  export const UI_ANCHORS = [
12
+ {
13
+ id: 'login.signedIn',
14
+ category: 'pattern',
15
+ description: 'signed in (claude.ai is rendering the app shell, not the login wall)',
16
+ requires: 'any',
17
+ check: async (b, url) => {
18
+ if (/claude\.ai\/login/.test(url)) {
19
+ return { ok: false, detail: `signed out — Chrome is on the login wall (${url.slice(0, 80)}). Run: designer setup` };
20
+ }
21
+ if (/claude\.ai\/design/.test(url)) {
22
+ const signedIn = (await hasSelector(b, '[data-testid="project-creator"]')) ||
23
+ (await hasSelector(b, '[data-testid="chat-composer-input"]'));
24
+ return signedIn
25
+ ? { ok: true }
26
+ : { ok: false, detail: `login wall rendered at ${url.slice(0, 80)} (no app shell) — signed out. Run: designer setup` };
27
+ }
28
+ return { ok: true, detail: `not on a claude.ai/design surface (url=${url.slice(0, 60)}) — sign-in not checked here` };
29
+ }
30
+ },
12
31
  {
13
32
  id: 'home.creator',
14
33
  category: 'home',
@@ -65,6 +84,31 @@ export const UI_ANCHORS = [
65
84
  requires: 'session',
66
85
  check: async (b) => ({ ok: await hasSelector(b, '[data-testid="chat-composer-input"]') })
67
86
  },
87
+ {
88
+ id: 'session.composerFillable',
89
+ category: 'session',
90
+ description: 'composer is fillable (textarea or contenteditable, per _submitPrompt)',
91
+ requires: 'session',
92
+ check: async (b) => {
93
+ const shape = await b
94
+ .evalValue(`(() => {
95
+ const el = document.querySelector('[data-testid="chat-composer-input"]');
96
+ if (!el) return { found: false };
97
+ const fillable = el instanceof HTMLTextAreaElement || el.isContentEditable;
98
+ return { found: true, tag: el.tagName, contentEditable: el.isContentEditable, fillable };
99
+ })()`)
100
+ .catch(() => ({ found: false }));
101
+ if (!shape.found)
102
+ return { ok: false, detail: 'composer not found' };
103
+ if (shape.fillable) {
104
+ return { ok: true, detail: shape.contentEditable ? 'contenteditable' : `<${(shape.tag || '').toLowerCase()}>` };
105
+ }
106
+ return {
107
+ ok: false,
108
+ detail: `composer is <${(shape.tag || '?').toLowerCase()}> — neither textarea nor contenteditable; _submitPrompt cannot fill it (composer shape drifted)`
109
+ };
110
+ }
111
+ },
68
112
  {
69
113
  id: 'session.sendButton',
70
114
  category: 'session',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pro-vi/designer",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "type": "module",
5
5
  "description": "MCP + CLI for autonomous iteration of claude.ai/design — drives the design surface via agent-browser, downloads handoff bundles, and exposes a tasting harness for full-viewport variant comparison.",
6
6
  "license": "MIT",
@@ -23,13 +23,14 @@
23
23
  "anthropic"
24
24
  ],
25
25
  "bin": {
26
- "designer": "./bin/designer"
26
+ "designer": "./bin/designer.mjs"
27
27
  },
28
28
  "scripts": {
29
29
  "mcp": "tsx mcp-server.ts",
30
30
  "cli": "tsx cli.ts",
31
31
  "setup": "tsx cli.ts setup",
32
32
  "doctor": "tsx cli.ts doctor",
33
+ "test": "npm run build && node --test tests/cli-metadata.test.mjs",
33
34
  "check": "tsc --noEmit",
34
35
  "build": "tsc -p tsconfig.build.json",
35
36
  "prepack": "npm run check && npm run build",
@@ -42,9 +43,11 @@
42
43
  "dependencies": {
43
44
  "@anthropic-ai/sdk": "^0.102.0",
44
45
  "@modelcontextprotocol/sdk": "^1.26.0",
46
+ "cross-spawn": "^7.0.6",
45
47
  "zod": "^4.4.3"
46
48
  },
47
49
  "devDependencies": {
50
+ "@types/cross-spawn": "^6.0.6",
48
51
  "@types/node": "^25.6.0",
49
52
  "tsx": "^4.21.0",
50
53
  "typescript": "^6.0.3"
@@ -54,13 +57,15 @@
54
57
  },
55
58
  "os": [
56
59
  "darwin",
57
- "linux"
60
+ "linux",
61
+ "win32"
58
62
  ],
59
63
  "files": [
60
64
  "dist/",
61
65
  "bin/",
62
66
  "skills/",
63
67
  "scripts/designer-chrome.sh",
68
+ "scripts/designer-chrome.ps1",
64
69
  "scripts/postinstall.mjs",
65
70
  "selectors.json",
66
71
  "README.md",
@@ -0,0 +1,50 @@
1
+ # Launch a Chrome instance with remote debugging enabled, in a dedicated
2
+ # user-data-dir so the default profile's debug-port lockdown (Chrome 136+)
3
+ # doesn't block us. Sign in to Claude once inside the launched window;
4
+ # the profile persists.
5
+ #
6
+ # PowerShell equivalent of scripts/designer-chrome.sh for Windows users.
7
+
8
+ $ErrorActionPreference = 'Stop'
9
+
10
+ $Port = if ($env:DESIGNER_CDP) { $env:DESIGNER_CDP } else { '9222' }
11
+ $Profile = Join-Path $env:USERPROFILE '.chrome-designer-profile'
12
+
13
+ # Default Chrome locations on Windows. Override with $env:CHROME_BIN.
14
+ $DefaultChromes = @(
15
+ (Join-Path ${env:ProgramFiles} 'Google\Chrome\Application\chrome.exe'),
16
+ (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'),
17
+ (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe')
18
+ )
19
+ $Chrome = if ($env:CHROME_BIN) { $env:CHROME_BIN } else { $DefaultChromes | Where-Object { Test-Path $_ } | Select-Object -First 1 }
20
+
21
+ if (-not $Chrome -or -not (Test-Path $Chrome)) {
22
+ Write-Error "[designer-chrome] Chrome not found. Set `$env:CHROME_BIN to override."
23
+ exit 1
24
+ }
25
+
26
+ # CDP already listening?
27
+ try {
28
+ $null = Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$Port/json/version" -TimeoutSec 2
29
+ Write-Host "[designer-chrome] CDP already listening on port $Port - nothing to do."
30
+ Write-Host " curl http://127.0.0.1:$Port/json/version"
31
+ exit 0
32
+ } catch {
33
+ # not running - continue
34
+ }
35
+
36
+ # Warn if a non-debug Chrome is up
37
+ if (Get-Process -Name chrome -ErrorAction SilentlyContinue) {
38
+ Write-Warning "[designer-chrome] Chrome is already running."
39
+ Write-Warning " If it's NOT a debug-mode Chrome, the launched window may not get the debug port."
40
+ Write-Warning " Close existing Chrome windows first, or accept the risk and continue."
41
+ }
42
+
43
+ Write-Host "[designer-chrome] Launching: $Chrome --remote-debugging-port=$Port --user-data-dir=$Profile"
44
+ Write-Host "[designer-chrome] Sign in to claude.ai in the new window. Then navigate to https://claude.ai/design."
45
+ Write-Host "[designer-chrome] When done, leave this window open. The CDP server runs as long as Chrome runs."
46
+
47
+ & $Chrome `
48
+ "--remote-debugging-port=$Port" `
49
+ "--user-data-dir=$Profile" `
50
+ "https://claude.ai/design"
package/bin/designer DELETED
@@ -1,35 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Wrapper that resolves the repo's tsx + cli.ts regardless of the user's cwd.
3
- # Lets users type `designer <verb>` (after `npm link` or PATH symlink)
4
- # instead of the verbose `node_modules/.bin/tsx cli.ts <verb>`.
5
-
6
- set -e
7
-
8
- # Resolve symlinks to find the real script location, then back up to repo root.
9
- SOURCE="${BASH_SOURCE[0]}"
10
- while [ -h "$SOURCE" ]; do
11
- DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
12
- SOURCE="$(readlink "$SOURCE")"
13
- [[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE"
14
- done
15
- BIN_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
16
- REPO_ROOT="$(cd "$BIN_DIR/.." && pwd)"
17
-
18
- DIST_CLI="$REPO_ROOT/dist/cli.js"
19
- TSX="$REPO_ROOT/node_modules/.bin/tsx"
20
- SRC_CLI="$REPO_ROOT/cli.ts"
21
-
22
- # Prefer compiled output (npm-installed users + post-build dev). Fall back to
23
- # tsx-on-source (clone-and-run dev mode, before tsc emits dist/).
24
- if [ -f "$DIST_CLI" ]; then
25
- exec node "$DIST_CLI" "$@"
26
- fi
27
-
28
- if [ -x "$TSX" ] && [ -f "$SRC_CLI" ]; then
29
- exec "$TSX" "$SRC_CLI" "$@"
30
- fi
31
-
32
- echo "[designer] No runnable found." >&2
33
- echo " Expected $DIST_CLI (compiled) or $TSX + $SRC_CLI (dev)." >&2
34
- echo " Run: cd $REPO_ROOT && npm install && npm run build" >&2
35
- exit 1