@pro-vi/designer 0.3.7 → 0.3.9

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.
@@ -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';
@@ -19,7 +19,7 @@ export function createBrowser({ session = DEFAULT_SESSION, headed = true, timeou
19
19
  function run(args, { input, parseJson = false } = {}) {
20
20
  return new Promise((resolve, reject) => {
21
21
  const finalArgs = [...connectFlags(), ...args];
22
- const child = spawn(BIN, finalArgs, { env: baseEnv, stdio: ['pipe', 'pipe', 'pipe'] });
22
+ const child = xspawn(BIN, finalArgs, { env: baseEnv, stdio: ['pipe', 'pipe', 'pipe'] });
23
23
  let stdout = '';
24
24
  let stderr = '';
25
25
  child.stdout.on('data', (d) => (stdout += d.toString()));
@@ -98,9 +98,9 @@ export function createBrowser({ session = DEFAULT_SESSION, headed = true, timeou
98
98
  args.push('--full');
99
99
  return run(args);
100
100
  },
101
- eval: (js) => run(['eval', js]),
101
+ eval: (js) => run(['eval', '--stdin'], { input: js }),
102
102
  evalValue: async (js) => {
103
- const out = await run(['eval', js]);
103
+ const out = await run(['eval', '--stdin'], { input: js });
104
104
  try {
105
105
  return JSON.parse(out);
106
106
  }
@@ -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";
@@ -195,17 +195,42 @@ export class DesignerController {
195
195
  const { promptTextarea, sendButton } = this.selectors.composer;
196
196
  await this.browser.waitFor(promptTextarea);
197
197
  await this.browser.evalValue(`(() => {
198
- const ta = document.querySelector(${JSON.stringify(promptTextarea)});
199
- if (!ta) throw new Error('textarea not found');
200
- const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
201
- setter.call(ta, ${JSON.stringify(prompt)});
202
- ta.dispatchEvent(new Event('input', { bubbles: true }));
203
- ta.focus();
204
- return true;
198
+ const el = document.querySelector(${JSON.stringify(promptTextarea)});
199
+ if (!el) throw new Error('composer input not found');
200
+ const text = ${JSON.stringify(prompt)};
201
+ if (el instanceof HTMLTextAreaElement) {
202
+ // Bypass React's value ownership via the native setter, then fire a
203
+ // bubbling input event.
204
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
205
+ setter.call(el, text);
206
+ el.dispatchEvent(new Event('input', { bubbles: true }));
207
+ el.focus();
208
+ return true;
209
+ }
210
+ if (el.isContentEditable) {
211
+ // Deliver the text as a synthetic paste so the editor's own paste
212
+ // pipeline updates its internal state; execCommand('insertText')
213
+ // flattens multi-line prompts into one paragraph.
214
+ el.focus();
215
+ const sel = window.getSelection();
216
+ const range = document.createRange();
217
+ range.selectNodeContents(el);
218
+ sel.removeAllRanges();
219
+ sel.addRange(range);
220
+ const dt = new DataTransfer();
221
+ dt.setData('text/plain', text);
222
+ const unhandled = el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
223
+ if (unhandled) {
224
+ // No editor intercepted the paste — plain contenteditable fallback.
225
+ document.execCommand('insertText', false, text);
226
+ }
227
+ return true;
228
+ }
229
+ throw new Error('composer input is neither textarea nor contenteditable: ' + el.tagName);
205
230
  })()`);
206
231
  for (let i = 0; i < 30; i++) {
207
232
  await new Promise((r) => setTimeout(r, 150));
208
- const disabled = await this.browser.evalValue(`(() => { const b = document.querySelector(${JSON.stringify(sendButton)}); return !b || b.disabled; })()`);
233
+ const disabled = await this.browser.evalValue(`(() => { const b = document.querySelector(${JSON.stringify(sendButton)}); return !b || b.disabled || b.getAttribute('aria-disabled') === 'true'; })()`);
209
234
  if (!disabled)
210
235
  break;
211
236
  }
@@ -520,7 +545,9 @@ export class DesignerController {
520
545
  if (!opened)
521
546
  await this._clickButtonByText(/^Export$/);
522
547
  await new Promise((r) => setTimeout(r, 400));
523
- await this._clickButtonByText(/handoff to claude code/i);
548
+ const viaSendTo = await this._clickClaudeCodeSendTo().catch(() => false);
549
+ if (!viaSendTo)
550
+ await this._clickButtonByText(/handoff to claude code/i);
524
551
  let handoffUrl = '';
525
552
  for (let i = 0; i < 30; i++) {
526
553
  await new Promise((r) => setTimeout(r, 300));
@@ -547,7 +574,7 @@ export class DesignerController {
547
574
  const buf = Buffer.from(await res.arrayBuffer());
548
575
  fs.writeFileSync(tgzPath, buf);
549
576
  await new Promise((resolve, reject) => {
550
- const child = spawn('tar', ['-xzf', tgzPath, '-C', bundleDir], { stdio: 'pipe' });
577
+ const child = xspawn('tar', ['-xzf', tgzPath, '-C', bundleDir], { stdio: 'pipe' });
551
578
  let err = '';
552
579
  child.stderr.on('data', (d) => (err += d.toString()));
553
580
  child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`tar exited ${code}: ${err}`)));
@@ -583,6 +610,28 @@ export class DesignerController {
583
610
  return true;
584
611
  })()`);
585
612
  }
613
+ async _clickClaudeCodeSendTo() {
614
+ const tabClicked = await this.browser.evalValue(`(() => {
615
+ const tab = Array.from(document.querySelectorAll('button[role="tab"]')).find(t => /send to/i.test(t.textContent || ''));
616
+ if (!tab) return false;
617
+ tab.click();
618
+ return true;
619
+ })()`);
620
+ if (!tabClicked)
621
+ return false;
622
+ await new Promise((r) => setTimeout(r, 400));
623
+ return this.browser.evalValue(`(() => {
624
+ const sends = Array.from(document.querySelectorAll('button')).filter(b => (b.textContent || '').trim() === 'Send');
625
+ const target = sends.find(b => {
626
+ let row = b;
627
+ for (let i = 0; i < 3 && row.parentElement; i++) row = row.parentElement;
628
+ return /claude code/i.test(row.textContent || '');
629
+ });
630
+ if (!target) return false;
631
+ target.click();
632
+ return true;
633
+ })()`);
634
+ }
586
635
  async _dialogText() {
587
636
  return ((await this.browser
588
637
  .evalValue(`(() => {
@@ -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.');
@@ -171,7 +173,8 @@ async function step3Chrome(port) {
171
173
  reminder: `Waiting for CDP at :${port}...`
172
174
  });
173
175
  if (!up) {
174
- log('chrome', 'fail', 'Chrome launched but CDP did not come up. Try `./scripts/designer-chrome.sh` manually.');
176
+ const fallback = IS_WIN ? 'scripts\\designer-chrome.ps1' : './scripts/designer-chrome.sh';
177
+ log('chrome', 'fail', `Chrome launched but CDP did not come up. Try \`${fallback}\` manually.`);
175
178
  return false;
176
179
  }
177
180
  log('chrome', 'ok', `CDP up on :${port}`);
@@ -217,25 +220,21 @@ function step5Skill() {
217
220
  return true;
218
221
  }
219
222
  function step6Mcp(port) {
220
- const claudeBin = spawnSync('which', ['claude'], { stdio: 'pipe' });
223
+ const claudeBin = xspawnSync(WHICH, ['claude'], { stdio: 'pipe' });
221
224
  if (claudeBin.status !== 0) {
222
225
  log('mcp', 'wait', 'claude CLI not on PATH; skipping MCP registration. Install Claude Code to register.');
223
226
  return true;
224
227
  }
225
- const list = spawnSync('claude', ['mcp', 'list'], { stdio: 'pipe' });
228
+ const list = xspawnSync('claude', ['mcp', 'list'], { stdio: 'pipe' });
226
229
  const stdout = list.stdout?.toString() || '';
227
230
  if (/(\s|^)designer\b/i.test(stdout)) {
228
231
  log('mcp', 'ok', 'Already registered.');
229
232
  return true;
230
233
  }
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'];
234
+ const envFlags = port === '9222' ? [] : ['-e', `DESIGNER_CDP=${port}`];
235
+ const cmd = ['mcp', 'add', '--scope', 'user', '--transport', 'stdio', ...envFlags, 'designer', '--', 'designer', 'mcp', 'serve'];
237
236
  log('mcp', 'wait', `Registering: claude ${cmd.join(' ')}`);
238
- const reg = spawnSync('claude', cmd, { stdio: 'inherit' });
237
+ const reg = xspawnSync('claude', cmd, { stdio: 'inherit' });
239
238
  if (reg.status !== 0) {
240
239
  log('mcp', 'fail', `claude mcp add exited ${reg.status}. Run manually:\n claude ${cmd.join(' ')}`);
241
240
  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 };
@@ -70,7 +70,7 @@ export const UI_ANCHORS = [
70
70
  category: 'session',
71
71
  description: 'send button',
72
72
  requires: 'session',
73
- check: async (b) => ({ ok: await hasSelector(b, '[data-testid="chat-send-button"]') })
73
+ check: async (b) => ({ ok: await hasSelector(b, '[data-testid="chat-send-button"], button[title^="Send ("]') })
74
74
  },
75
75
  {
76
76
  id: 'session.htmlViewerIframe',
@@ -122,16 +122,30 @@ export const UI_ANCHORS = [
122
122
  {
123
123
  id: 'share.handoffMenuItem',
124
124
  category: 'share',
125
- description: 'Handoff-to-Claude-Code menu item (inside Share dropdown)',
125
+ description: 'Handoff-to-Claude-Code action (Share → Send to… tab → Claude Code row, or the legacy dropdown item)',
126
126
  requires: 'session',
127
127
  check: async (b) => {
128
128
  const opened = await b.evalValue(`(() => { const btn = Array.from(document.querySelectorAll('button')).find(x => (x.textContent||'').trim() === 'Share'); if (!btn) return false; btn.click(); return true; })()`).catch(() => false);
129
129
  if (!opened)
130
130
  return { ok: false, detail: 'Share button not clickable' };
131
131
  await new Promise((r) => setTimeout(r, 400));
132
- const found = await hasButtonMatching(b, /handoff to claude code/i);
132
+ let found = await hasButtonMatching(b, /handoff to claude code/i);
133
+ if (!found) {
134
+ const tabClicked = await b.evalValue(`(() => { const tab = Array.from(document.querySelectorAll('button[role="tab"]')).find(t => /send to/i.test(t.textContent || '')); if (!tab) return false; tab.click(); return true; })()`).catch(() => false);
135
+ if (tabClicked) {
136
+ await new Promise((r) => setTimeout(r, 400));
137
+ found = await b.evalValue(`(() => {
138
+ const sends = Array.from(document.querySelectorAll('button')).filter(x => (x.textContent || '').trim() === 'Send');
139
+ return sends.some(x => {
140
+ let row = x;
141
+ for (let i = 0; i < 3 && row.parentElement; i++) row = row.parentElement;
142
+ return /claude code/i.test(row.textContent || '');
143
+ });
144
+ })()`).catch(() => false);
145
+ }
146
+ }
133
147
  await b.evalValue(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); true`).catch(() => null);
134
- return { ok: found, detail: found ? undefined : 'Share opened but no Handoff-to-Claude-Code item' };
148
+ return { ok: found, detail: found ? undefined : 'Share opened but no Claude Code handoff action (checked legacy item and Send to… tab)' };
135
149
  }
136
150
  },
137
151
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pro-vi/designer",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
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",
@@ -40,11 +41,13 @@
40
41
  "smoke": "bash scripts/install-smoke.sh"
41
42
  },
42
43
  "dependencies": {
43
- "@anthropic-ai/sdk": "^0.96.0",
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/selectors.json CHANGED
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "composer": {
20
20
  "promptTextarea": "[data-testid=\"chat-composer-input\"]",
21
- "sendButton": "[data-testid=\"chat-send-button\"]",
21
+ "sendButton": "[data-testid=\"chat-send-button\"], button[title^=\"Send (\"]",
22
22
  "stopButton": null,
23
23
  "attachButton": "button[aria-label=\"Attach file\"]",
24
24
  "modelButton": "[data-testid=\"model-selector-button\"]"
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