rechrome 1.27.0 → 1.28.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.
Files changed (4) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/rechrome.js +191 -182
  4. package/rechrome.ts +191 -182
package/README.md CHANGED
@@ -39,7 +39,7 @@ Pick the profile up front with `rech setup --profile you@example.com`.
39
39
  Check it:
40
40
 
41
41
  ```bash
42
- rech status # daemon, the URL in use, registered profiles
42
+ rech status # is it working: the URL in use, the daemon, the current profile
43
43
  rech profile # every Chrome profile and whether it is connected
44
44
  ```
45
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.27.0",
3
+ "version": "1.28.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/rechrome.js CHANGED
@@ -1041,7 +1041,49 @@ export function extractGlobalProfileArg(args: string[]): { args: string[]; selec
1041
1041
  return { args: rest, selector };
1042
1042
  }
1043
1043
 
1044
- async function run(url: string, args: string[], overrideEnv?: Record<string, string>) {
1044
+ function editDistance(a: string, b: string): number {
1045
+ const row = Array.from({ length: b.length + 1 }, (_, j) => j);
1046
+ for (let i = 1; i <= a.length; i++) {
1047
+ let diagonal = row[0];
1048
+ row[0] = i;
1049
+ for (let j = 1; j <= b.length; j++) {
1050
+ const above = row[j];
1051
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
1052
+ diagonal = above;
1053
+ }
1054
+ }
1055
+ return row[b.length];
1056
+ }
1057
+
1058
+ /** What to do when no RECHROME_URL is configured: set up here, or connect to another machine. */
1059
+ export function notConnectedMessage(): string {
1060
+ return [
1061
+ `rech: not connected to a rechrome daemon (${ENV_KEY} is not set).`,
1062
+ ` On the machine with Chrome: rech setup`,
1063
+ ` On another machine: rech connect '<URL printed by \`rech url\` on that machine>'`,
1064
+ ].join("\n");
1065
+ }
1066
+
1067
+ /**
1068
+ * When playwright-cli rejects a command, replace its own usage dump with a rech-branded hint.
1069
+ * Candidates are rech's commands plus the browser commands listed in that usage text, so the
1070
+ * suggestion stays current without a hardcoded list. Returns null for any other output.
1071
+ */
1072
+ export function unknownCommandHint(output: string, rechCommands: Iterable<string> = RECH_COMMANDS): string | null {
1073
+ const unknown = output.match(/^Unknown command: (\S+)/m)?.[1];
1074
+ if (!unknown) return null;
1075
+ const browser = [...output.matchAll(/^ {2}([a-z][a-z0-9-]+) /gm)].map(m => m[1]);
1076
+ const candidates = [...new Set([...rechCommands, ...browser])];
1077
+ const scored = candidates.map(c => ({ c, d: editDistance(unknown.toLowerCase(), c) })).sort((x, y) => x.d - y.d);
1078
+ const best = scored[0] && scored[0].d <= Math.max(1, Math.floor(unknown.length / 3)) ? scored[0].c : null;
1079
+ return [
1080
+ `rech: unknown command "${unknown}".${best ? ` Did you mean "${best}"?` : ""}`,
1081
+ ` rech --help rechrome commands (setup, status, profile, url, connect, listener…)`,
1082
+ ` rech pw --help browser commands (open, click, screenshot…)`,
1083
+ ].join("\n");
1084
+ }
1085
+
1086
+ async function run(url: string, args: string[], overrideEnv?: Record<string, string>, opts: { verbatim?: boolean } = {}) {
1045
1087
  // Match the underlying CLI's command names while accepting the short forms humans
1046
1088
  // naturally try. Keep this client-side so old and new serve daemons behave alike.
1047
1089
  args = normalizeCommandArgs(args);
@@ -1060,11 +1102,17 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
1060
1102
 
1061
1103
  const isOpenWithUrl = args[0] === "open" && args.length > 1;
1062
1104
  if (existingSession && isOpenWithUrl) {
1063
- return run(url, ["goto", ...args.slice(1)], overrideEnv);
1105
+ return run(url, ["goto", ...args.slice(1)], overrideEnv, opts);
1064
1106
  }
1065
1107
 
1066
1108
  if (existingSession)
1067
1109
  console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
1110
+ // A typo'd command: rech's hint instead of playwright-cli's full usage (kept for `rech pw`).
1111
+ const hint = !opts.verbatim && status !== 0 ? unknownCommandHint(`${stderr ?? ""}\n${stdout ?? ""}`) : null;
1112
+ if (hint) {
1113
+ console.error(hint);
1114
+ process.exit(status || 1);
1115
+ }
1068
1116
  if (stderr) {
1069
1117
  if (stderr.includes('Extension connection timeout')) {
1070
1118
  const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
@@ -1866,8 +1914,17 @@ async function requireListeners() {
1866
1914
  return config;
1867
1915
  }
1868
1916
 
1917
+ /** Print rows as left-aligned columns; the first row is the header. */
1918
+ function printTable(rows: string[][]): void {
1919
+ const widths = rows[0].map((_, i) => Math.max(...rows.map(r => (r[i] ?? "").length)));
1920
+ for (const r of rows) console.log(r.map((c, i) => (c ?? "").padEnd(widths[i])).join(" ").trimEnd());
1921
+ }
1922
+
1869
1923
  async function listListeners(): Promise<void> {
1870
- for (const l of (await requireListeners()).listeners) console.log(`${l.name} ${listenerAddress(l)}${normalizePrefix(l.prefix)} ${l.profiles === "*" ? "local management (all profiles)" : l.profiles.join(", ")}`);
1924
+ const rows = [["NAME", "ADDRESS", "PROFILES", "PUBLIC URL"]];
1925
+ for (const l of (await requireListeners()).listeners)
1926
+ rows.push([l.name, `${listenerAddress(l)}${normalizePrefix(l.prefix)}`, l.profiles === "*" ? "(all — local management)" : l.profiles.join(", "), l.publicUrl ?? "-"]);
1927
+ printTable(rows);
1871
1928
  }
1872
1929
 
1873
1930
  async function removeListener(name: string): Promise<void> {
@@ -1967,8 +2024,7 @@ async function urlList(): Promise<void> {
1967
2024
  const local = `http://${listenerAddress(l)}${normalizePrefix(l.prefix)}`;
1968
2025
  for (const profile of l.profiles === "*" ? ["(all profiles)"] : l.profiles) rows.push([l.name, profile, local, l.publicUrl ?? "-"]);
1969
2026
  }
1970
- const widths = rows[0].map((_, i) => Math.max(...rows.map(r => r[i].length)));
1971
- for (const r of rows) console.log(r.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
2027
+ printTable(rows);
1972
2028
  console.log(`\nPrint a full URL (contains the secret key): rech url <profile> --listener <name>`);
1973
2029
  }
1974
2030
 
@@ -2400,126 +2456,45 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
2400
2456
  async function status(): Promise<void> {
2401
2457
  const url = process.env[ENV_KEY];
2402
2458
  if (!url) {
2403
- console.log(`serve: not configured (run \`rech setup\`)`);
2459
+ console.log(`serve: not configured`);
2460
+ console.log(notConnectedMessage().split("\n").slice(1).join("\n"));
2404
2461
  return;
2405
2462
  }
2406
2463
  const parsed = parseUrl(url);
2407
2464
  const ping = await fetch(serviceUrl(url), { signal: AbortSignal.timeout(2000) }).catch(() => null);
2408
- // Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
2409
- // POSIX-only and absent on Windows). bind is "0.0.0.0" (all interfaces) or the loopback IP.
2410
- const pingBody = ping
2411
- ? await fetch(serviceUrl(url, "ping"), {
2412
- headers: { Authorization: `Bearer ${parsed.key}` },
2413
- signal: AbortSignal.timeout(2000),
2414
- }).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; listener?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
2465
+ // The authenticated /ping reports which listener answered, its bind, and the profiles it allows.
2466
+ const pingResponse = ping
2467
+ ? await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(2000) }).catch(() => null)
2468
+ : null;
2469
+ const pingBody = pingResponse?.ok
2470
+ ? await pingResponse.json().catch(() => null) as { bind?: string; listener?: string; profiles?: string[] | "*"; degraded?: boolean; consecutiveTimeouts?: number } | null
2415
2471
  : null;
2416
2472
  // Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
2417
2473
  const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
2418
- console.log(`serve: ${ping ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}` : "not running"}`);
2474
+ console.log(`serve: ${ping ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}` : `not reachable at ${serviceUrl(url)}`}`);
2475
+ if (pingResponse?.status === 401)
2476
+ console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech url <profile>\`), then \`rech connect '<url>'\``);
2419
2477
  // daemonManager().id — there is no PM_BIN constant. Referencing one threw a
2420
2478
  // ReferenceError that took down the whole of `rech status`, so the one command
2421
2479
  // that reports "the relay is wedged" died exactly when the relay was wedged,
2422
2480
  // printing a stack trace instead of the restart hint.
2423
2481
  if (pingBody?.degraded)
2424
2482
  console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; force it now with \`${daemonManager().id} restart ${PM_PROCESS_NAME}\``);
2425
- const pmOut = await pmList();
2426
- const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
2427
- console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
2428
- const registry = await readTokenRegistry();
2429
- const entries = Object.entries(registry);
2430
- if (entries.length) {
2431
- console.log(`\nprofiles:`);
2432
- const primaryProfile = parsed.profileDirectory;
2433
- for (const [email, entry] of entries) {
2434
- const isPrimary = email === primaryProfile || entry.profileDir === primaryProfile;
2435
- const marker = isPrimary ? " (primary)" : "";
2436
- console.log(` ${email.padEnd(36)} [${entry.profileDir}] ext: ${entry.extensionId.slice(0, 8)}… token: ${entry.token.slice(0, 8)}…${marker}`);
2437
- }
2438
- } else if (parsed.profileDirectory) {
2439
- // Legacy: no registry yet, show from RECHROME_URL
2440
- const email = await resolveProfileEmail(parsed.profileDirectory).catch(() => parsed.profileDirectory);
2441
- console.log(`\nprofiles:\n ${email} [${parsed.profileDirectory}] (legacy — re-run \`rech setup\` to register)`);
2442
- }
2443
- }
2444
-
2445
- function printHelp(): void {
2446
- console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
2447
-
2448
- Usage:
2449
- rech [--profile <email|name|folder>] <playwright-args...>
2450
- Run Playwright CLI command with the given registered
2451
- Chrome profile. --profile selects the profile by exact
2452
- registered email (e.g. you@gmail.com), exact Chrome
2453
- profile name, or exact profile folder name. The profile
2454
- must already be registered (see \`rech setup\`). Place
2455
- --profile before the playwright subcommand. Requires
2456
- ${ENV_KEY}.
2457
- rech setup [--listen <local|lan|tailscale|IP>] [--profile <email|name|folder>] [--token <tok>] [--prefix <path>] [--port <port>] [--yes]
2458
- First-time setup: daemon + Chrome extension + config
2459
- --prefix=rechrome mounts at /rechrome/ on a scoped listener.
2460
- Prefixed setup defaults to the management port + 1; override with --port.
2461
- Offers to install missing oxmgr globally (y/N).
2462
- --yes approves installation without prompting.
2463
- --profile selects the Chrome profile non-interactively.
2464
- Menu numbers are not accepted. Resolution order is exact
2465
- email (e.g. you@gmail.com), exact Chrome profile name,
2466
- then exact profile folder name (e.g. "Profile 1"). See
2467
- available values with \`rech profile\`.
2468
- --token (or RECH_TOKEN) supplies the auth token for
2469
- non-TTY/agent runs, skipping the interactive paste
2470
- rech provision-profile <name> --experimental [--headed]
2471
- (experimental) Auto-provision a managed QA profile on
2472
- Chrome for Testing — branded Chrome 149+ rejects
2473
- --load-extension, so this is a clean browser, not your
2474
- real Chrome. For your real Chrome, use \`rech setup\`
2475
- rech status Show current configuration and serve health
2476
- rech tray [show|hide|stop] Native menu-bar/tray icon for the serve daemon
2477
- (show=start, hide/show toggle, stop=quit). Auto-
2478
- starts after \`rech setup\`; skipped with no GUI
2479
- rech uninstall Remove the serve daemon and clear config
2480
- rech serve Start the serve server manually (foreground)
2481
- rech listener [ls|add|remove] Manage daemon listener addresses and allowed profiles
2482
- rech listener allow|deny <name> <profile...>
2483
- Add or remove profiles on an existing listener
2484
- rech listener port [name] Print a listener's port, for a reverse-proxy command
2485
- rech listener set <name> --public-url <url>
2486
- Record where a proxy exposes the listener (rech url uses it)
2487
- rech listener rotate-key <name>
2488
- New key for a listener; URLs with the old key stop working
2489
- rech profile [ls|list]
2490
- List Chrome + managed test profiles and connection status
2491
- rech url [profile] [--listener <name>] [--local] [--save]
2492
- Print a connection URL (includes the secret key): the public
2493
- URL when one is set, else the listener address. --save also
2494
- writes it to this project's .rechrome/.env.local.
2495
- \`rech profile [name] --print-uri\` is an alias.
2496
- rech url ls List every listener × profile URL (keys hidden)
2497
- rech connect <url> Check a shared URL answers, then save it for this project
2498
- rech <playwright-args...> Run Playwright CLI command (requires ${ENV_KEY})
2499
- rech pw <playwright-args...> Forward verbatim to playwright-cli, even when a name clashes
2500
- with rech's own (rech pw --version, rech pw status)
2501
- rech --version rechrome's version
2502
- rech --isolate <args...> Run in a throwaway session (sugar for -s=<random>) so a
2503
- fragile single-shot flow (OAuth/login) never shares tabs
2504
- with the worktree's default session
2505
-
2506
- Environment:
2507
- ${ENV_KEY} Server URL set by \`rech setup\`
2508
- RECH_TOKEN Auth token for \`rech setup\` (same as --token)
2509
- RECH_IDENTITY Session bucket mode: worktree (default) | branch | cwd. The session a
2510
- client reuses is keyed on the worktree root path; \`branch\` restores the
2511
- old <remote>/tree/<branch> keying, \`cwd\` keys on the exact directory
2512
- RECH_SETUP_AGENT Setup hints: codex | claude | none (otherwise auto-detected)
2513
-
2514
- Examples:
2515
- rech setup
2516
- rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
2517
- rech --profile you@gmail.com open https://example.com
2518
- rech eval "() => document.title"
2519
- rech open https://example.com
2520
- rech screenshot`);
2483
+ // The daemon line is about this machine; a client of a remote host has no local daemon to report.
2484
+ const isHost = !!(await readListeners().catch(() => null));
2485
+ if (isHost) {
2486
+ const daemonRegistered = (await pmList()).includes(PM_PROCESS_NAME);
2487
+ console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
2488
+ }
2489
+ // Same resolution as a command: ?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY.
2490
+ const effective = resolveEffectiveProfile(parsed.profileDirectory);
2491
+ const current = effective ? await resolveProfileEmail(effective).catch(() => effective) : undefined;
2492
+ const allowed = pingBody?.profiles === "*" ? "all registered profiles" : pingBody?.profiles?.join(", ");
2493
+ console.log(`profile: ${current ?? "(none selected; add ?profile= to the URL or pass --profile)"}${allowed ? ` — this listener serves: ${allowed}` : ""}`);
2494
+ if (isHost) console.log(`\nMore: rech profile (profiles) · rech url ls (who can connect, and where)`);
2521
2495
  }
