@dassi_ai/cli 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dassi",
3
3
  "description": "Drive the Dassi Chrome extension from Claude Code. Pick tabs/groups, then run AI agent or browser tools against them.",
4
- "version": "0.1.0",
4
+ "version": "0.1.3",
5
5
  "author": {
6
6
  "name": "Omnify Labs",
7
7
  "email": "team@dassi.ai"
package/README.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  Standalone CLI for the [Dassi](../extension/README.md) Chrome extension — run browser automation from the terminal.
4
4
 
5
+ ## Quickstart
6
+
7
+ ```bash
8
+ # 1. Install the Dassi extension from the Chrome Web Store and sign in.
9
+ # 2. Open the Dassi side panel once — this wakes the extension so the CLI can reach it.
10
+ # 3. Run a command (no install needed):
11
+ npx @dassi_ai/cli@latest list-tabs # find a TAB ID
12
+ npx @dassi_ai/cli@latest run "summarize this page" --tab <id>
13
+ ```
14
+
15
+ Prefer a permanent `dassi` command? `npm install -g @dassi_ai/cli` (Node ≥ 20.11), then drop the `npx @dassi_ai/cli@latest` prefix.
16
+
17
+ Two things that trip people up:
18
+ - **`run` needs a target** — pass `--tab <id>` (or `--group <id>` / `--group-title <name>`). `--profile` only selects *which* connected Chrome, not the tab.
19
+ - **Multiple Chrome windows/profiles connected?** Commands need `--profile <label>`; run `dassi list-profiles` to see the labels (one profile = no flag needed).
20
+
5
21
  ## Installation
6
22
 
7
23
  ```bash
@@ -54,6 +70,29 @@ dassi list-profiles
54
70
  dassi list-tabs --profile dev
55
71
  dassi run "summarize" --tab 456 --profile dev # alias: --label
56
72
 
73
+ # Launch a fresh Chrome with a locally-built dev dist (for testing extension changes)
74
+ pnpm build # build extension/dist first
75
+ dassi launch # loads extension/dist as profile "dev"
76
+ dassi launch --label qa --dist some/dist # custom label + dist
77
+ # Drive the launched Chrome — run still needs a tab/group target; --profile selects which Chrome:
78
+ dassi list-tabs --profile dev # find a tab id in the launched profile
79
+ dassi run "summarize this page" --tab <id> --profile dev
80
+ dassi launch --stop # close the "dev" Chrome (or --stop-all)
81
+ # Note: launch reuses the default daemon (port 18790). To launch on an isolated
82
+ # port (DASSI_BRIDGE_PORT=18791 dassi launch), no default daemon may be running —
83
+ # it errors clearly otherwise, since it can't confirm the running daemon's port.
84
+ #
85
+ # How the extension is loaded (auto-detected per Chrome binary):
86
+ # • Branded Google Chrome (137+) disabled the `--load-extension` flag, so launch
87
+ # installs the dist at runtime via the `Extensions.loadUnpacked` CDP command over
88
+ # `--remote-debugging-pipe`. Because such an extension lives only as long as the
89
+ # debugging pipe, launch spawns a detached `__launch-hold` helper that keeps the
90
+ # pipe open; `--stop` kills the helper, which closes the pipe and its Chrome.
91
+ # • Chrome for Testing / Chromium still honour `--load-extension` (persistent), so
92
+ # launch uses that directly there — no helper. Point at one with `--chrome <path>`.
93
+ # • The mode is auto-detected from the binary; override with `--load-mode auto|pipe|flag`
94
+ # (e.g. `dassi launch --chrome <cft> --load-mode pipe` to exercise the pipe path on CfT).
95
+
57
96
  # Show version / help
58
97
  dassi --version
59
98
  dassi --help
@@ -0,0 +1,102 @@
1
+ /**
2
+ * CDP-over-pipe transport for `dassi launch`. Chrome 137+ branded builds disabled the
3
+ * `--load-extension` flag, so the dist is installed at runtime via the
4
+ * `Extensions.loadUnpacked` CDP command over `--remote-debugging-pipe`. This module is
5
+ * the minimal, dependency-free client for that exchange.
6
+ *
7
+ * PRIVATE (underscore-prefixed): import these symbols from `launch.mjs`, which re-exports
8
+ * them. `launch.mjs` is the single public gateway for the launch module.
9
+ */
10
+
11
+ /**
12
+ * Minimal CDP client over Chrome's `--remote-debugging-pipe` transport. Messages are
13
+ * JSON framed with a trailing NUL (`\0`) byte; replies are matched back by `id`.
14
+ * No external deps — the CLI only needs to issue a couple of commands at launch.
15
+ * @param {{ writable: import('stream').Writable; readable: import('stream').Readable }} pipe
16
+ * `writable` = Chrome's CDP input (fd 3); `readable` = Chrome's CDP output (fd 4).
17
+ * @returns {{ send: (method: string, params?: object) => Promise<object>; dispose: () => void }}
18
+ */
19
+ export function cdpPipeClient({ writable, readable }) {
20
+ let nextId = 1;
21
+ const pending = new Map();
22
+ let buf = Buffer.alloc(0);
23
+ const onData = (chunk) => {
24
+ buf = Buffer.concat([buf, chunk]);
25
+ let nul;
26
+ // Reason: a single chunk may carry several NUL-delimited frames, or a partial one —
27
+ // drain every complete frame and keep the remainder buffered for the next chunk.
28
+ while ((nul = buf.indexOf(0)) !== -1) {
29
+ const frame = buf.subarray(0, nul).toString('utf8');
30
+ buf = buf.subarray(nul + 1);
31
+ if (!frame) continue;
32
+ let msg;
33
+ try { msg = JSON.parse(frame); } catch { continue; }
34
+ const entry = msg.id != null ? pending.get(msg.id) : undefined;
35
+ if (!entry) continue; // an event, or a reply we're not waiting on
36
+ pending.delete(msg.id);
37
+ if (msg.error) entry.reject(new Error(msg.error.message || `CDP error ${msg.error.code ?? ''}`));
38
+ else entry.resolve(msg.result ?? {});
39
+ }
40
+ };
41
+ readable.on('data', onData);
42
+ return {
43
+ send(method, params = {}) {
44
+ const id = nextId++;
45
+ return new Promise((resolve, reject) => {
46
+ pending.set(id, { resolve, reject });
47
+ writable.write(`${JSON.stringify({ id, method, params })}\0`);
48
+ });
49
+ },
50
+ dispose() {
51
+ readable.off('data', onData);
52
+ for (const { reject } of pending.values()) reject(new Error('CDP pipe disposed'));
53
+ pending.clear();
54
+ },
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Install the unpacked dist into the just-launched Chrome over the CDP pipe, then
60
+ * open the seed options page (so the extension persists label+bridgePort and dials
61
+ * the daemon). This is the runtime replacement for the disabled `--load-extension`.
62
+ *
63
+ * On success the pipe is left OPEN (still draining Chrome's CDP output so its buffer
64
+ * can't fill) and a `dispose()` is returned — the caller MUST call it once the
65
+ * profile has registered. Reason: branded Chrome discards an automation-created tab
66
+ * (the seed page) if the debugging pipe closes before it finishes loading; keeping
67
+ * the pipe alive until the bridge registers lets the seed run.
68
+ * @param {{ child: import('child_process').ChildProcess; distPath: string; label: string; bridgePort: number; timeoutMs?: number }} opts
69
+ * @returns {Promise<{ id: string; dispose: () => void }>} the loaded extension id + a pipe closer.
70
+ * @throws if the pipe fds are missing or a CDP command errors/times out.
71
+ */
72
+ export async function loadExtensionOverPipe({ child, distPath, label, bridgePort, timeoutMs = 15000 }) {
73
+ const writable = child.stdio?.[3];
74
+ const readable = child.stdio?.[4];
75
+ if (!writable || !readable) {
76
+ throw new Error('Chrome was not spawned with a CDP pipe (fds 3/4 missing).');
77
+ }
78
+ const cdp = cdpPipeClient({ writable, readable });
79
+ // Reason: capture + clear the timer so a fast-resolving CDP command doesn't leave a
80
+ // pending setTimeout keeping the event loop alive for the full timeoutMs.
81
+ const withTimeout = (p, what) => {
82
+ let timer;
83
+ const timeout = new Promise((_, r) => {
84
+ timer = setTimeout(() => r(new Error(`CDP ${what} timed out after ${timeoutMs}ms`)), timeoutMs);
85
+ });
86
+ return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
87
+ };
88
+ try {
89
+ const loaded = await withTimeout(cdp.send('Extensions.loadUnpacked', { path: distPath }), 'Extensions.loadUnpacked');
90
+ const id = loaded.id;
91
+ if (!id) throw new Error('Extensions.loadUnpacked returned no extension id.');
92
+ // Reason: open the seed page AFTER the extension exists (it isn't loaded at
93
+ // startup anymore), so the options page persists label+bridgePort and the
94
+ // extension's bridge connects to the daemon's actual port.
95
+ const seed = `chrome-extension://${id}/options.html?label=${encodeURIComponent(label)}&bridgePort=${bridgePort}`;
96
+ await withTimeout(cdp.send('Target.createTarget', { url: seed }), 'Target.createTarget');
97
+ return { id, dispose: () => cdp.dispose() };
98
+ } catch (err) {
99
+ cdp.dispose();
100
+ throw err;
101
+ }
102
+ }
package/dassi-daemon.mjs CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  buildReadyPayload,
26
26
  createCommandQueue,