2522
2496
 
2497
+
2523
2498
  export type SetupOptions = { profile?: string; token?: string; listen?: string; prefix?: string; port?: number; yes?: boolean };
2524
2499
  export type RechHandlers = {
2525
2500
  serve(): Promise<void> | void;
@@ -2547,77 +2522,111 @@ export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners"
2547
2522
 
2548
2523
  const portOption = { type: "number", requiresArg: true, describe: "Listener port (1-65535)" } as const;
2549
2524
 
2525
+ // yargs trims indentation in .usage(), so the indented browser-command block lives in the epilogue.
2526
+ const HELP_USAGE = `rechrome (rech) — drive your real, logged-in Chrome from scripts, agents and other machines
2527
+
2528
+ Usage: rech <command> [options] · rech <browser-command> [args]`;
2529
+
2530
+ const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
2531
+ rech [--profile <p>] [--isolate] <browser-command> [args]
2532
+ open, goto, click, fill, screenshot, eval, tab-list… (\`rech pw --help\` lists all)
2533
+ --profile <p> as another registered profile (email, name or folder); put it first
2534
+ --isolate in a throwaway session, e.g. for a login flow
2535
+ rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
2536
+ rech --version rechrome's version
2537
+
2538
+ Environment:
2539
+ ${ENV_KEY} connection URL; read from the nearest .rechrome/.env.local or .env.local
2540
+ RECH_IDENTITY session key: worktree (default) | branch | cwd
2541
+ RECH_TOKEN extension token for \`rech setup\` (same as --token)
2542
+ RECH_SETUP_AGENT setup hints: codex | claude | none (auto-detected)
2543
+
2544
+ Examples:
2545
+ rech setup --profile you@example.com set up Chrome on this machine
2546
+ rech open https://example.com open a page in this project's session
2547
+ rech screenshot saved to <project>/.rechrome/output/
2548
+ rech url you@example.com --listener share URL to give another machine (secret)
2549
+ rech connect '<url>' use that URL in this project
2550
+
2551
+ Run \`rech <command> --help\` for a command's options. Tutorial: https://github.com/snomiao/rechrome#tutorial`;
2552
+
2550
2553
  export function rechCli(argv: string[], handlers: RechHandlers) {
2551
2554
  return yargs(argv)
2552
2555
  .scriptName("rech")
2556
+ .usage(HELP_USAGE)
2557
+ .epilogue(HELP_EPILOGUE)
2558
+ .wrap(Math.min(110, process.stdout.columns || 110))
2553
2559
  .parserConfiguration({ "parse-numbers": false, "parse-positional-numbers": false })
2554
- .command("serve", "Run the rechrome daemon in the foreground", {}, () => handlers.serve())
2555
- .command("status", "Show daemon, relay and profile connection status", {}, () => handlers.status())
2556
- .command(["listener", "listeners"], "Manage network listeners", y => y
2557
- .command(["ls", "list", "$0"], "List listeners (credentials hidden)", {}, () => handlers.listListeners())
2558
- .command("add <name>", "Expose registered profiles on a network", y => y
2560
+ // Set up and inspect this machine
2561
+ .command("setup", "Set up this machine: daemon, Chrome extension, connection", y => y
2562
+ .option("profile", { type: "string", requiresArg: true, describe: "Chrome profile: exact email, Chrome profile name, or folder (e.g. \"Profile 1\"); not menu numbers" })
2563
+ .option("token", { type: "string", requiresArg: true, describe: "Extension token, for headless runs (default: read from the profile, or RECH_TOKEN)" })
2564
+ .option("listen", { type: "string", requiresArg: true, describe: "Who can reach this profile: local (default) | lan | tailscale | <detected IP>" })
2565
+ .option("prefix", { type: "string", requiresArg: true, describe: "URL path for a proxied listener, e.g. rechrome (port defaults to the management port + 1)" })
2566
+ .option("port", portOption)
2567
+ .option("yes", { alias: "y", type: "boolean", default: false, describe: "Approve installing a missing oxmgr without prompting" }),
2568
+ a => handlers.setup({ profile: a.profile, token: a.token ?? process.env.RECH_TOKEN, listen: a.listen, prefix: a.prefix, port: a.port, yes: a.yes }))
2569
+ .command("status", "Is it working? The URL in use, the daemon, and the current profile", {}, () => handlers.status())
2570
+ .command(["profile [name]", "profiles [name]"], "List Chrome profiles and whether each is connected", y => y
2571
+ .positional("name", { type: "string", describe: "ls/list lists all (the default)" })
2572
+ .option("print-uri", { type: "boolean", describe: "Same as `rech url <name>`" })
2573
+ .option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URL for" }),
2574
+ a => {
2575
+ if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech url`
2576
+ if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
2577
+ throw new Error(`To print "${a.name}"'s connection URL: rech url ${JSON.stringify(a.name)}. To list profiles: rech profile`);
2578
+ })
2579
+ // Share with and connect from other machines
2580
+ .command(["url [profile]", "urls [profile]"], "Print a connection URL to share (contains a secret key); `url ls` lists all", y => y
2581
+ .positional("profile", { type: "string", describe: "Profile (email, name or folder); ls/list lists every listener's URLs" })
2582
+ .option("listener", { type: "string", requiresArg: true, describe: "Listener to build the URL for" })
2583
+ .option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
2584
+ .option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" }),
2585
+ a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save
2586
+ ? handlers.urlList()
2587
+ : handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
2588
+ .command("connect <url>", "Use a URL from another machine in this project (checks it first)", y => y
2589
+ .positional("url", { type: "string", demandOption: true }),
2590
+ a => handlers.connect(a.url))
2591
+ .command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
2592
+ .command(["ls", "list", "$0"], "List listeners (keys hidden)", {}, () => handlers.listListeners())
2593
+ .command("add <name>", "Expose registered profiles on an address (local for a proxy, lan, tailscale, IP)", y => y
2559
2594
  .positional("name", { type: "string", demandOption: true })
2560
2595
  .option("listen", { type: "string", requiresArg: true, demandOption: true, describe: "local | lan | tailscale | <detected IP>" })
2561
2596
  .option("profile", { type: "string", array: true, requiresArg: true, demandOption: true, describe: "Allowed profile; repeat for several" })
2562
2597
  .option("port", portOption)
2563
2598
  .option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix, e.g. rechrome" }),
2564
2599
  a => handlers.addListener(a.name, { listen: a.listen, profile: a.profile, port: a.port, prefix: a.prefix }))
2565
- .command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
2566
- a => handlers.removeListener(a.name))
2567
- .command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
2568
- a => handlers.listenerPort(a.name))
2569
2600
  .command("allow <name> <profiles..>", "Allow more profiles on a listener", y => y
2570
2601
  .positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
2571
2602
  a => handlers.allowListener(a.name, a.profiles))
2572
2603
  .command("deny <name> <profiles..>", "Remove profiles from a listener", y => y
2573
2604
  .positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
2574
2605
  a => handlers.denyListener(a.name, a.profiles))
2575
- .command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
2576
- a => handlers.rotateKey(a.name))
2577
2606
  .command("set <name>", "Record where a reverse proxy exposes a listener", y => y
2578
2607
  .positional("name", { type: "string", demandOption: true })
2579
2608
  .option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.js.net/rechrome/" })
2580
2609
  .option("clear-public-url", { type: "boolean", conflicts: "public-url" })
2581
2610
  .check(a => a.publicUrl !== undefined || a.clearPublicUrl ? true : "Pass --public-url <url> or --clear-public-url"),
2582
2611
  a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl }))
2612
+ .command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
2613
+ a => handlers.listenerPort(a.name))
2614
+ .command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
2615
+ a => handlers.rotateKey(a.name))
2616
+ .command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
2617
+ a => handlers.removeListener(a.name))
2583
2618
  .demandCommand(1).strict())