27
27
  createConnectionRegistry,
28
+ getDaemonBridgePort,
28
29
  } from './dassi-shared.mjs';
29
30
 
30
31
  // ─── Constants ────────────────────────────────────────────────────────────────
@@ -153,17 +154,12 @@ async function handleRegistration(ws, data, ctx, resolve, reject, port) {
153
154
  }
154
155
  }
155
156
 
156
- // Reason: Port is configurable via DASSI_BRIDGE_PORT so the benchmark harness
157
- // can run a daemon that doesn't conflict with the developer's real Chrome
158
- // daemon. Defaults to 18790 the production port that the prod extension
159
- // SW connects to.
160
- // Validate so an empty/garbage env var (DASSI_BRIDGE_PORT="") doesn't yield NaN,
161
- // which `new WSServer({ port: NaN })` would silently accept and bind to a random
162
- // ephemeral port — leaving the extension's known-port connect attempt hanging.
163
- const _rawPort = parseInt(process.env.DASSI_BRIDGE_PORT ?? '', 10);
164
- const BRIDGE_PORT = Number.isInteger(_rawPort) && _rawPort > 0 && _rawPort <= 65535
165
- ? _rawPort
166
- : 18790;
157
+ // Reason: Port is configurable via DASSI_BRIDGE_PORT (single source of truth in
158
+ // getDaemonBridgePort also seeded into launched extensions by `dassi launch`).
159
+ // Defaults to 18790, the production port. The validation guards against an
160
+ // empty/garbage env var yielding NaN, which `new WSServer({ port: NaN })` would
161
+ // silently accept and bind to a random ephemeral port.
162
+ const BRIDGE_PORT = getDaemonBridgePort();
167
163
 
168
164
  /**
169
165
  * Starts a WebSocket server on the configured bridge port (default 18790).
package/dassi-shared.mjs CHANGED
@@ -11,6 +11,15 @@ import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as os from 'os';
13
13
 
14
+ // ─── Extension identity ───────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Stable Dassi extension ID. The extension manifest ships a fixed `key`, so the
18
+ * unpacked dev build gets the SAME id as the Web Store build regardless of load
19
+ * path — letting `dassi launch` open the extension's options page directly.
20
+ */
21
+ export const DASSI_EXTENSION_ID = 'bjcngahpcjeililljmfegmlanlpgibdi';
22
+
14
23
  // ─── Path helpers ─────────────────────────────────────────────────────────────
15
24
 
16
25
  /**
@@ -53,6 +62,31 @@ export function getReadyFile(session) {
53
62
  return path.join(getAppDir(), `${session}.ready`);
54
63
  }
55
64
 
65
+ /**
66
+ * Resolve the bridge (WebSocket) port the daemon listens on. Configurable via
67
+ * DASSI_BRIDGE_PORT (e.g. the benchmark harness uses 18791 to isolate from a
68
+ * developer's real Chrome on 18790); falls back to 18790 on empty/invalid input.
69
+ * Single source of truth shared by the daemon (which binds it) and `dassi launch`
70
+ * (which seeds it into the launched extension so it connects to the same port).
71
+ * @param {Record<string, string|undefined>} [env]
72
+ * @returns {number} A valid TCP port (1–65535).
73
+ */
74
+ export const DEFAULT_BRIDGE_PORT = 18790;
75
+
76
+ export function getDaemonBridgePort(env = process.env) {
77
+ const p = parseInt(env.DASSI_BRIDGE_PORT ?? '', 10);
78
+ return Number.isInteger(p) && p > 0 && p <= 65535 ? p : DEFAULT_BRIDGE_PORT;
79
+ }
80
+
81
+ /**
82
+ * Path to the JSON file tracking Chrome instances started by `dassi launch`,
83
+ * so `dassi launch --stop` can find and kill them.
84
+ * @returns {string} Absolute path to launches.json under the app dir.
85
+ */
86
+ export function getLaunchesFile() {
87
+ return path.join(getAppDir(), 'launches.json');
88
+ }
89
+
56
90
  // ─── Session validation ───────────────────────────────────────────────────────