2584
- .command(["url [profile]", "urls [profile]"], "Print a profile's connection URL (contains a secret key); `url ls` lists them", y => y
2585
- .positional("profile", { type: "string", describe: "Profile (email, name or folder); ls/list lists all listeners' URLs" })
2586
- .option("listener", { type: "string", requiresArg: true, describe: "Listener to build the URL for" })
2587
- .option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
2588
- .option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" }),
2589
- a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save
2590
- ? handlers.urlList()
2591
- : handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
2592
- .command("connect <url>", "Check a shared connection URL and save it for this project", y => y
2593
- .positional("url", { type: "string", demandOption: true }),
2594
- a => handlers.connect(a.url))
2595
- .command(["profile [name]", "profiles [name]"], "List profiles, or print a profile's connection URI", y => y
2596
- .positional("name", { type: "string", describe: "Profile (email, name or folder); ls/list lists all" })
2597
- .option("print-uri", { type: "boolean", describe: "Print the profile's connection URI (contains a secret key)" })
2598
- .option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URI for" }),
2599
- a => {
2600
- if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech url`
2601
- if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
2602
- throw new Error("Usage: rech profile [ls|list] | rech profile [name] --print-uri. Create, rename, and delete are not implemented.");
2603
- })
2604
- .command("setup", "Install the daemon and connect a Chrome profile", y => y
2605
- .option("profile", { type: "string", requiresArg: true, describe: "Chrome profile: email, name or folder" })
2606
- .option("token", { type: "string", requiresArg: true, describe: "Extension token (default: read from the profile, or RECH_TOKEN)" })
2607
- .option("listen", { type: "string", requiresArg: true, describe: "local | lan | tailscale | <detected IP>" })
2608
- .option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix for a scoped listener, e.g. rechrome" })
2609
- .option("port", portOption)
2610
- .option("yes", { alias: "y", type: "boolean", default: false, describe: "Approve installing a missing oxmgr without prompting" }),
2611
- a => handlers.setup({ profile: a.profile, token: a.token ?? process.env.RECH_TOKEN, listen: a.listen, prefix: a.prefix, port: a.port, yes: a.yes }))
2612
- .command("tray [action]", "Show or hide the tray icon", y => y
2619
+ // Daemon and extras
2620
+ .command("tray [action]", "Menu-bar icon for the daemon (starts after setup)", y => y
2613
2621
  .positional("action", { type: "string", choices: ["show", "start", "hide", "stop", "quit"] }),
2614
2622
  a => handlers.tray(a.action))
2615
- .command("provision-profile <name>", "Create a managed Chrome-for-Testing profile (experimental)", y => y
2623
+ .command("provision-profile <name>", "(experimental) Clean Chrome-for-Testing profile, fully automated; not your real Chrome", y => y
2616
2624
  .positional("name", { type: "string", demandOption: true })
2617
2625
  .option("experimental", { type: "boolean", default: false })
2618
2626
  .option("headed", { type: "boolean", default: false }),
2619
2627
  a => handlers.provisionProfile(a.name, { headed: a.headed, experimental: a.experimental }))
2620
- .command("uninstall", "Stop and remove the rechrome daemon", {}, () => handlers.uninstall())
2628
+ .command("uninstall", "Stop and remove the daemon", {}, () => handlers.uninstall())
2629
+ .command("serve", "Run the daemon in the foreground (normally managed by oxmgr)", {}, () => handlers.serve())
2621
2630
  .demandCommand(1)
2622
2631
  .strict()
2623
2632
  .help()
@@ -2629,32 +2638,33 @@ if (import.meta.main) {
2629
2638
  let args = process.argv.slice(2);
2630
2639
  const cmd = args[0]?.toLowerCase();
2631
2640
 
2641
+ const handlers: RechHandlers = {
2642
+ serve: async () => { const { serve } = await import("./serve.js"); serve(); }, // long-lived; watcher intentionally kept alive
2643
+ status,
2644
+ listListeners, addListener, removeListener, listProfiles, printProfileUri,
2645
+ urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
2646
+ setup: async (opts) => {
2647
+ await setup(opts); // setup closes envWatcher itself before printing Done
2648
+ // Auto-start the tray (best-effort, silent on headless / missing binary).
2649
+ await startTray({ quiet: true }).catch(() => {});
2650
+ },
2651
+ tray: trayCommand,
2652
+ provisionProfile: async (name, { headed, experimental }) => {
2653
+ // Experimental: a managed profile runs on Chrome for Testing, not the user's real Google Chrome
2654
+ // (branded Chrome 149+ rejects --load-extension). It's a clean browser with no logins/cookies,
2655
+ // so it's gated behind --experimental rather than offered as the default setup path.
2656
+ if (!experimental) throw new Error([
2657
+ `provision-profile is experimental and creates a Chrome-for-Testing profile (not your`,
2658
+ `real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`,
2659
+ `can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`,
2660
+ `To proceed anyway, re-run with --experimental.`,
2661
+ ].join("\n"));
2662
+ await provisionProfile(name, { headed });
2663
+ },
2664
+ uninstall: daemonUninstall,
2665
+ };
2666
+
2632
2667
  if (cmd && RECH_COMMANDS.has(cmd)) {
2633
- const handlers: RechHandlers = {
2634
- serve: async () => { const { serve } = await import("./serve.js"); serve(); }, // long-lived; watcher intentionally kept alive
2635
- status,
2636
- listListeners, addListener, removeListener, listProfiles, printProfileUri,
2637
- urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
2638
- setup: async (opts) => {
2639
- await setup(opts); // setup closes envWatcher itself before printing Done
2640
- // Auto-start the tray (best-effort, silent on headless / missing binary).
2641
- await startTray({ quiet: true }).catch(() => {});
2642
- },
2643
- tray: trayCommand,
2644
- provisionProfile: async (name, { headed, experimental }) => {
2645
- // Experimental: a managed profile runs on Chrome for Testing, not the user's real Google Chrome
2646
- // (branded Chrome 149+ rejects --load-extension). It's a clean browser with no logins/cookies,
2647
- // so it's gated behind --experimental rather than offered as the default setup path.
2648
- if (!experimental) throw new Error([
2649
- `provision-profile is experimental and creates a Chrome-for-Testing profile (not your`,
2650
- `real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`,
2651
- `can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`,
2652
- `To proceed anyway, re-run with --experimental.`,
2653
- ].join("\n"));
2654
- await provisionProfile(name, { headed });
2655
- },
2656
- uninstall: daemonUninstall,
2657
- };
2658
2668
  try {
2659
2669
  await rechCli([cmd, ...args.slice(1)], handlers).parseAsync();
2660
2670
  } catch (error) {
@@ -2667,13 +2677,12 @@ if (import.meta.main) {
2667
2677
  console.log(rechromeVersion()); // playwright-cli's own: rech pw --version
2668
2678
  envWatcher?.close();
2669
2679
  } else if (cmd === "help" || cmd === "--help" || cmd === "-h" || args.length === 0) {
2670
- printHelp();
2671
- envWatcher?.close();
2680
+ try { await rechCli(["--help"], handlers).parseAsync(); }
2681
+ finally { envWatcher?.close(); }
2672
2682
  } else {
2673
2683
  const url = process.env[ENV_KEY];
2674
2684
  if (!url) {
2675
- console.error(`${ENV_KEY} is not set. Run \`rech setup\` to configure.\n`);
2676
- printHelp();
2685
+ console.error(notConnectedMessage());
2677
2686
  process.exit(1);
2678
2687
  }
2679
2688
  // --profile: target a registered Chrome profile globally (see extractGlobalProfileArg for
@@ -2724,7 +2733,7 @@ if (import.meta.main) {
2724
2733
  args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
2725
2734
  }
2726
2735
  args = [...forwarded, ...args];
2727
- await run(url, args, overrideEnv);
2736
+ await run(url, args, overrideEnv, { verbatim: separator !== -1 });
2728
2737
  envWatcher?.close();
2729
2738
  }
2730
2739
  }
package/rechrome.ts CHANGED
@@ -1041,7 +1041,49 @@ export function extractGlobalProfileArg(args: string[]): { args: string[]; selec
1041
1041
  return { args: rest, selector };
1042
1042
  }
1043
1043
 
1044
- async function run(url: string, args: string[], overrideEnv?: Record<string, string>) {
1044
+ function editDistance(a: string, b: string): number {
1045
+ const row = Array.from({ length: b.length + 1 }, (_, j) => j);
1046
+ for (let i = 1; i <= a.length; i++) {
1047
+ let diagonal = row[0];
1048
+ row[0] = i;
1049
+ for (let j = 1; j <= b.length; j++) {
1050
+ const above = row[j];
1051
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
1052
+ diagonal = above;
1053
+ }
1054
+ }
1055
+ return row[b.length];
1056
+ }
1057
+
1058
+ /** What to do when no RECHROME_URL is configured: set up here, or connect to another machine. */
1059
+ export function notConnectedMessage(): string {
1060
+ return [
1061
+ `rech: not connected to a rechrome daemon (${ENV_KEY} is not set).`,
1062
+ ` On the machine with Chrome: rech setup`,
1063
+ ` On another machine: rech connect '<URL printed by \`rech url\` on that machine>'`,
1064
+ ].join("\n");
1065
+ }
1066
+
1067
+ /**
1068
+ * When playwright-cli rejects a command, replace its own usage dump with a rech-branded hint.
1069
+ * Candidates are rech's commands plus the browser commands listed in that usage text, so the
1070
+ * suggestion stays current without a hardcoded list. Returns null for any other output.
1071
+ */
1072
+ export function unknownCommandHint(output: string, rechCommands: Iterable<string> = RECH_COMMANDS): string | null {
1073
+ const unknown = output.match(/^Unknown command: (\S+)/m)?.[1];
1074
+ if (!unknown) return null;
1075
+ const browser = [...output.matchAll(/^ {2}([a-z][a-z0-9-]+) /gm)].map(m => m[1]);
1076
+ const candidates = [...new Set([...rechCommands, ...browser])];
1077
+ const scored = candidates.map(c => ({ c, d: editDistance(unknown.toLowerCase(), c) })).sort((x, y) => x.d - y.d);
1078
+ const best = scored[0] && scored[0].d <= Math.max(1, Math.floor(unknown.length / 3)) ? scored[0].c : null;
1079
+ return [
1080
+ `rech: unknown command "${unknown}".${best ? ` Did you mean "${best}"?` : ""}`,
1081
+ ` rech --help rechrome commands (setup, status, profile, url, connect, listener…)`,
1082
+ ` rech pw --help browser commands (open, click, screenshot…)`,
1083
+ ].join("\n");
1084
+ }
1085
+
1086
+ async function run(url: string, args: string[], overrideEnv?: Record<string, string>, opts: { verbatim?: boolean } = {}) {
1045
1087
  // Match the underlying CLI's command names while accepting the short forms humans
1046
1088
  // naturally try. Keep this client-side so old and new serve daemons behave alike.
1047
1089
  args = normalizeCommandArgs(args);
@@ -1060,11 +1102,17 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
1060
1102
 
1061
1103
  const isOpenWithUrl = args[0] === "open" && args.length > 1;
1062
1104
  if (existingSession && isOpenWithUrl) {
1063
- return run(url, ["goto", ...args.slice(1)], overrideEnv);
1105
+ return run(url, ["goto", ...args.slice(1)], overrideEnv, opts);
1064
1106
  }
1065
1107
 
1066
1108
  if (existingSession)
1067
1109
  console.error(`[rech] session already has open tabs — listing existing tabs instead of opening a new window`);
1110
+ // A typo'd command: rech's hint instead of playwright-cli's full usage (kept for `rech pw`).
1111
+ const hint = !opts.verbatim && status !== 0 ? unknownCommandHint(`${stderr ?? ""}\n${stdout ?? ""}`) : null;
1112
+ if (hint) {
1113
+ console.error(hint);
1114
+ process.exit(status || 1);
1115
+ }
1068
1116
  if (stderr) {
1069
1117
  if (stderr.includes('Extension connection timeout')) {
1070
1118
  const hasToken = !!effectiveEnv["PLAYWRIGHT_MCP_EXTENSION_TOKEN"];
@@ -1866,8 +1914,17 @@ async function requireListeners() {
1866
1914
  return config;
1867
1915
  }
1868
1916
 
1917
+ /** Print rows as left-aligned columns; the first row is the header. */
1918
+ function printTable(rows: string[][]): void {
1919
+ const widths = rows[0].map((_, i) => Math.max(...rows.map(r => (r[i] ?? "").length)));
1920
+ for (const r of rows) console.log(r.map((c, i) => (c ?? "").padEnd(widths[i])).join(" ").trimEnd());
1921
+ }
1922
+
1869
1923
  async function listListeners(): Promise<void> {
1870
- for (const l of (await requireListeners()).listeners) console.log(`${l.name} ${listenerAddress(l)}${normalizePrefix(l.prefix)} ${l.profiles === "*" ? "local management (all profiles)" : l.profiles.join(", ")}`);
1924
+ const rows = [["NAME", "ADDRESS", "PROFILES", "PUBLIC URL"]];
1925
+ for (const l of (await requireListeners()).listeners)
1926
+ rows.push([l.name, `${listenerAddress(l)}${normalizePrefix(l.prefix)}`, l.profiles === "*" ? "(all — local management)" : l.profiles.join(", "), l.publicUrl ?? "-"]);
1927
+ printTable(rows);
1871
1928
  }
1872
1929
 
1873
1930
  async function removeListener(name: string): Promise<void> {
@@ -1967,8 +2024,7 @@ async function urlList(): Promise<void> {
1967
2024
  const local = `http://${listenerAddress(l)}${normalizePrefix(l.prefix)}`;
1968
2025
  for (const profile of l.profiles === "*" ? ["(all profiles)"] : l.profiles) rows.push([l.name, profile, local, l.publicUrl ?? "-"]);
1969
2026
  }
1970
- const widths = rows[0].map((_, i) => Math.max(...rows.map(r => r[i].length)));
1971
- for (const r of rows) console.log(r.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd());
2027
+ printTable(rows);
1972
2028
  console.log(`\nPrint a full URL (contains the secret key): rech url <profile> --listener <name>`);
1973
2029
  }
1974
2030
 
@@ -2400,126 +2456,45 @@ async function setup(opts: SetupOptions = {}): Promise<void> {
2400
2456
  async function status(): Promise<void> {
2401
2457
  const url = process.env[ENV_KEY];
2402
2458
  if (!url) {
2403
- console.log(`serve: not configured (run \`rech setup\`)`);
2459
+ console.log(`serve: not configured`);
2460
+ console.log(notConnectedMessage().split("\n").slice(1).join("\n"));
2404
2461
  return;
2405
2462
  }
2406
2463
  const parsed = parseUrl(url);
2407
2464
  const ping = await fetch(serviceUrl(url), { signal: AbortSignal.timeout(2000) }).catch(() => null);
2408
- // Resolve the daemon's actual bind from its authenticated /ping (cross-platform; lsof is
2409
- // POSIX-only and absent on Windows). bind is "0.0.0.0" (all interfaces) or the loopback IP.
2410
- const pingBody = ping
2411
- ? await fetch(serviceUrl(url, "ping"), {
2412
- headers: { Authorization: `Bearer ${parsed.key}` },
2413
- signal: AbortSignal.timeout(2000),
2414
- }).then(r => (r.ok ? r.json() : null)).catch(() => null) as { bind?: string; listener?: string; degraded?: boolean; consecutiveTimeouts?: number } | null
2465
+ // The authenticated /ping reports which listener answered, its bind, and the profiles it allows.
2466
+ const pingResponse = ping
2467
+ ? await fetch(serviceUrl(url, "ping"), { headers: { Authorization: `Bearer ${parsed.key}` }, signal: AbortSignal.timeout(2000) }).catch(() => null)
2468
+ : null;
2469
+ const pingBody = pingResponse?.ok
2470
+ ? await pingResponse.json().catch(() => null) as { bind?: string; listener?: string; profiles?: string[] | "*"; degraded?: boolean; consecutiveTimeouts?: number } | null
2415
2471
  : null;
2416
2472
  // Show the URL this client connects to; through a proxy, the daemon's bind is on another port.
2417
2473
  const details = [pingBody?.listener && `listener ${pingBody.listener}`, pingBody?.bind && `bind ${pingBody.bind}`].filter(Boolean).join(", ");
2418
- console.log(`serve: ${ping ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}` : "not running"}`);
2474
+ console.log(`serve: ${ping ? `running ${serviceUrl(url)}${details ? ` (${details})` : ""}` : `not reachable at ${serviceUrl(url)}`}`);
2475
+ if (pingResponse?.status === 401)
2476
+ console.log(`auth: ✗ key rejected — ask the host for a fresh URL (\`rech url <profile>\`), then \`rech connect '<url>'\``);
2419
2477
  // daemonManager().id — there is no PM_BIN constant. Referencing one threw a
2420
2478
  // ReferenceError that took down the whole of `rech status`, so the one command
2421
2479
  // that reports "the relay is wedged" died exactly when the relay was wedged,
2422
2480
  // printing a stack trace instead of the restart hint.
2423
2481
  if (pingBody?.degraded)
2424
2482
  console.log(`relay: ⚠ degraded (${pingBody.consecutiveTimeouts} consecutive command timeouts) — if it persists, the daemon self-restarts; force it now with \`${daemonManager().id} restart ${PM_PROCESS_NAME}\``);
2425
- const pmOut = await pmList();
2426
- const daemonRegistered = pmOut.includes(PM_PROCESS_NAME);
2427
- console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
2428
- const registry = await readTokenRegistry();
2429
- const entries = Object.entries(registry);
2430
- if (entries.length) {
2431
- console.log(`\nprofiles:`);
2432
- const primaryProfile = parsed.profileDirectory;
2433
- for (const [email, entry] of entries) {
2434
- const isPrimary = email === primaryProfile || entry.profileDir === primaryProfile;
2435
- const marker = isPrimary ? " (primary)" : "";
2436
- console.log(` ${email.padEnd(36)} [${entry.profileDir}] ext: ${entry.extensionId.slice(0, 8)}… token: ${entry.token.slice(0, 8)}…${marker}`);
2437
- }
2438
- } else if (parsed.profileDirectory) {
2439
- // Legacy: no registry yet, show from RECHROME_URL
2440
- const email = await resolveProfileEmail(parsed.profileDirectory).catch(() => parsed.profileDirectory);
2441
- console.log(`\nprofiles:\n ${email} [${parsed.profileDirectory}] (legacy — re-run \`rech setup\` to register)`);
2442
- }
2443
- }
2444
-
2445
- function printHelp(): void {
2446
- console.log(`rechrome (rech) — drive Chrome via Playwright over HTTP
2447
-
2448
- Usage:
2449
- rech [--profile <email|name|folder>] <playwright-args...>
2450
- Run Playwright CLI command with the given registered
2451
- Chrome profile. --profile selects the profile by exact
2452
- registered email (e.g. you@gmail.com), exact Chrome
2453
- profile name, or exact profile folder name. The profile
2454
- must already be registered (see \`rech setup\`). Place
2455
- --profile before the playwright subcommand. Requires
2456
- ${ENV_KEY}.
2457
- rech setup [--listen <local|lan|tailscale|IP>] [--profile <email|name|folder>] [--token <tok>] [--prefix <path>] [--port <port>] [--yes]
2458
- First-time setup: daemon + Chrome extension + config
2459
- --prefix=rechrome mounts at /rechrome/ on a scoped listener.
2460
- Prefixed setup defaults to the management port + 1; override with --port.
2461
- Offers to install missing oxmgr globally (y/N).
2462
- --yes approves installation without prompting.
2463
- --profile selects the Chrome profile non-interactively.
2464
- Menu numbers are not accepted. Resolution order is exact
2465
- email (e.g. you@gmail.com), exact Chrome profile name,
2466
- then exact profile folder name (e.g. "Profile 1"). See
2467
- available values with \`rech profile\`.
2468
- --token (or RECH_TOKEN) supplies the auth token for
2469
- non-TTY/agent runs, skipping the interactive paste
2470
- rech provision-profile <name> --experimental [--headed]
2471
- (experimental) Auto-provision a managed QA profile on
2472
- Chrome for Testing — branded Chrome 149+ rejects
2473
- --load-extension, so this is a clean browser, not your
2474
- real Chrome. For your real Chrome, use \`rech setup\`
2475
- rech status Show current configuration and serve health
2476
- rech tray [show|hide|stop] Native menu-bar/tray icon for the serve daemon
2477
- (show=start, hide/show toggle, stop=quit). Auto-
2478
- starts after \`rech setup\`; skipped with no GUI
2479
- rech uninstall Remove the serve daemon and clear config
2480
- rech serve Start the serve server manually (foreground)
2481
- rech listener [ls|add|remove] Manage daemon listener addresses and allowed profiles
2482
- rech listener allow|deny <name> <profile...>
2483
- Add or remove profiles on an existing listener
2484
- rech listener port [name] Print a listener's port, for a reverse-proxy command
2485
- rech listener set <name> --public-url <url>
2486
- Record where a proxy exposes the listener (rech url uses it)
2487
- rech listener rotate-key <name>
2488
- New key for a listener; URLs with the old key stop working
2489
- rech profile [ls|list]
2490
- List Chrome + managed test profiles and connection status
2491
- rech url [profile] [--listener <name>] [--local] [--save]
2492
- Print a connection URL (includes the secret key): the public
2493
- URL when one is set, else the listener address. --save also
2494
- writes it to this project's .rechrome/.env.local.
2495
- \`rech profile [name] --print-uri\` is an alias.
2496
- rech url ls List every listener × profile URL (keys hidden)
2497
- rech connect <url> Check a shared URL answers, then save it for this project
2498
- rech <playwright-args...> Run Playwright CLI command (requires ${ENV_KEY})
2499
- rech pw <playwright-args...> Forward verbatim to playwright-cli, even when a name clashes
2500
- with rech's own (rech pw --version, rech pw status)
2501
- rech --version rechrome's version
2502
- rech --isolate <args...> Run in a throwaway session (sugar for -s=<random>) so a
2503
- fragile single-shot flow (OAuth/login) never shares tabs
2504
- with the worktree's default session
2505
-
2506
- Environment:
2507
- ${ENV_KEY} Server URL set by \`rech setup\`
2508
- RECH_TOKEN Auth token for \`rech setup\` (same as --token)
2509
- RECH_IDENTITY Session bucket mode: worktree (default) | branch | cwd. The session a
2510
- client reuses is keyed on the worktree root path; \`branch\` restores the
2511
- old <remote>/tree/<branch> keying, \`cwd\` keys on the exact directory
2512
- RECH_SETUP_AGENT Setup hints: codex | claude | none (otherwise auto-detected)
2513
-
2514
- Examples:
2515
- rech setup
2516
- rech setup --profile you@gmail.com --token <PLAYWRIGHT_MCP_EXTENSION_TOKEN>
2517
- rech --profile you@gmail.com open https://example.com
2518
- rech eval "() => document.title"
2519
- rech open https://example.com
2520
- rech screenshot`);
2483
+ // The daemon line is about this machine; a client of a remote host has no local daemon to report.
2484
+ const isHost = !!(await readListeners().catch(() => null));
2485
+ if (isHost) {
2486
+ const daemonRegistered = (await pmList()).includes(PM_PROCESS_NAME);
2487
+ console.log(`daemon: ${daemonRegistered ? `${daemonManager().id} (${PM_PROCESS_NAME})` : "not installed"}`);
2488
+ }
2489
+ // Same resolution as a command: ?profile= in the URL, else PLAYWRIGHT_MCP_PROFILE_DIRECTORY.
2490
+ const effective = resolveEffectiveProfile(parsed.profileDirectory);
2491
+ const current = effective ? await resolveProfileEmail(effective).catch(() => effective) : undefined;
2492
+ const allowed = pingBody?.profiles === "*" ? "all registered profiles" : pingBody?.profiles?.join(", ");
2493
+ console.log(`profile: ${current ?? "(none selected; add ?profile= to the URL or pass --profile)"}${allowed ? ` — this listener serves: ${allowed}` : ""}`);
2494
+ if (isHost) console.log(`\nMore: rech profile (profiles) · rech url ls (who can connect, and where)`);
2521
2495
  }
2522
2496
 
2497
+
2523
2498
  export type SetupOptions = { profile?: string; token?: string; listen?: string; prefix?: string; port?: number; yes?: boolean };
2524
2499
  export type RechHandlers = {
2525
2500
  serve(): Promise<void> | void;
@@ -2547,77 +2522,111 @@ export const RECH_COMMANDS = new Set(["serve", "status", "listener", "listeners"
2547
2522
 
2548
2523
  const portOption = { type: "number", requiresArg: true, describe: "Listener port (1-65535)" } as const;
2549
2524
 
2525
+ // yargs trims indentation in .usage(), so the indented browser-command block lives in the epilogue.
2526
+ const HELP_USAGE = `rechrome (rech) — drive your real, logged-in Chrome from scripts, agents and other machines
2527
+
2528
+ Usage: rech <command> [options] · rech <browser-command> [args]`;
2529
+
2530
+ const HELP_EPILOGUE = `Browser commands (sent to this project's Chrome session):
2531
+ rech [--profile <p>] [--isolate] <browser-command> [args]
2532
+ open, goto, click, fill, screenshot, eval, tab-list… (\`rech pw --help\` lists all)
2533
+ --profile <p> as another registered profile (email, name or folder); put it first
2534
+ --isolate in a throwaway session, e.g. for a login flow
2535
+ rech pw <args> forward verbatim to playwright-cli, e.g. \`rech pw --version\`
2536
+ rech --version rechrome's version
2537
+
2538
+ Environment:
2539
+ ${ENV_KEY} connection URL; read from the nearest .rechrome/.env.local or .env.local
2540
+ RECH_IDENTITY session key: worktree (default) | branch | cwd
2541
+ RECH_TOKEN extension token for \`rech setup\` (same as --token)
2542
+ RECH_SETUP_AGENT setup hints: codex | claude | none (auto-detected)
2543
+
2544
+ Examples:
2545
+ rech setup --profile you@example.com set up Chrome on this machine
2546
+ rech open https://example.com open a page in this project's session
2547
+ rech screenshot saved to <project>/.rechrome/output/
2548
+ rech url you@example.com --listener share URL to give another machine (secret)
2549
+ rech connect '<url>' use that URL in this project
2550
+
2551
+ Run \`rech <command> --help\` for a command's options. Tutorial: https://github.com/snomiao/rechrome#tutorial`;
2552
+
2550
2553
  export function rechCli(argv: string[], handlers: RechHandlers) {
2551
2554
  return yargs(argv)
2552
2555
  .scriptName("rech")
2556
+ .usage(HELP_USAGE)
2557
+ .epilogue(HELP_EPILOGUE)
2558
+ .wrap(Math.min(110, process.stdout.columns || 110))
2553
2559
  .parserConfiguration({ "parse-numbers": false, "parse-positional-numbers": false })
2554
- .command("serve", "Run the rechrome daemon in the foreground", {}, () => handlers.serve())
2555
- .command("status", "Show daemon, relay and profile connection status", {}, () => handlers.status())
2556
- .command(["listener", "listeners"], "Manage network listeners", y => y
2557
- .command(["ls", "list", "$0"], "List listeners (credentials hidden)", {}, () => handlers.listListeners())
2558
- .command("add <name>", "Expose registered profiles on a network", y => y
2560
+ // Set up and inspect this machine
2561
+ .command("setup", "Set up this machine: daemon, Chrome extension, connection", y => y
2562
+ .option("profile", { type: "string", requiresArg: true, describe: "Chrome profile: exact email, Chrome profile name, or folder (e.g. \"Profile 1\"); not menu numbers" })
2563
+ .option("token", { type: "string", requiresArg: true, describe: "Extension token, for headless runs (default: read from the profile, or RECH_TOKEN)" })
2564
+ .option("listen", { type: "string", requiresArg: true, describe: "Who can reach this profile: local (default) | lan | tailscale | <detected IP>" })
2565
+ .option("prefix", { type: "string", requiresArg: true, describe: "URL path for a proxied listener, e.g. rechrome (port defaults to the management port + 1)" })
2566
+ .option("port", portOption)
2567
+ .option("yes", { alias: "y", type: "boolean", default: false, describe: "Approve installing a missing oxmgr without prompting" }),
2568
+ a => handlers.setup({ profile: a.profile, token: a.token ?? process.env.RECH_TOKEN, listen: a.listen, prefix: a.prefix, port: a.port, yes: a.yes }))
2569
+ .command("status", "Is it working? The URL in use, the daemon, and the current profile", {}, () => handlers.status())
2570
+ .command(["profile [name]", "profiles [name]"], "List Chrome profiles and whether each is connected", y => y
2571
+ .positional("name", { type: "string", describe: "ls/list lists all (the default)" })
2572
+ .option("print-uri", { type: "boolean", describe: "Same as `rech url <name>`" })
2573
+ .option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URL for" }),
2574
+ a => {
2575
+ if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech url`
2576
+ if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
2577
+ throw new Error(`To print "${a.name}"'s connection URL: rech url ${JSON.stringify(a.name)}. To list profiles: rech profile`);
2578
+ })
2579
+ // Share with and connect from other machines
2580
+ .command(["url [profile]", "urls [profile]"], "Print a connection URL to share (contains a secret key); `url ls` lists all", y => y
2581
+ .positional("profile", { type: "string", describe: "Profile (email, name or folder); ls/list lists every listener's URLs" })
2582
+ .option("listener", { type: "string", requiresArg: true, describe: "Listener to build the URL for" })
2583
+ .option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
2584
+ .option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" }),
2585
+ a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save
2586
+ ? handlers.urlList()
2587
+ : handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
2588
+ .command("connect <url>", "Use a URL from another machine in this project (checks it first)", y => y
2589
+ .positional("url", { type: "string", demandOption: true }),
2590
+ a => handlers.connect(a.url))
2591
+ .command(["listener", "listeners"], "Control who can connect: listeners, allowed profiles, keys, public URLs", y => y
2592
+ .command(["ls", "list", "$0"], "List listeners (keys hidden)", {}, () => handlers.listListeners())
2593
+ .command("add <name>", "Expose registered profiles on an address (local for a proxy, lan, tailscale, IP)", y => y
2559
2594
  .positional("name", { type: "string", demandOption: true })
2560
2595
  .option("listen", { type: "string", requiresArg: true, demandOption: true, describe: "local | lan | tailscale | <detected IP>" })
2561
2596
  .option("profile", { type: "string", array: true, requiresArg: true, demandOption: true, describe: "Allowed profile; repeat for several" })
2562
2597
  .option("port", portOption)
2563
2598
  .option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix, e.g. rechrome" }),
2564
2599
  a => handlers.addListener(a.name, { listen: a.listen, profile: a.profile, port: a.port, prefix: a.prefix }))
2565
- .command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
2566
- a => handlers.removeListener(a.name))
2567
- .command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
2568
- a => handlers.listenerPort(a.name))
2569
2600
  .command("allow <name> <profiles..>", "Allow more profiles on a listener", y => y
2570
2601
  .positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
2571
2602
  a => handlers.allowListener(a.name, a.profiles))
2572
2603
  .command("deny <name> <profiles..>", "Remove profiles from a listener", y => y
2573
2604
  .positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
2574
2605
  a => handlers.denyListener(a.name, a.profiles))
2575
- .command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
2576
- a => handlers.rotateKey(a.name))
2577
2606
  .command("set <name>", "Record where a reverse proxy exposes a listener", y => y
2578
2607
  .positional("name", { type: "string", demandOption: true })
2579
2608
  .option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.ts.net/rechrome/" })
2580
2609
  .option("clear-public-url", { type: "boolean", conflicts: "public-url" })
2581
2610
  .check(a => a.publicUrl !== undefined || a.clearPublicUrl ? true : "Pass --public-url <url> or --clear-public-url"),
2582
2611
  a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl }))
2612
+ .command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
2613
+ a => handlers.listenerPort(a.name))
2614
+ .command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
2615
+ a => handlers.rotateKey(a.name))
2616
+ .command("remove <name>", "Remove a listener", y => y.positional("name", { type: "string", demandOption: true }),
2617
+ a => handlers.removeListener(a.name))
2583
2618
  .demandCommand(1).strict())
2584
- .command(["url [profile]", "urls [profile]"], "Print a profile's connection URL (contains a secret key); `url ls` lists them", y => y
2585
- .positional("profile", { type: "string", describe: "Profile (email, name or folder); ls/list lists all listeners' URLs" })
2586
- .option("listener", { type: "string", requiresArg: true, describe: "Listener to build the URL for" })
2587
- .option("local", { type: "boolean", describe: "Print the direct listener address even when a public URL is set" })
2588
- .option("save", { type: "boolean", describe: "Also save it as RECHROME_URL in this project's .rechrome/.env.local" }),
2589
- a => ["ls", "list"].includes(a.profile ?? "") && !a.listener && !a.save
2590
- ? handlers.urlList()
2591
- : handlers.printProfileUri(a.profile, a.listener, { local: a.local, save: a.save }))
2592
- .command("connect <url>", "Check a shared connection URL and save it for this project", y => y
2593
- .positional("url", { type: "string", demandOption: true }),
2594
- a => handlers.connect(a.url))
2595
- .command(["profile [name]", "profiles [name]"], "List profiles, or print a profile's connection URI", y => y
2596
- .positional("name", { type: "string", describe: "Profile (email, name or folder); ls/list lists all" })
2597
- .option("print-uri", { type: "boolean", describe: "Print the profile's connection URI (contains a secret key)" })
2598
- .option("listener", { type: "string", requiresArg: true, implies: "print-uri", describe: "Listener to build the URI for" }),
2599
- a => {
2600
- if (a.printUri) return handlers.printProfileUri(a.name, a.listener); // alias of `rech url`
2601
- if (a.name === undefined || ["ls", "list"].includes(a.name)) return handlers.listProfiles();
2602
- throw new Error("Usage: rech profile [ls|list] | rech profile [name] --print-uri. Create, rename, and delete are not implemented.");
2603
- })
2604
- .command("setup", "Install the daemon and connect a Chrome profile", y => y
2605
- .option("profile", { type: "string", requiresArg: true, describe: "Chrome profile: email, name or folder" })
2606
- .option("token", { type: "string", requiresArg: true, describe: "Extension token (default: read from the profile, or RECH_TOKEN)" })
2607
- .option("listen", { type: "string", requiresArg: true, describe: "local | lan | tailscale | <detected IP>" })
2608
- .option("prefix", { type: "string", requiresArg: true, describe: "URL path prefix for a scoped listener, e.g. rechrome" })
2609
- .option("port", portOption)
2610
- .option("yes", { alias: "y", type: "boolean", default: false, describe: "Approve installing a missing oxmgr without prompting" }),
2611
- a => handlers.setup({ profile: a.profile, token: a.token ?? process.env.RECH_TOKEN, listen: a.listen, prefix: a.prefix, port: a.port, yes: a.yes }))
2612
- .command("tray [action]", "Show or hide the tray icon", y => y
2619
+ // Daemon and extras
2620
+ .command("tray [action]", "Menu-bar icon for the daemon (starts after setup)", y => y
2613
2621
  .positional("action", { type: "string", choices: ["show", "start", "hide", "stop", "quit"] }),
2614
2622
  a => handlers.tray(a.action))
2615
- .command("provision-profile <name>", "Create a managed Chrome-for-Testing profile (experimental)", y => y
2623
+ .command("provision-profile <name>", "(experimental) Clean Chrome-for-Testing profile, fully automated; not your real Chrome", y => y
2616
2624
  .positional("name", { type: "string", demandOption: true })
2617
2625
  .option("experimental", { type: "boolean", default: false })
2618
2626
  .option("headed", { type: "boolean", default: false }),
2619
2627
  a => handlers.provisionProfile(a.name, { headed: a.headed, experimental: a.experimental }))
2620
- .command("uninstall", "Stop and remove the rechrome daemon", {}, () => handlers.uninstall())
2628
+ .command("uninstall", "Stop and remove the daemon", {}, () => handlers.uninstall())
2629
+ .command("serve", "Run the daemon in the foreground (normally managed by oxmgr)", {}, () => handlers.serve())
2621
2630
  .demandCommand(1)
2622
2631
  .strict()
2623
2632
  .help()
@@ -2629,32 +2638,33 @@ if (import.meta.main) {
2629
2638
  let args = process.argv.slice(2);
2630
2639
  const cmd = args[0]?.toLowerCase();
2631
2640
 
2641
+ const handlers: RechHandlers = {
2642
+ serve: async () => { const { serve } = await import("./serve.ts"); serve(); }, // long-lived; watcher intentionally kept alive
2643
+ status,
2644
+ listListeners, addListener, removeListener, listProfiles, printProfileUri,
2645
+ urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
2646
+ setup: async (opts) => {
2647
+ await setup(opts); // setup closes envWatcher itself before printing Done
2648
+ // Auto-start the tray (best-effort, silent on headless / missing binary).
2649
+ await startTray({ quiet: true }).catch(() => {});
2650
+ },
2651
+ tray: trayCommand,
2652
+ provisionProfile: async (name, { headed, experimental }) => {
2653
+ // Experimental: a managed profile runs on Chrome for Testing, not the user's real Google Chrome
2654
+ // (branded Chrome 149+ rejects --load-extension). It's a clean browser with no logins/cookies,
2655
+ // so it's gated behind --experimental rather than offered as the default setup path.
2656
+ if (!experimental) throw new Error([
2657
+ `provision-profile is experimental and creates a Chrome-for-Testing profile (not your`,
2658
+ `real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`,
2659
+ `can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`,
2660
+ `To proceed anyway, re-run with --experimental.`,
2661
+ ].join("\n"));
2662
+ await provisionProfile(name, { headed });
2663
+ },
2664
+ uninstall: daemonUninstall,
2665
+ };
2666
+
2632
2667
  if (cmd && RECH_COMMANDS.has(cmd)) {
2633
- const handlers: RechHandlers = {
2634
- serve: async () => { const { serve } = await import("./serve.ts"); serve(); }, // long-lived; watcher intentionally kept alive
2635
- status,
2636
- listListeners, addListener, removeListener, listProfiles, printProfileUri,
2637
- urlList, connect, listenerPort, allowListener, denyListener, rotateKey, setListener,
2638
- setup: async (opts) => {
2639
- await setup(opts); // setup closes envWatcher itself before printing Done
2640
- // Auto-start the tray (best-effort, silent on headless / missing binary).
2641
- await startTray({ quiet: true }).catch(() => {});
2642
- },
2643
- tray: trayCommand,
2644
- provisionProfile: async (name, { headed, experimental }) => {
2645
- // Experimental: a managed profile runs on Chrome for Testing, not the user's real Google Chrome
2646
- // (branded Chrome 149+ rejects --load-extension). It's a clean browser with no logins/cookies,
2647
- // so it's gated behind --experimental rather than offered as the default setup path.
2648
- if (!experimental) throw new Error([
2649
- `provision-profile is experimental and creates a Chrome-for-Testing profile (not your`,
2650
- `real Chrome): branded Google Chrome 149+ rejects --load-extension, so a managed profile`,
2651
- `can't reuse your logged-in Chrome. For your real Chrome use: rech setup --profile <email|name|folder>`,
2652
- `To proceed anyway, re-run with --experimental.`,
2653
- ].join("\n"));
2654
- await provisionProfile(name, { headed });
2655
- },
2656
- uninstall: daemonUninstall,
2657
- };
2658
2668
  try {
2659
2669
  await rechCli([cmd, ...args.slice(1)], handlers).parseAsync();
2660
2670
  } catch (error) {
@@ -2667,13 +2677,12 @@ if (import.meta.main) {
2667
2677
  console.log(rechromeVersion()); // playwright-cli's own: rech pw --version
2668
2678
  envWatcher?.close();
2669
2679
  } else if (cmd === "help" || cmd === "--help" || cmd === "-h" || args.length === 0) {
2670
- printHelp();
2671
- envWatcher?.close();
2680
+ try { await rechCli(["--help"], handlers).parseAsync(); }
2681
+ finally { envWatcher?.close(); }
2672
2682
  } else {
2673
2683
  const url = process.env[ENV_KEY];
2674
2684
  if (!url) {
2675
- console.error(`${ENV_KEY} is not set. Run \`rech setup\` to configure.\n`);
2676
- printHelp();
2685
+ console.error(notConnectedMessage());
2677
2686
  process.exit(1);
2678
2687
  }
2679
2688
  // --profile: target a registered Chrome profile globally (see extractGlobalProfileArg for
@@ -2724,7 +2733,7 @@ if (import.meta.main) {
2724
2733
  args.push(`-s=iso-${randomBytes(8).toString("hex")}`);
2725
2734
  }
2726
2735
  args = [...forwarded, ...args];
2727
- await run(url, args, overrideEnv);
2736
+ await run(url, args, overrideEnv, { verbatim: separator !== -1 });
2728
2737
  envWatcher?.close();
2729
2738
  }
2730
2739
  }