57
91
 
58
92
  /**
package/dassi.mjs CHANGED
@@ -15,10 +15,15 @@ import {
15
15
  isDaemonRunning,
16
16
  parseReadyPayload,
17
17
  validateSession,
18
+ DASSI_EXTENSION_ID,
19
+ getAppDir,
20
+ getLaunchesFile,
18
21
  } from './dassi-shared.mjs';
22
+ import { dispatchLaunch } from './launch.mjs';
19
23
  import { parseToolCommand, requireTarget, parseStrictInt } from './tool-commands.mjs';
20
24
  import { runWithGroupExpansion } from './group-expansion.mjs';
21
25
  import { formatResponse } from './format-response.mjs';
26
+ import { HELP_TEXT } from './help-text.mjs';
22
27
 
23
28
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
29
  const DAEMON_SCRIPT = path.join(__dirname, 'dassi-daemon.mjs');
@@ -27,7 +32,7 @@ const READY_POLL_MS = 100;
27
32
  const READY_TIMEOUT_MS = 30_000;
28
33
  const LOGIN_POLL_MS = 2_000;
29
34
  const LOGIN_TIMEOUT_MS = 5 * 60_000;
30
- const CHROME_WEB_STORE_URL = 'https://chromewebstore.google.com/detail/dassi-ai-browser-agent-fo/bjcngahpcjeililljmfegmlanlpgibdi';
35
+ const CHROME_WEB_STORE_URL = `https://chromewebstore.google.com/detail/dassi-ai-browser-agent-fo/${DASSI_EXTENSION_ID}`;
31
36
 
32
37
  // ─── Arg parsing ──────────────────────────────────────────────────────────────
33
38
 
@@ -80,6 +85,44 @@ export function parseCliArgs(argv) {
80
85
  return finish('list_profiles', {});
81
86
  }
82
87
 
88
+ if (command === 'launch') {
89
+ if (consumeFlag(args, '--stop-all')) return finish('launch_stop', { all: true });
90
+ const stopIdx = args.indexOf('--stop');
91
+ if (stopIdx !== -1) {
92
+ args.splice(stopIdx, 1);
93
+ // Reason: resolve the stop label like the start path does — a positional
94
+ // token after --stop wins, else the global --label/--profile (parsed into
95
+ // `profile` before this branch), else the default. Keeps `--stop --label qa`
96
+ // symmetric with `launch --label qa` instead of silently targeting "dev".
97
+ const positional = args[0] && !args[0].startsWith('--') ? args.shift() : null;
98
+ return finish('launch_stop', { label: positional ?? profile ?? 'dev', all: false });
99
+ }
100
+ const timeoutRaw = getFlag(args, '--timeout');
101
+ // Reason: --label is consumed early as a global flag (aliased to --profile).
102
+ // The launch branch reads it from `profile` as the canonical source; any
103
+ // remaining --label token in args is a second occurrence and takes precedence.
104
+ const launchLabel = getFlag(args, '--label') ?? profile ?? 'dev';
105
+ // Reason: the label becomes a path segment (per-label profile dir) + the seed URL,
106
+ // so restrict to a path-safe token (blocks "x/../../.ssh" traversal) within the
107
+ // extension's 64-char cap — same character set as validateSession.
108
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(launchLabel)) {
109
+ throw new Error('--label must be 1–64 characters of letters, digits, hyphen, or underscore.');
110
+ }
111
+ const loadMode = getFlag(args, '--load-mode') ?? 'auto'; // auto (detect) | pipe | flag
112
+ if (!['auto', 'pipe', 'flag'].includes(loadMode)) throw new Error('--load-mode must be one of: auto, pipe, flag.');
113
+ return finish('launch', {
114
+ label: launchLabel,
115
+ dist: getFlag(args, '--dist') ?? null,
116
+ chrome: getFlag(args, '--chrome') ?? null,
117
+ profileDir: getFlag(args, '--profile-dir') ?? null,
118
+ loadMode,
119
+ timeoutMs: timeoutRaw ? parseStrictInt(timeoutRaw, '--timeout') : 30000,
120
+ });
121
+ }
122
+
123
+ // Hidden helper command (detached child of `dassi launch` pipe mode); config via DASSI_HOLD_* env.
124
+ if (command === '__launch-hold') return finish('launch_hold', {});
125
+
83
126
  if (command === 'run') {
84
127
  const prompt = args.shift();
85
128
  if (!prompt) throw new Error('run requires a prompt argument');
@@ -319,44 +362,7 @@ export async function waitForLogin(socketPath, optionsUrl) {
319
362
 
320
363
  // ─── Immediate actions ────────────────────────────────────────────────────────
321
364
 
322
- const HELP_TEXT =
323
- 'Usage: dassi [options] <command>\n\n' +
324
- 'Browser commands (each accepts --tab <id> | --group <id> | --group-title <name>):\n' +
325
- ' navigate <url> --tab <id> Navigate to URL\n' +
326
- ' click <ref> --tab <id> Click element by ref\n' +
327
- ' fill <ref> <text> --tab <id> Fill input with text (instant)\n' +
328
- ' type <ref> <text> --tab <id> Type text with keyboard events\n' +
329
- ' read-page --tab <id> Read page accessibility tree\n' +
330
- ' get-text --tab <id> Extract page text\n' +
331
- ' screenshot --tab <id> [-o file] Capture viewport screenshot\n' +
332
- ' panel-screenshot --tab <id> [-o f] [--width W] [--height H]\n' +
333
- ' Capture side panel UI (default 360x800)\n' +
334
- ' eval <code> --tab <id> Execute JavaScript\n' +
335
- ' tabs --tab <id> List tabs in same group\n' +
336
- ' open [url] --tab <id> Open new tab in group\n' +
337
- ' close --tab <id> Close tab\n\n' +
338
- 'Agent commands:\n' +
339
- ' run <prompt> --tab <id> | --group <id> | --group-title <name>\n' +
340
- ' Run AI agent on a tab or group (group = sequential)\n' +
341
- ' list-tabs [--all] List tabs in groups dassi has open (--all = every Chrome tab)\n' +
342
- ' list-groups [--all] List dassi-open tab groups (--all = every Chrome group)\n' +
343
- ' list-profiles List connected profiles (Chrome instances)\n' +
344
- ' status Check extension status\n' +
345
- ' bug-report [-o file] Export debug logs from all contexts\n' +
346
- ' raw <json> Send raw JSON command\n\n' +
347
- 'Options:\n' +
348
- ' --tab <id> Chrome tab ID (use list-tabs to find)\n' +
349
- ' --all list-tabs/list-groups: include every Chrome tab/group\n' +
350
- ' --timeout <ms> Timeout for run command (default: 300000)\n' +
351
- ' --session <name> Daemon session name (default: "default")\n' +
352
- ' --profile <label> Target a specific connected profile (alias: --label)\n' +
353
- ' --json Output raw JSON\n' +
354
- ' --filter <type> Filter for read-page (interactive|all)\n' +
355
- ' --depth <n> Depth for read-page tree\n' +
356
- ' --await Await promise in eval\n' +
357
- ' -o, --output <f> Output file for screenshot\n' +
358
- ' --version Show version\n' +
359
- ' --help Show help';
365
+ // HELP_TEXT lives in ./help-text.mjs (extracted to keep this file within the size limit).
360
366
 
361
367
  /**
362
368
  * Handles --version and --help, which don't need a daemon. Returns true if handled.
@@ -431,6 +437,9 @@ export async function run() {
431
437
  const { action, params, session, json, profile } = parsed;
432
438
  handleImmediateAction(action);
433
439
 
440
+ // Launch family (launch / --stop / __launch-hold) → launch.mjs.
441
+ if (await dispatchLaunch(action, params, { spawn: child_process.spawn, ensureDaemonRunning, getSocketPath, sendCommand, appDir: getAppDir(), launchesFile: getLaunchesFile() })) return;
442
+
434
443
  const socketPath = await ensureDaemonReady(session);
435
444
 
436
445
  const id = `cli_${Date.now()}`;
package/help-text.mjs ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `dassi --help` output. Extracted from dassi.mjs to keep that file within the
3
+ * CLAUDE.md size limit; this is pure presentation with no runtime dependencies.
4
+ */
5
+ export const HELP_TEXT =
6
+ 'Usage: dassi [options] <command>\n\n' +
7
+ 'Browser commands (each accepts --tab <id> | --group <id> | --group-title <name>):\n' +
8
+ ' navigate <url> --tab <id> Navigate to URL\n' +
9
+ ' click <ref> --tab <id> Click element by ref\n' +
10
+ ' fill <ref> <text> --tab <id> Fill input with text (instant)\n' +
11
+ ' type <ref> <text> --tab <id> Type text with keyboard events\n' +
12
+ ' read-page --tab <id> Read page accessibility tree\n' +
13
+ ' get-text --tab <id> Extract page text\n' +
14
+ ' screenshot --tab <id> [-o file] Capture viewport screenshot\n' +
15
+ ' panel-screenshot --tab <id> [-o f] [--width W] [--height H]\n' +
16
+ ' Capture side panel UI (default 360x800)\n' +
17
+ ' eval <code> --tab <id> Execute JavaScript\n' +
18
+ ' tabs --tab <id> List tabs in same group\n' +
19
+ ' open [url] --tab <id> Open new tab in group\n' +
20
+ ' close --tab <id> Close tab\n\n' +
21
+ 'Agent commands:\n' +
22
+ ' run <prompt> --tab <id> | --group <id> | --group-title <name>\n' +
23
+ ' Run AI agent on a tab or group (group = sequential)\n' +
24
+ ' list-tabs [--all] List tabs in groups dassi has open (--all = every Chrome tab)\n' +
25
+ ' list-groups [--all] List dassi-open tab groups (--all = every Chrome group)\n' +
26
+ ' list-profiles List connected profiles (Chrome instances)\n' +
27
+ ' status Check extension status\n' +
28
+ ' bug-report [-o file] Export debug logs from all contexts\n' +
29
+ ' raw <json> Send raw JSON command\n' +
30
+ ' launch [--label <name>] [--dist <path>] [--chrome <path>] [--load-mode auto|pipe|flag]\n' +
31
+ ' Open Chrome with a dev dist loaded. --load-mode\n' +
32
+ ' defaults to auto (pipe for branded Chrome 137+,\n' +
33
+ ' --load-extension for Chrome for Testing/Chromium).\n' +
34
+ ' launch --stop [label] | --stop-all\n' +
35
+ ' Stop a launched Chrome\n\n' +
36
+ 'Options:\n' +
37
+ ' --tab <id> Chrome tab ID (use list-tabs to find)\n' +
38
+ ' --all list-tabs/list-groups: include every Chrome tab/group\n' +
39
+ ' --timeout <ms> Timeout for run command (default: 300000)\n' +
40
+ ' --session <name> Daemon session name (default: "default")\n' +
41
+ ' --profile <label> Target a specific connected profile (alias: --label)\n' +
42
+ ' --json Output raw JSON\n' +
43
+ ' --filter <type> Filter for read-page (interactive|all)\n' +
44
+ ' --depth <n> Depth for read-page tree\n' +
45
+ ' --await Await promise in eval\n' +
46
+ ' -o, --output <f> Output file for screenshot\n' +
47
+ ' --version Show version\n' +
48
+ ' --help Show help';
package/launch.mjs ADDED
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Pure helpers for `dassi launch` — locating Chrome, resolving the dist,
3
+ * building Chrome launch args, and tracking launched instances.
4
+ */
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
6
+ import { execFileSync, spawn as childSpawn } from 'child_process';
7
+ import * as path from 'path';
8
+ import { DASSI_EXTENSION_ID, getDaemonBridgePort, DEFAULT_BRIDGE_PORT, isDaemonRunning as isDaemonRunningImpl } from './dassi-shared.mjs';
9
+ import { loadExtensionOverPipe } from './_launch-pipe.mjs';
10
+ // `_launch-pipe.mjs` is private (underscore-prefixed): `launch.mjs` is the SINGLE public
11
+ // gateway for the launch module. Re-export the pipe transport here so every consumer
12
+ // (including the unit tests) imports it from this one surface, never from the private file.
13
+ export { cdpPipeClient, loadExtensionOverPipe } from './_launch-pipe.mjs';
14
+
15
+ /**
16
+ * Resolve the path to an installed Chrome binary.
17
+ * @param {{ platform?: string; env?: Record<string,string|undefined>; override?: string|null; exists?: (p: string) => boolean }} [opts]
18
+ * @returns {string | null} The first existing Chrome path, the override, or null.
19
+ */
20
+ export function resolveChromePath({ platform = process.platform, env = process.env, override = null, exists = existsSync } = {}) {
21
+ if (override) return override;
22
+ const candidates = [];
23
+ if (platform === 'darwin') {
24
+ candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome');
25
+ } else if (platform === 'win32') {
26
+ const pf = env['ProgramFiles'] ?? 'C:\\Program Files';
27
+ const pf86 = env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)';
28
+ candidates.push(`${pf}\\Google\\Chrome\\Application\\chrome.exe`, `${pf86}\\Google\\Chrome\\Application\\chrome.exe`);
29
+ if (env['LOCALAPPDATA']) candidates.push(`${env['LOCALAPPDATA']}\\Google\\Chrome\\Application\\chrome.exe`);
30
+ } else {
31
+ candidates.push('/opt/google/chrome/chrome', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser');
32
+ }
33
+ return candidates.find((p) => exists(p)) ?? null;
34
+ }
35
+
36
+ /**
37
+ * Resolve and validate the extension dist directory to load.
38
+ * @param {{ dist?: string|null; cwd?: string; exists?: (p: string) => boolean }} [opts]
39
+ * @returns {string} Absolute path to the dist directory.
40
+ * @throws if the directory has no manifest.json.
41
+ */
42
+ export function resolveDistPath({ dist = null, cwd = process.cwd(), exists = existsSync } = {}) {
43
+ const base = dist ? path.resolve(cwd, dist) : path.resolve(cwd, 'extension/dist');
44
+ if (!exists(path.join(base, 'manifest.json'))) {
45
+ throw new Error(`No built extension at ${base} (manifest.json missing). Run \`pnpm build\`, or pass --dist <path>.`);
46
+ }
47
+ return base;
48
+ }
49
+
50
+ /**
51
+ * Build the Chrome args for a pipe-driven launch.
52
+ *
53
+ * The extension is NOT loaded via the `--load-extension` flag: Chrome 137+ branded
54
+ * builds reject it (`ERR_BLOCKED_BY_CLIENT`), and the `--disable-features=
55
+ * DisableLoadExtensionCommandLineSwitch` escape hatch is gone in Chrome 149. Instead
56
+ * the dist is installed at runtime over the CDP pipe via `Extensions.loadUnpacked`
57
+ * (see {@link loadExtensionOverPipe}). That command is only accepted over the
58
+ * `--remote-debugging-pipe` transport (fds 3/4) — not `--remote-debugging-port` —
59
+ * and requires `--enable-unsafe-extension-debugging`. Works on branded Chrome,
60
+ * Chrome for Testing, and Chromium alike.
61
+ * @param {{ profileDir: string }} opts
62
+ * @returns {string[]} Chrome args.
63
+ */
64
+ export function buildLaunchArgs({ profileDir }) {
65
+ return [
66
+ '--remote-debugging-pipe',
67
+ '--enable-unsafe-extension-debugging',
68
+ `--user-data-dir=${profileDir}`,
69
+ '--no-first-run',
70
+ '--no-default-browser-check',
71
+ ];
72
+ }
73
+
74
+ /**
75
+ * Build Chrome args for the legacy `--load-extension` path (Chrome for Testing /
76
+ * Chromium, which still honour it and load the extension *persistently*). The dist
77
+ * is loaded at startup, so the seed options URL can ride along as the startup tab.
78
+ * @param {{ distPath: string; profileDir: string; label: string; bridgePort: number }} opts
79
+ * @returns {string[]} Chrome args (the final entry is the seed URL to open).
80
+ */
81
+ export function buildFlagArgs({ distPath, profileDir, label, bridgePort }) {
82
+ const seed = `options.html?label=${encodeURIComponent(label)}&bridgePort=${bridgePort}`;
83
+ return [
84
+ `--load-extension=${distPath}`,
85
+ `--disable-extensions-except=${distPath}`,
86
+ `--user-data-dir=${profileDir}`,
87
+ '--no-first-run',
88
+ '--no-default-browser-check',
89
+ `chrome-extension://${DASSI_EXTENSION_ID}/${seed}`,
90
+ ];
91
+ }
92
+
93
+ /**
94
+ * Does this Chrome binary still support a *persistent* `--load-extension`?
95
+ * Branded Google Chrome 137+ disabled it (ERR_BLOCKED_BY_CLIENT); Chrome for Testing
96
+ * and Chromium still honour it. Detected from `--version` output. Defaults to false
97
+ * (→ pipe path) on any error — the pipe path works everywhere, so it's the safe
98
+ * fallback.
99
+ * @param {string} chromePath
100
+ * @param {(p: string) => string} [runVersion] inject for tests
101
+ * @returns {boolean} true → use `--load-extension`; false → use the CDP pipe.
102
+ */
103
+ export function chromeSupportsLoadFlag(chromePath, runVersion = (p) => execFileSync(p, ['--version'], { encoding: 'utf8' })) {
104
+ try {
105
+ return /for testing|chromium/i.test(runVersion(chromePath));
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Read the launches registry. Returns [] if the file is missing or unparseable.
113
+ * @param {string} file
114
+ * @returns {Array<{label: string; pid: number; profileDir: string; startedAt: number}>}
115
+ */
116
+ export function readLaunches(file) {
117
+ try {
118
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
119
+ return Array.isArray(parsed) ? parsed : [];
120
+ } catch {
121
+ return [];
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Record a launched Chrome, replacing any prior entry for the same label.
127
+ *
128
+ * Reason: read-modify-write is not cross-process locked. For this dev-only CLI
129
+ * that's acceptable — concurrent `dassi launch`/`--stop` of *different* labels is
130
+ * rare, and the worst case (a lost entry) is recoverable via `--stop-all` + a
131
+ * manual Chrome close. A lock is intentionally omitted (YAGNI for a dev tool).
132
+ * @param {string} file
133
+ * @param {{label: string; pid: number; profileDir: string; startedAt: number}} entry
134
+ */
135
+ export function recordLaunch(file, entry) {
136
+ const list = readLaunches(file).filter((e) => e.label !== entry.label);
137
+ list.push(entry);
138
+ // Reason: ensure the dir exists so the write can't ENOENT after Chrome is already
139
+ // spawned+connected (which would orphan it with nothing recorded to --stop).
140
+ mkdirSync(path.dirname(file), { recursive: true });
141
+ writeFileSync(file, JSON.stringify(list, null, 2), { mode: 0o600 });
142
+ }
143
+
144
+ /**
145
+ * Remove the entry for a label. Returns the removed entry, or null if absent.
146
+ * @param {string} file
147
+ * @param {string} label
148
+ * @returns {object | null}
149
+ */
150
+ export function removeLaunch(file, label) {
151
+ const list = readLaunches(file);
152
+ const found = list.find((e) => e.label === label) ?? null;
153
+ if (found) writeFileSync(file, JSON.stringify(list.filter((e) => e.label !== label), null, 2), { mode: 0o600 });
154
+ return found;
155
+ }
156
+
157
+ // ─── Launch orchestrators (exported for testability; deps-injected to avoid circular imports) ───
158
+
159
+ /**
160
+ * Poll the daemon's list_profiles until `label` registers or the timeout elapses.
161
+ * @param {{ sendCommand: Function; socketPath: string; label: string; timeoutMs: number; sleep?: (ms:number)=>Promise<void> }} deps
162
+ * @returns {Promise<boolean>} true if the label connected in time.
163
+ */
164
+ export async function waitForProfile({ sendCommand, socketPath, label, timeoutMs, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
165
+ const deadline = Date.now() + timeoutMs;
166
+ while (Date.now() < deadline) {
167
+ try {
168
+ const resp = await sendCommand(socketPath, { id: `cli_lp_${Date.now()}`, action: 'list_profiles' });
169
+ if (resp.success && Array.isArray(resp.data) && resp.data.some((p) => p.label === label)) return true;
170
+ } catch { /* daemon still coming up — keep polling */ }
171
+ await sleep(500);
172
+ }
173
+ return false;
174
+ }
175
+
176
+ /**
177
+ * One-shot check: is a profile with `label` already connected to the daemon?
178
+ * Tolerates the daemon/socket not being up yet (returns false).
179
+ * @param {{ sendCommand: Function; socketPath: string; label: string }} deps
180
+ * @returns {Promise<boolean>}
181
+ */
182
+ async function isLabelConnected({ sendCommand, socketPath, label }) {
183
+ try {
184
+ const resp = await sendCommand(socketPath, { id: `cli_pre_${Date.now()}`, action: 'list_profiles' });
185
+ return resp.success && Array.isArray(resp.data) && resp.data.some((p) => p.label === label);
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Daemon pre-flight for a launch: validate the bridge port, spawn the (non-blocking)
193
+ * daemon, and refuse a duplicate label. Calls `exit(1)` + returns null on failure.
194
+ * @returns {Promise<{ bridgePort: number; socketPath: string } | null>}
195
+ */
196
+ async function preflightLaunch({ ensureDaemonRunning, getSocketPath, sendCommand, isDaemonRunning, label, error, exit }) {
197
+ // Reason: `dassi launch` reuses the 'default' daemon session, but the session PID
198
+ // doesn't tell us its port. If a non-default DASSI_BRIDGE_PORT is requested while a
199
+ // default daemon is already running, we can't confirm it's on that port — fail clearly.
200
+ const bridgePort = getDaemonBridgePort();
201
+ if (bridgePort !== DEFAULT_BRIDGE_PORT && isDaemonRunning('default')) {
202
+ error(`❌ DASSI_BRIDGE_PORT=${bridgePort} is set, but a daemon is already running for the default session (it may be on a different port). Stop it first, or omit DASSI_BRIDGE_PORT to use the running daemon.`);
203
+ exit(1);
204
+ return null;
205
+ }
206
+ // Reason: only SPAWN the daemon (non-blocking) — none has connected yet.
207
+ ensureDaemonRunning('default');
208
+ const socketPath = getSocketPath('default');
209
+ // Reason: a same-label Chrome already running makes waitForProfile falsely succeed.
210
+ if (await isLabelConnected({ sendCommand, socketPath, label })) {
211
+ error(`❌ A profile labelled "${label}" is already connected. Run \`dassi launch --stop ${label}\` first, or use a different --label.`);
212
+ exit(1);
213
+ return null;
214
+ }
215
+ return { bridgePort, socketPath };
216
+ }
217
+
218
+ /**
219
+ * Launch Chrome with the dev dist loaded under a dedicated profile, wait for the
220
+ * extension to register under `label`, then record it. Auto-detects the load mode
221
+ * (pipe helper for branded Chrome / `--load-extension` for CfT/Chromium).
222
+ * @param {{label:string; dist:string|null; chrome:string|null; profileDir:string|null; loadMode?:string; timeoutMs:number}} opts
223
+ * @param {{ spawn: Function; ensureDaemonRunning: Function; getSocketPath: Function; sendCommand: Function; appDir: string; launchesFile: string; isDaemonRunning?: Function; detectLoadFlag?: Function; helperCommand?: string[]; log?: Function; error?: Function; exit?: Function; mkdir?: Function; kill?: Function }} deps
224
+ * `detectLoadFlag` chooses pipe-vs-flag mode; `helperCommand` is the argv used to spawn the detached pipe-hold helper (both injectable for tests).
225
+ * @returns {Promise<void>} resolves once the launch is recorded (or `deps.exit` is called on failure).
226
+ */
227
+ export async function handleLaunch(opts, deps) {
228
+ const {
229
+ spawn, ensureDaemonRunning, getSocketPath, sendCommand, appDir, launchesFile,
230
+ isDaemonRunning = isDaemonRunningImpl,
231
+ detectLoadFlag = chromeSupportsLoadFlag,
232
+ helperCommand = [process.execPath, process.argv[1]],
233
+ log = console.log, error = console.error, exit = process.exit,
234
+ mkdir = (d) => mkdirSync(d, { recursive: true }), kill = (pid) => process.kill(pid),
235
+ } = deps;
236
+
237
+ const target = resolveLaunchTarget(opts, { error, exit, appDir, mkdir });
238
+ if (!target) return; // resolution failed (exit already called)
239
+ const { distPath, chromePath, profileDir } = target;
240
+
241
+ const pre = await preflightLaunch({ ensureDaemonRunning, getSocketPath, sendCommand, isDaemonRunning, label: opts.label, error, exit });
242
+ if (!pre) return; // a guard failed (exit already called)
243
+ const { bridgePort, socketPath } = pre;
244
+
245
+ // Reason: branded Chrome 137+ disabled --load-extension, so install over the CDP pipe via
246
+ // a persistent helper. CfT/Chromium still honour --load-extension. --load-mode forces it;
247
+ // 'auto' (default) detects from the binary's --version.
248
+ const useFlag = opts.loadMode === 'flag' ? true : opts.loadMode === 'pipe' ? false : detectLoadFlag(chromePath);
249
+ const common = { spawn, kill, sendCommand, socketPath, chromePath, distPath, profileDir, bridgePort, opts, error };
250
+ const pid = useFlag ? await spawnFlagAndConnect(common) : await spawnHelperAndConnect({ ...common, helperCommand });
251
+ if (pid === null) return exit(1);
252
+
253
+ recordLaunch(launchesFile, { label: opts.label, pid, profileDir, mode: useFlag ? 'flag' : 'pipe', startedAt: Date.now() });
254
+ log(`✅ launched "${opts.label}" (pid ${pid}) — drive it with: dassi run "…" --profile ${opts.label}`);
255
+ }
256
+
257
+ /**
258
+ * Entry for the hidden `dassi __launch-hold` subcommand: reads the DASSI_HOLD_* env vars
259
+ * (set by spawnHelperAndConnect) and runs the pipe-holding helper. Never returns.
260
+ * @param {Record<string,string|undefined>} [env] Environment variables; defaults to process.env.
261
+ * @param {Function} [run] Pipe-hold runner; defaults to runPipeHoldHelper (injectable for tests).
262
+ * @returns {Promise<void>}
263
+ */
264
+ export async function handleLaunchHold(env = process.env, run = runPipeHoldHelper) {
265
+ await run({
266
+ label: env.DASSI_HOLD_LABEL,
267
+ distPath: env.DASSI_HOLD_DIST,
268
+ profileDir: env.DASSI_HOLD_PROFILE_DIR,
269
+ bridgePort: Number(env.DASSI_HOLD_BRIDGE_PORT),
270
+ chromePath: env.DASSI_HOLD_CHROME,
271
+ });
272
+ }
273
+
274
+ /**
275
+ * Route a `launch*` CLI action to its handler. Returns true if it was a launch action
276
+ * (so the caller can `return`), false otherwise. Keeps the launch surface out of dassi.mjs.
277
+ * @param {string} action
278
+ * @param {object} params
279
+ * @param {{ spawn:Function; ensureDaemonRunning:Function; getSocketPath:Function; sendCommand:Function; appDir:string; launchesFile:string; kill?:Function; handleHold?:Function }} deps
280
+ * `handleHold` overrides the `__launch-hold` runner (injectable for tests; defaults to handleLaunchHold).
281
+ * @returns {Promise<boolean>}
282
+ */
283
+ export async function dispatchLaunch(action, params, deps) {
284
+ if (action === 'launch') { await handleLaunch(params, deps); return true; }
285
+ if (action === 'launch_stop') { handleStop(params, { launchesFile: deps.launchesFile, kill: deps.kill }); return true; }
286
+ if (action === 'launch_hold') { await (deps.handleHold ?? handleLaunchHold)(); return true; }
287
+ return false;
288
+ }
289
+
290
+ /**
291
+ * Flag mode (Chrome for Testing / Chromium): detached Chrome with a persistent
292
+ * `--load-extension`. The browser keeps the extension after this CLI exits.
293
+ * @returns {Promise<number|null>} the Chrome pid, or null on spawn/connect failure.
294
+ */
295
+ async function spawnFlagAndConnect({ spawn, kill, sendCommand, socketPath, chromePath, distPath, profileDir, bridgePort, opts, error }) {
296
+ const child = spawn(chromePath, buildFlagArgs({ distPath, profileDir, label: opts.label, bridgePort }), { detached: true, stdio: 'ignore' });
297
+ child.on('error', () => {});
298
+ child.unref();
299
+ if (typeof child.pid !== 'number') {
300
+ error('❌ Failed to launch Chrome — check the Chrome path (--chrome).');
301
+ return null;
302
+ }
303
+ error(`Launching Chrome (pid ${child.pid}) with ${distPath} — waiting for "${opts.label}" to connect…`);
304
+ if (await waitForProfile({ sendCommand, socketPath, label: opts.label, timeoutMs: opts.timeoutMs })) return child.pid;
305
+ try { kill(child.pid); } catch { /* already gone */ }
306
+ error(`❌ "${opts.label}" did not connect within ${opts.timeoutMs}ms (Chrome already using ${profileDir}, or a stale profile).`);
307
+ return null;
308
+ }
309
+
310
+ /**
311
+ * Pipe mode (branded Chrome): spawn a detached `dassi __launch-hold` helper that holds the
312
+ * CDP pipe open so the loadUnpacked extension persists. The recorded pid is the HELPER's —
313
+ * `dassi launch --stop` SIGTERMs it, and it closes the pipe + kills its Chrome child.
314
+ * @returns {Promise<number|null>} the helper pid, or null on spawn/connect failure.
315
+ */
316
+ async function spawnHelperAndConnect({ spawn, kill, sendCommand, socketPath, chromePath, distPath, profileDir, bridgePort, opts, error, helperCommand }) {
317
+ const [node, cli] = helperCommand;
318
+ // Reason: pass the helper's config via env (not flags) so it can't collide with the
319
+ // CLI's global --label/--profile parsing.
320
+ const helper = spawn(node, [cli, '__launch-hold'], {
321
+ detached: true,
322
+ stdio: 'ignore',
323
+ env: {
324
+ ...process.env,
325
+ DASSI_HOLD_LABEL: opts.label,
326
+ DASSI_HOLD_DIST: distPath,
327
+ DASSI_HOLD_PROFILE_DIR: profileDir,
328
+ DASSI_HOLD_BRIDGE_PORT: String(bridgePort),
329
+ DASSI_HOLD_CHROME: chromePath,
330
+ },
331
+ });
332
+ helper.on('error', () => {});
333
+ helper.unref();
334
+ if (typeof helper.pid !== 'number') {
335
+ error('❌ Failed to start the launch helper.');
336
+ return null;
337
+ }
338
+ error(`Launching Chrome via pipe helper (pid ${helper.pid}) with ${distPath} — waiting for "${opts.label}" to connect…`);
339
+ if (await waitForProfile({ sendCommand, socketPath, label: opts.label, timeoutMs: opts.timeoutMs })) return helper.pid;
340
+ try { kill(helper.pid); } catch { /* already gone */ }
341
+ error(`❌ "${opts.label}" did not connect within ${opts.timeoutMs}ms (extension installed but the bridge didn't register — daemon port mismatch or a stale profile).`);
342
+ return null;
343
+ }
344
+
345
+ /**
346
+ * Long-lived `dassi __launch-hold` helper (a detached child of `dassi launch` on branded
347
+ * Chrome): spawns Chrome with the CDP pipe, installs the dist, signals readiness, then
348
+ * HOLDS the pipe open until killed — a loadUnpacked extension lives only as long as its
349
+ * debugging session. On SIGTERM/SIGINT (from `dassi launch --stop`) or if Chrome exits, it
350
+ * tears down and exits.
351
+ * @param {{ distPath:string; profileDir:string; label:string; bridgePort:number; chromePath:string;
352
+ * spawn?:Function; loadExtension?:Function; onReady?:Function; hold?:()=>Promise<void>;
353
+ * error?:Function; exit?:Function }} o
354
+ * @returns {Promise<void>} In production never resolves (hold() is infinite until killed);
355
+ * resolves after calling exit(1) on extension-install failure.
356
+ */
357
+ export async function runPipeHoldHelper(o) {
358
+ const {
359
+ distPath, profileDir, label, bridgePort, chromePath,
360
+ spawn = childSpawn, loadExtension = loadExtensionOverPipe,
361
+ onReady = () => {}, hold = () => new Promise(() => {}),
362
+ error = (m) => process.stderr.write(`${m}\n`), exit = process.exit,
363
+ } = o;
364
+ const child = spawn(chromePath, buildLaunchArgs({ profileDir }), { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
365
+ let dispose = () => {};
366
+ let shuttingDown = false;
367
+ const shutdown = () => {
368
+ if (shuttingDown) return;
369
+ shuttingDown = true;
370
+ try { dispose(); } catch { /* ignore */ }
371
+ try { child.kill('SIGTERM'); } catch { /* already gone */ }
372
+ exit(0);
373
+ };
374
+ // Reason: this helper runs as a detached process whose whole job is to hold the CDP
375
+ // pipe open. SIGTERM is how `dassi launch --stop` tears it down; SIGINT covers Ctrl-C.
376
+ // Without these handlers the Chrome child (and its loaded extension) would be orphaned.
377
+ process.on('SIGTERM', shutdown);
378
+ process.on('SIGINT', shutdown);
379
+ child.on('error', () => {});
380
+ child.on('exit', () => { if (!shuttingDown) exit(0); }); // Chrome died → stop holding
381
+ try {
382
+ ({ dispose } = await loadExtension({ child, distPath, label, bridgePort }));
383
+ } catch (err) {
384
+ // Reason: set shuttingDown FIRST so the child.on('exit') guard above can't win the
385
+ // race and call exit(0) — a failed install must surface as exit(1), not false success.
386
+ shuttingDown = true;
387
+ error(`launch-hold: failed to install extension: ${err.message}`);
388
+ try { child.kill('SIGTERM'); } catch { /* already gone */ }
389
+ return exit(1);
390
+ }
391
+ onReady();
392
+ await hold(); // hold the pipe open (draining) until killed
393
+ }
394
+
395
+ /**
396
+ * Resolve the dist dir, Chrome binary, and profile dir for a launch.
397
+ * Calls `exit(1)` and returns null on any failure (extracted to keep
398
+ * handleLaunch within the function-length limit).
399
+ * @param {{label:string; dist:string|null; chrome:string|null; profileDir:string|null}} opts
400
+ * @param {{ error: Function; exit: Function; appDir: string; mkdir: Function }} deps
401
+ * @returns {{distPath:string; chromePath:string; profileDir:string} | null}
402
+ */
403
+ function resolveLaunchTarget(opts, { error, exit, appDir, mkdir }) {
404
+ let distPath;
405
+ try { distPath = resolveDistPath({ dist: opts.dist }); }
406
+ catch (err) { error(`❌ ${err.message}`); exit(1); return null; }
407
+
408
+ const chromePath = resolveChromePath({ override: opts.chrome });
409
+ if (!chromePath) {
410
+ error('❌ Could not find Chrome. Pass --chrome <path> to the Chrome/Chromium binary.');
411
+ exit(1);
412
+ return null;
413
+ }
414
+
415
+ const profileDir = opts.profileDir ?? path.join(appDir, `launch-${opts.label}`);
416
+ mkdir(profileDir);
417
+ return { distPath, chromePath, profileDir };
418
+ }
419
+
420
+ /**
421
+ * Kill the Chrome instance(s) recorded by `dassi launch`.
422
+ * @param {{label?: string; all?: boolean}} opts
423
+ * @param {{ launchesFile: string; kill?: (pid:number)=>void; log?: Function }} deps
424
+ */
425
+ export function handleStop(opts, deps) {
426
+ const { launchesFile, kill = (pid) => process.kill(pid), log = console.log } = deps;
427
+ const list = readLaunches(launchesFile);
428
+ const targets = opts.all ? list : list.filter((e) => e.label === opts.label);
429
+ if (targets.length === 0) {
430
+ log(opts.all ? 'No launched Chrome instances to stop.' : `Nothing to stop for "${opts.label}".`);
431
+ return;
432
+ }
433
+ for (const e of targets) {
434
+ // Reason: guard against corrupt launches.json entries with non-number pids, which
435
+ // would cause process.kill to throw a misleading TypeError instead of a clear skip.
436
+ if (typeof e.pid !== 'number') { log(`Skipping "${e.label}" — corrupt record (no pid).`); removeLaunch(launchesFile, e.label); continue; }
437
+ try { kill(e.pid); log(`Stopped "${e.label}" (pid ${e.pid}).`); }
438
+ catch { log(`"${e.label}" (pid ${e.pid}) was already gone.`); }
439
+ removeLaunch(launchesFile, e.label);
440
+ }
441
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dassi_ai/cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "CLI for the Dassi Chrome extension — run browser automation from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,9 @@
13
13
  "tool-commands.mjs",
14
14
  "format-response.mjs",
15
15
  "group-expansion.mjs",
16
+ "launch.mjs",
17
+ "_launch-pipe.mjs",
18
+ "help-text.mjs",
16
19
  ".claude-plugin/",
17
20
  "skills/",
18
21
  "README.md"
@@ -32,11 +32,26 @@ Each accepts `--tab <id>` OR `--group <id>` OR `--group-title <name>`, except wh
32
32
  | `open [url]` | url? | **Single-target only** (`--tab` required). Opens new tab in current group. |
33
33
  | `close` | — | Close tab. Fans out across a group = close all member tabs. |
34
34
 
35
+ ## Dev launch commands (loading a local build for testing)
36
+
37
+ | Command | Args | Notes |
38
+ |---|---|---|
39
+ | `dassi launch` | `--label <name>` (default `dev`), `--dist <path>` (default `extension/dist`), `--chrome <path>`, `--load-mode auto\|pipe\|flag`, `--timeout <ms>` | Open a dedicated Chrome with a locally-built dev dist loaded, registered under `--label`. Then drive it by adding `--profile <label>` to any command. |
40
+ | `dassi launch --stop [label]` / `--stop-all` | label? | Close a launched Chrome (default label `dev`). |
41
+ | `dassi list-profiles` | `--json` | List connected Chrome instances (profiles), by `label`/id. |
42
+
43
+ **How the extension is loaded** (`--load-mode`, default `auto`):
44
+ - **Branded Google Chrome 137+** disabled the `--load-extension` flag (`ERR_BLOCKED_BY_CLIENT`), so launch installs the dist at runtime via the `Extensions.loadUnpacked` CDP command over `--remote-debugging-pipe`. Such an extension is tied to the debugging session, so launch spawns a detached helper that holds the pipe open; `--stop` kills the helper (which closes the pipe + its Chrome).
45
+ - **Chrome for Testing / Chromium** still honour `--load-extension` (persistent) → used directly, no helper.
46
+ - `--load-mode pipe|flag` forces a mode (e.g. `--chrome <cft> --load-mode pipe` exercises the pipe path on Chrome for Testing); `auto` detects from the binary's `--version`.
47
+ - A freshly launched profile is **signed out** — sign in to that Chrome before `dassi run`/agent commands work in it.
48
+
35
49
  ## Global options
36
50
 
37
51
  | Flag | Effect |
38
52
  |---|---|
39
53
  | `--session <name>` | Daemon session name (default `default`). Selects which per-session daemon process and Unix socket the CLI connects to. Each distinct `--session` value spawns its own daemon; only one can be running at a time because they all bind the same WebSocket port (see the "Multi-tab dispatch is sequential" note below). |
54
+ | `--profile <label>` (alias `--label`) | Target a specific connected Chrome instance (e.g. one started by `dassi launch --label qa`). Required when multiple profiles are connected. |
40
55
  | `--json` | Raw JSON output (in group fan-out: single JSON array of `{tabId, response}` entries). |
41
56
  | `--version`, `--help` | Self-explanatory. |
42
57