@dassi_ai/cli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -78,6 +78,9 @@ dassi launch --label qa --dist some/dist # custom label + dist
78
78
  dassi list-tabs --profile dev # find a tab id in the launched profile
79
79
  dassi run "summarize this page" --tab <id> --profile dev
80
80
  dassi launch --stop # close the "dev" Chrome (or --stop-all)
81
+ # Pass extra args to Chrome by appending `-- <args>` after all CLI flags
82
+ # (forwarded verbatim in both load modes; rejected on non-launch commands):
83
+ dassi launch -- --headless=new --remote-debugging-port=9222
81
84
  # Note: launch reuses the default daemon (port 18790). To launch on an isolated
82
85
  # port (DASSI_BRIDGE_PORT=18791 dassi launch), no default daemon may be running —
83
86
  # it errors clearly otherwise, since it can't confirm the running daemon's port.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Daemon client — the CLI side of the daemon protocol. Extracted from
3
+ * dassi.mjs to keep that file within the CLAUDE.md 500-line limit.
4
+ *
5
+ * Covers spawning the daemon, waiting for its ready file, the Unix-socket
6
+ * NDJSON transport, and the interactive onboarding flow (install prompt +
7
+ * sign-in poll loop).
8
+ */
9
+
10
+ import * as net from 'net';
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import * as child_process from 'child_process';
14
+ import { fileURLToPath } from 'url';
15
+ import {
16
+ getSocketPath,
17
+ getReadyFile,
18
+ isDaemonRunning,
19
+ parseReadyPayload,
20
+ DASSI_EXTENSION_ID,
21
+ } from './dassi-shared.mjs';
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const DAEMON_SCRIPT = path.join(__dirname, 'dassi-daemon.mjs');
25
+ const READY_POLL_MS = 100;
26
+ const READY_TIMEOUT_MS = 30_000;
27
+ const LOGIN_POLL_MS = 2_000;
28
+ const LOGIN_TIMEOUT_MS = 5 * 60_000;
29
+ const CHROME_WEB_STORE_URL = `https://chromewebstore.google.com/detail/dassi-ai-browser-agent-fo/${DASSI_EXTENSION_ID}`;
30
+
31
+ // ─── Daemon management ────────────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Ensures the daemon is running for the given session.
35
+ * Spawns it as a detached background process if the PID file is missing or stale.
36
+ * @param {string} session
37
+ */
38
+ export function ensureDaemonRunning(session) {
39
+ if (isDaemonRunning(session)) return;
40
+
41
+ // Reason: detached + unref means the daemon outlives the CLI process
42
+ const daemon = child_process.spawn(process.execPath, [DAEMON_SCRIPT], {
43
+ detached: true,
44
+ stdio: 'ignore',
45
+ env: { ...process.env, DASSI_SESSION: session },
46
+ });
47
+ daemon.unref();
48
+ }
49
+
50
+ /**
51
+ * Polls for the ready file until it appears or the timeout elapses.
52
+ * Resolves with the parsed ready payload.
53
+ * @param {string} readyFile
54
+ * @param {number} [timeoutMs]
55
+ * @returns {Promise<{ status: string; [key: string]: unknown }>}
56
+ */
57
+ export function waitForReady(readyFile, timeoutMs = READY_TIMEOUT_MS) {
58
+ return new Promise((resolve, reject) => {
59
+ const deadline = Date.now() + timeoutMs;
60
+ const check = () => {
61
+ if (fs.existsSync(readyFile)) {
62
+ try {
63
+ resolve(parseReadyPayload(fs.readFileSync(readyFile, 'utf8')));
64
+ return;
65
+ } catch { /* file may be partially written — keep polling */ }
66
+ }
67
+ if (Date.now() >= deadline) {
68
+ const seconds = Math.round(timeoutMs / 1000);
69
+ reject(new Error(`Timed out waiting for Dassi daemon to start (${seconds}s)`));
70
+ return;
71
+ }
72
+ setTimeout(check, READY_POLL_MS);
73
+ };
74
+ check();
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Connects to the daemon Unix socket, sends one NDJSON command, and reads the response.
80
+ * @param {string} socketPath - Unix socket path.
81
+ * @param {object} command - Command object to send (will be JSON-stringified).
82
+ * @returns {Promise<object>} Parsed response object.
83
+ */
84
+ export function sendCommand(socketPath, command) {
85
+ return new Promise((resolve, reject) => {
86
+ const socket = net.createConnection(socketPath);
87
+ let buffer = '';
88
+ let settled = false;
89
+
90
+ // Reason: guard against double-settlement since 'close' fires after socket.destroy()
91
+ // in the normal data path, and we don't want the close handler to re-resolve
92
+ const settle = (fn, val) => {
93
+ if (settled) return;
94
+ settled = true;
95
+ fn(val);
96
+ };
97
+
98
+ socket.on('connect', () => {
99
+ socket.write(JSON.stringify(command) + '\n');
100
+ });
101
+
102
+ socket.on('data', (chunk) => {
103
+ buffer += chunk.toString();
104
+ const nl = buffer.indexOf('\n');
105
+ if (nl !== -1) {
106
+ const line = buffer.slice(0, nl);
107
+ socket.destroy();
108
+ try { settle(resolve, JSON.parse(line)); }
109
+ catch { settle(reject, new Error(`Invalid JSON from daemon: ${line}`)); }
110
+ }
111
+ });
112
+
113
+ socket.on('error', (err) => settle(reject, err));
114
+
115
+ socket.on('close', () => {
116
+ if (settled) return;
117
+ // Reason: attempt to parse whatever arrived before the socket closed unexpectedly
118
+ if (buffer.trim()) {
119
+ try { settle(resolve, JSON.parse(buffer.trim())); }
120
+ catch { settle(reject, new Error(`Invalid JSON from daemon: ${buffer.trim()}`)); }
121
+ } else {
122
+ settle(reject, new Error('Daemon closed connection without a response'));
123
+ }
124
+ });
125
+ });
126
+ }
127
+
128
+ // ─── Login helpers ────────────────────────────────────────────────────────────
129
+
130
+ /**
131
+ * Defense-in-depth check for `optionsUrl` before handing it to the `open`
132
+ * package. Legit URLs always come from `chrome.runtime.getURL('options.html')`
133
+ * → `chrome-extension://<id>/options.html`. Anything else is unexpected and
134
+ * we should not auto-launch it (the `open` package shells out to the OS URL
135
+ * handler).
136
+ * @param {unknown} optionsUrl
137
+ * @returns {boolean}
138
+ */
139
+ export function isValidOptionsUrl(optionsUrl) {
140
+ return typeof optionsUrl === 'string' && /^chrome-extension:\/\/[a-z]{32}\//.test(optionsUrl);
141
+ }
142
+
143
+ /**
144
+ * Polls the daemon's status until the user signs in (extension reports
145
+ * authenticated=true) or the LOGIN_TIMEOUT_MS deadline elapses. On entry,
146
+ * best-effort auto-opens the extension's options page (subject to the
147
+ * isValidOptionsUrl guard above).
148
+ * @param {string} socketPath - Path to the daemon Unix socket.
149
+ * @param {string | undefined} optionsUrl - URL of the Dassi options page.
150
+ * @returns {Promise<void>} Resolves on successful login; throws on timeout.
151
+ */
152
+ export async function waitForLogin(socketPath, optionsUrl) {
153
+ console.error(`⚠️ Dassi is installed but you're not signed in.\n Opening the Dassi settings page...\n`);
154
+
155
+ // Auto-open the options page — best-effort (package may not be installed).
156
+ // Reason: only open URLs the extension would legitimately produce
157
+ // (chrome.runtime.getURL → `chrome-extension://<id>/options.html`).
158
+ // Defense-in-depth: even though optionsUrl is sourced from the daemon's
159
+ // ready file (which lives in our owner-only ~/.dassi/ dir), validating the
160
+ // protocol prevents `open` from launching arbitrary URIs/shell-handlers if
161
+ // the file is ever tampered with.
162
+ if (isValidOptionsUrl(optionsUrl)) {
163
+ try {
164
+ const { default: open } = await import('open');
165
+ await open(String(optionsUrl));
166
+ } catch {
167
+ console.error(` Please open: ${optionsUrl}`);
168
+ }
169
+ } else if (optionsUrl) {
170
+ console.error(` Refusing to auto-open unexpected URL: ${optionsUrl}\n Please open the Dassi settings page manually.`);
171
+ }
172
+
173
+ // Poll the daemon socket every LOGIN_POLL_MS until authenticated
174
+ console.error(' Waiting for sign-in... (Ctrl+C to cancel)');
175
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS;
176
+ let pollIndex = 0;
177
+ while (Date.now() < deadline) {
178
+ await new Promise((r) => setTimeout(r, LOGIN_POLL_MS));
179
+ try {
180
+ // Reason: increment poll index so each status request has a unique id for tracing
181
+ const resp = await sendCommand(socketPath, { id: `poll-auth-${pollIndex++}`, action: 'status' });
182
+ if (resp.success && resp.data?.authenticated) {
183
+ console.error(` ✓ Signed in as ${resp.data.email}\n ✓ Ready\n`);
184
+ return;
185
+ }
186
+ } catch { /* daemon may not be socket-ready yet — keep polling */ }
187
+ }
188
+
189
+ throw new Error('Login timed out. Please sign in and try again.');
190
+ }
191
+
192
+ // ─── Daemon readiness ─────────────────────────────────────────────────────────
193
+
194
+ /**
195
+ * Ensures the daemon is running and ready, handling onboarding if needed.
196
+ * @param {string} session
197
+ * @returns {Promise<string>} The daemon's Unix socket path.
198
+ */
199
+ export async function ensureDaemonReady(session) {
200
+ const socketPath = getSocketPath(session);
201
+ const readyFile = getReadyFile(session);
202
+
203
+ // Reason: delete any stale ready file from a previous run so the new daemon
204
+ // writes a fresh one. Skip if a daemon is already running to avoid racing.
205
+ if (!isDaemonRunning(session)) {
206
+ try { fs.unlinkSync(readyFile); } catch { /* ok if missing */ }
207
+ }
208
+
209
+ ensureDaemonRunning(session);
210
+
211
+ const ready = await waitForReady(readyFile);
212
+
213
+ if (ready.status === 'extension_not_installed') {
214
+ console.error(
215
+ `❌ Dassi extension not detected.\n\n` +
216
+ ` Install it from:\n ${CHROME_WEB_STORE_URL}\n\n` +
217
+ ` Then run this command again.`
218
+ );
219
+ process.exit(1);
220
+ }
221
+
222
+ if (ready.status === 'needs_login') {
223
+ await waitForLogin(socketPath, ready.optionsUrl ? String(ready.optionsUrl) : undefined);
224
+ }
225
+
226
+ return socketPath;
227
+ }
package/dassi.mjs CHANGED
@@ -4,21 +4,12 @@
4
4
  * Protocol-compatible with agent-browser: {id, action, ...} → {id, success, data/error}
5
5
  */
6
6
 
7
- import * as net from 'net';
8
7
  import * as fs from 'fs';
9
8
  import * as path from 'path';
10
9
  import * as child_process from 'child_process';
11
10
  import { fileURLToPath, pathToFileURL } from 'url';
12
- import {
13
- getSocketPath,
14
- getReadyFile,
15
- isDaemonRunning,
16
- parseReadyPayload,
17
- validateSession,
18
- DASSI_EXTENSION_ID,
19
- getAppDir,
20
- getLaunchesFile,
21
- } from './dassi-shared.mjs';
11
+ import { getSocketPath, validateSession, getAppDir, getLaunchesFile } from './dassi-shared.mjs';
12
+ import { ensureDaemonRunning, sendCommand, ensureDaemonReady } from './daemon-client.mjs';
22
13
  import { dispatchLaunch } from './launch.mjs';
23
14
  import { parseToolCommand, requireTarget, parseStrictInt } from './tool-commands.mjs';
24
15
  import { runWithGroupExpansion } from './group-expansion.mjs';
@@ -26,13 +17,12 @@ import { formatResponse } from './format-response.mjs';
26
17
  import { HELP_TEXT } from './help-text.mjs';
27
18
 
28
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
- const DAEMON_SCRIPT = path.join(__dirname, 'dassi-daemon.mjs');
30
20
  const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')).version;
31
- const READY_POLL_MS = 100;
32
- const READY_TIMEOUT_MS = 30_000;
33
- const LOGIN_POLL_MS = 2_000;
34
- const LOGIN_TIMEOUT_MS = 5 * 60_000;
35
- const CHROME_WEB_STORE_URL = `https://chromewebstore.google.com/detail/dassi-ai-browser-agent-fo/${DASSI_EXTENSION_ID}`;
21
+
22
+ // Reason: tests import isValidOptionsUrl from this module (it moved to
23
+ // daemon-client.mjs with the rest of the daemon-client unit) — re-export to
24
+ // keep the public surface and the tests unchanged.
25
+ export { isValidOptionsUrl } from './daemon-client.mjs';
36
26
 
37
27
  // ─── Arg parsing ──────────────────────────────────────────────────────────────
38
28
 
@@ -47,6 +37,16 @@ const CHROME_WEB_STORE_URL = `https://chromewebstore.google.com/detail/dassi-ai-
47
37
  export function parseCliArgs(argv) {
48
38
  const args = [...argv];
49
39
 
40
+ // Reason: hoist the `--` split to BEFORE global flag parsing so that Chrome
41
+ // passthrough tokens like --json, --label, --profile, or --stop can never be
42
+ // mistakenly consumed as CLI flags. Everything after the first `--` token is
43
+ // sliced out here and stored verbatim in `chromeArgs`; the remaining `args`
44
+ // array is then parsed as usual with no risk of collision. Non-launch commands
45
+ // that supply a `--` separator are rejected loudly below (after the action is
46
+ // known) so callers get a clear error instead of silent data loss.
47
+ const sepIdx = args.indexOf('--');
48
+ const chromeArgs = sepIdx === -1 ? [] : args.splice(sepIdx).slice(1);
49
+
50
50
  // Reason: handle --version and --help before any flag parsing so they work
51
51
  // even when other flags like --session are incomplete (e.g. `dassi --version --session`)
52
52
  if (consumeFlag(args, '--version')) {
@@ -67,6 +67,13 @@ export function parseCliArgs(argv) {
67
67
  const command = args.shift();
68
68
  if (!command) throw new Error('No command specified. Run: dassi --help');
69
69
 
70
+ // Reason: chromeArgs (tokens after `--`) are only meaningful for the `launch`
71
+ // command. Any other command that supplies `--` tokens would have them silently
72
+ // dropped — fail loudly instead so the caller knows their intent was not honoured.
73
+ if (chromeArgs.length > 0 && command !== 'launch') {
74
+ throw new Error("'--' is only valid with the launch command");
75
+ }
76
+
70
77
  if (command === 'list-tabs') {
71
78
  const all = consumeFlag(args, '--all');
72
79
  return finish('list_tabs', all ? { all: true } : {});
@@ -86,10 +93,16 @@ export function parseCliArgs(argv) {
86
93
  }
87
94
 
88
95
  if (command === 'launch') {
89
- if (consumeFlag(args, '--stop-all')) return finish('launch_stop', { all: true });
96
+ if (consumeFlag(args, '--stop-all')) {
97
+ // Reason: chromeArgs with --stop-all makes no sense — reject to prevent confusion.
98
+ if (chromeArgs.length > 0) throw new Error("'--' chrome args cannot be combined with launch --stop-all");
99
+ return finish('launch_stop', { all: true });
100
+ }
90
101
  const stopIdx = args.indexOf('--stop');
91
102
  if (stopIdx !== -1) {
92
103
  args.splice(stopIdx, 1);
104
+ // Reason: chromeArgs with --stop makes no sense — reject to prevent confusion.
105
+ if (chromeArgs.length > 0) throw new Error("'--' chrome args cannot be combined with launch --stop");
93
106
  // Reason: resolve the stop label like the start path does — a positional
94
107
  // token after --stop wins, else the global --label/--profile (parsed into
95
108
  // `profile` before this branch), else the default. Keeps `--stop --label qa`
@@ -117,6 +130,7 @@ export function parseCliArgs(argv) {
117
130
  profileDir: getFlag(args, '--profile-dir') ?? null,
118
131
  loadMode,
119
132
  timeoutMs: timeoutRaw ? parseStrictInt(timeoutRaw, '--timeout') : 30000,
133
+ chromeArgs,
120
134
  });
121
135
  }
122
136
 
@@ -199,167 +213,6 @@ function consumeFlag(args, flag) {
199
213
  return true;
200
214
  }
201
215
 
202
- // ─── Daemon management ────────────────────────────────────────────────────────
203
-
204
- /**
205
- * Ensures the daemon is running for the given session.
206
- * Spawns it as a detached background process if the PID file is missing or stale.
207
- * @param {string} session
208
- */
209
- export function ensureDaemonRunning(session) {
210
- if (isDaemonRunning(session)) return;
211
-
212
- // Reason: detached + unref means the daemon outlives the CLI process
213
- const daemon = child_process.spawn(process.execPath, [DAEMON_SCRIPT], {
214
- detached: true,
215
- stdio: 'ignore',
216
- env: { ...process.env, DASSI_SESSION: session },
217
- });
218
- daemon.unref();
219
- }
220
-
221
- /**
222
- * Polls for the ready file until it appears or the timeout elapses.
223
- * Resolves with the parsed ready payload.
224
- * @param {string} readyFile
225
- * @param {number} [timeoutMs]
226
- * @returns {Promise<{ status: string; [key: string]: unknown }>}
227
- */
228
- export function waitForReady(readyFile, timeoutMs = READY_TIMEOUT_MS) {
229
- return new Promise((resolve, reject) => {
230
- const deadline = Date.now() + timeoutMs;
231
- const check = () => {
232
- if (fs.existsSync(readyFile)) {
233
- try {
234
- resolve(parseReadyPayload(fs.readFileSync(readyFile, 'utf8')));
235
- return;
236
- } catch { /* file may be partially written — keep polling */ }
237
- }
238
- if (Date.now() >= deadline) {
239
- const seconds = Math.round(timeoutMs / 1000);
240
- reject(new Error(`Timed out waiting for Dassi daemon to start (${seconds}s)`));
241
- return;
242
- }
243
- setTimeout(check, READY_POLL_MS);
244
- };
245
- check();
246
- });
247
- }
248
-
249
- /**
250
- * Connects to the daemon Unix socket, sends one NDJSON command, and reads the response.
251
- * @param {string} socketPath - Unix socket path.
252
- * @param {object} command - Command object to send (will be JSON-stringified).
253
- * @returns {Promise<object>} Parsed response object.
254
- */
255
- export function sendCommand(socketPath, command) {
256
- return new Promise((resolve, reject) => {
257
- const socket = net.createConnection(socketPath);
258
- let buffer = '';
259
- let settled = false;
260
-
261
- // Reason: guard against double-settlement since 'close' fires after socket.destroy()
262
- // in the normal data path, and we don't want the close handler to re-resolve
263
- const settle = (fn, val) => {
264
- if (settled) return;
265
- settled = true;
266
- fn(val);
267
- };
268
-
269
- socket.on('connect', () => {
270
- socket.write(JSON.stringify(command) + '\n');
271
- });
272
-
273
- socket.on('data', (chunk) => {
274
- buffer += chunk.toString();
275
- const nl = buffer.indexOf('\n');
276
- if (nl !== -1) {
277
- const line = buffer.slice(0, nl);
278
- socket.destroy();
279
- try { settle(resolve, JSON.parse(line)); }
280
- catch { settle(reject, new Error(`Invalid JSON from daemon: ${line}`)); }
281
- }
282
- });
283
-
284
- socket.on('error', (err) => settle(reject, err));
285
-
286
- socket.on('close', () => {
287
- if (settled) return;
288
- // Reason: attempt to parse whatever arrived before the socket closed unexpectedly
289
- if (buffer.trim()) {
290
- try { settle(resolve, JSON.parse(buffer.trim())); }
291
- catch { settle(reject, new Error(`Invalid JSON from daemon: ${buffer.trim()}`)); }
292
- } else {
293
- settle(reject, new Error('Daemon closed connection without a response'));
294
- }
295
- });
296
- });
297
- }
298
-
299
- // ─── Login helpers ────────────────────────────────────────────────────────────
300
-
301
- /**
302
- * Defense-in-depth check for `optionsUrl` before handing it to the `open`
303
- * package. Legit URLs always come from `chrome.runtime.getURL('options.html')`
304
- * → `chrome-extension://<id>/options.html`. Anything else is unexpected and
305
- * we should not auto-launch it (the `open` package shells out to the OS URL
306
- * handler).
307
- * @param {unknown} optionsUrl
308
- * @returns {boolean}
309
- */
310
- export function isValidOptionsUrl(optionsUrl) {
311
- return typeof optionsUrl === 'string' && /^chrome-extension:\/\/[a-z]{32}\//.test(optionsUrl);
312
- }
313
-
314
- /**
315
- * Polls the daemon's status until the user signs in (extension reports
316
- * authenticated=true) or the LOGIN_TIMEOUT_MS deadline elapses. On entry,
317
- * best-effort auto-opens the extension's options page (subject to the
318
- * isValidOptionsUrl guard above).
319
- * @param {string} socketPath - Path to the daemon Unix socket.
320
- * @param {string | undefined} optionsUrl - URL of the Dassi options page.
321
- * @returns {Promise<void>} Resolves on successful login; throws on timeout.
322
- */
323
- export async function waitForLogin(socketPath, optionsUrl) {
324
- console.error(`⚠️ Dassi is installed but you're not signed in.\n Opening the Dassi settings page...\n`);
325
-
326
- // Auto-open the options page — best-effort (package may not be installed).
327
- // Reason: only open URLs the extension would legitimately produce
328
- // (chrome.runtime.getURL → `chrome-extension://<id>/options.html`).
329
- // Defense-in-depth: even though optionsUrl is sourced from the daemon's
330
- // ready file (which lives in our owner-only ~/.dassi/ dir), validating the
331
- // protocol prevents `open` from launching arbitrary URIs/shell-handlers if
332
- // the file is ever tampered with.
333
- if (isValidOptionsUrl(optionsUrl)) {
334
- try {
335
- const { default: open } = await import('open');
336
- await open(String(optionsUrl));
337
- } catch {
338
- console.error(` Please open: ${optionsUrl}`);
339
- }
340
- } else if (optionsUrl) {
341
- console.error(` Refusing to auto-open unexpected URL: ${optionsUrl}\n Please open the Dassi settings page manually.`);
342
- }
343
-
344
- // Poll the daemon socket every LOGIN_POLL_MS until authenticated
345
- console.error(' Waiting for sign-in... (Ctrl+C to cancel)');
346
- const deadline = Date.now() + LOGIN_TIMEOUT_MS;
347
- let pollIndex = 0;
348
- while (Date.now() < deadline) {
349
- await new Promise((r) => setTimeout(r, LOGIN_POLL_MS));
350
- try {
351
- // Reason: increment poll index so each status request has a unique id for tracing
352
- const resp = await sendCommand(socketPath, { id: `poll-auth-${pollIndex++}`, action: 'status' });
353
- if (resp.success && resp.data?.authenticated) {
354
- console.error(` ✓ Signed in as ${resp.data.email}\n ✓ Ready\n`);
355
- return;
356
- }
357
- } catch { /* daemon may not be socket-ready yet — keep polling */ }
358
- }
359
-
360
- throw new Error('Login timed out. Please sign in and try again.');
361
- }
362
-
363
216
  // ─── Immediate actions ────────────────────────────────────────────────────────
364
217
 
365
218
  // HELP_TEXT lives in ./help-text.mjs (extracted to keep this file within the size limit).
@@ -381,43 +234,6 @@ function handleImmediateAction(action) {
381
234
  return false;
382
235
  }
383
236
 
384
- // ─── Daemon readiness ─────────────────────────────────────────────────────────
385
-
386
- /**
387
- * Ensures the daemon is running and ready, handling onboarding if needed.
388
- * @param {string} session
389
- * @returns {Promise<string>} The daemon's Unix socket path.
390
- */
391
- async function ensureDaemonReady(session) {
392
- const socketPath = getSocketPath(session);
393
- const readyFile = getReadyFile(session);
394
-
395
- // Reason: delete any stale ready file from a previous run so the new daemon
396
- // writes a fresh one. Skip if a daemon is already running to avoid racing.
397
- if (!isDaemonRunning(session)) {
398
- try { fs.unlinkSync(readyFile); } catch { /* ok if missing */ }
399
- }
400
-
401
- ensureDaemonRunning(session);
402
-
403
- const ready = await waitForReady(readyFile);
404
-
405
- if (ready.status === 'extension_not_installed') {
406
- console.error(
407
- `❌ Dassi extension not detected.\n\n` +
408
- ` Install it from:\n ${CHROME_WEB_STORE_URL}\n\n` +
409
- ` Then run this command again.`
410
- );
411
- process.exit(1);
412
- }
413
-
414
- if (ready.status === 'needs_login') {
415
- await waitForLogin(socketPath, ready.optionsUrl ? String(ready.optionsUrl) : undefined);
416
- }
417
-
418
- return socketPath;
419
- }
420
-
421
237
  // ─── Entry point ──────────────────────────────────────────────────────────────
422
238
 
423
239
  /**
package/help-text.mjs CHANGED
@@ -27,10 +27,10 @@ export const HELP_TEXT =
27
27
  ' status Check extension status\n' +
28
28
  ' bug-report [-o file] Export debug logs from all contexts\n' +
29
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' +
30
+ ' launch [--label <name>] [--dist <path>] [--chrome <path>]\n' +
31
+ ' [--load-mode auto|pipe|flag] [-- <chrome args…>]\n' +
32
+ ' Open Chrome with a dev dist loaded\n' +
33
+ ' (args after -- go to Chrome, e.g. --headless=new)\n' +
34
34
  ' launch --stop [label] | --stop-all\n' +
35
35
  ' Stop a launched Chrome\n\n' +
36
36
  'Options:\n' +
package/launch.mjs CHANGED
@@ -58,16 +58,17 @@ export function resolveDistPath({ dist = null, cwd = process.cwd(), exists = exi
58
58
  * `--remote-debugging-pipe` transport (fds 3/4) — not `--remote-debugging-port` —
59
59
  * and requires `--enable-unsafe-extension-debugging`. Works on branded Chrome,
60
60
  * Chrome for Testing, and Chromium alike.
61
- * @param {{ profileDir: string }} opts
61
+ * @param {{ profileDir: string; chromeArgs?: string[] }} opts
62
62
  * @returns {string[]} Chrome args.
63
63
  */
64
- export function buildLaunchArgs({ profileDir }) {
64
+ export function buildLaunchArgs({ profileDir, chromeArgs = [] }) {
65
65
  return [
66
66
  '--remote-debugging-pipe',
67
67
  '--enable-unsafe-extension-debugging',
68
68
  `--user-data-dir=${profileDir}`,
69
69
  '--no-first-run',
70
70
  '--no-default-browser-check',
71
+ ...chromeArgs,
71
72
  ];
72
73
  }
73
74
 
@@ -75,10 +76,10 @@ export function buildLaunchArgs({ profileDir }) {
75
76
  * Build Chrome args for the legacy `--load-extension` path (Chrome for Testing /
76
77
  * Chromium, which still honour it and load the extension *persistently*). The dist
77
78
  * 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
+ * @param {{ distPath: string; profileDir: string; label: string; bridgePort: number; chromeArgs?: string[] }} opts
79
80
  * @returns {string[]} Chrome args (the final entry is the seed URL to open).
80
81
  */
81
- export function buildFlagArgs({ distPath, profileDir, label, bridgePort }) {
82
+ export function buildFlagArgs({ distPath, profileDir, label, bridgePort, chromeArgs = [] }) {
82
83
  const seed = `options.html?label=${encodeURIComponent(label)}&bridgePort=${bridgePort}`;
83
84
  return [
84
85
  `--load-extension=${distPath}`,
@@ -86,6 +87,9 @@ export function buildFlagArgs({ distPath, profileDir, label, bridgePort }) {
86
87
  `--user-data-dir=${profileDir}`,
87
88
  '--no-first-run',
88
89
  '--no-default-browser-check',
90
+ // Reason: extra args must precede the positional seed URL — Chrome treats
91
+ // everything after the first positional as URLs to open.
92
+ ...chromeArgs,
89
93
  `chrome-extension://${DASSI_EXTENSION_ID}/${seed}`,
90
94
  ];
91
95
  }
@@ -219,7 +223,7 @@ async function preflightLaunch({ ensureDaemonRunning, getSocketPath, sendCommand
219
223
  * Launch Chrome with the dev dist loaded under a dedicated profile, wait for the
220
224
  * extension to register under `label`, then record it. Auto-detects the load mode
221
225
  * (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
226
+ * @param {{label:string; dist:string|null; chrome:string|null; profileDir:string|null; loadMode?:string; timeoutMs:number; chromeArgs?:string[]}} opts
223
227
  * @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
228
  * `detectLoadFlag` chooses pipe-vs-flag mode; `helperCommand` is the argv used to spawn the detached pipe-hold helper (both injectable for tests).
225
229
  * @returns {Promise<void>} resolves once the launch is recorded (or `deps.exit` is called on failure).
@@ -262,12 +266,17 @@ export async function handleLaunch(opts, deps) {
262
266
  * @returns {Promise<void>}
263
267
  */
264
268
  export async function handleLaunchHold(env = process.env, run = runPipeHoldHelper) {
269
+ let chromeArgs = [];
270
+ // Reason: fail-open to [] — a malformed env var must not strand the helper.
271
+ try { chromeArgs = JSON.parse(env.DASSI_HOLD_CHROME_ARGS ?? '[]'); } catch { process.stderr.write('⚠️ Ignoring malformed DASSI_HOLD_CHROME_ARGS (expected JSON array).\n'); }
272
+ if (!Array.isArray(chromeArgs)) chromeArgs = [];
265
273
  await run({
266
274
  label: env.DASSI_HOLD_LABEL,
267
275
  distPath: env.DASSI_HOLD_DIST,
268
276
  profileDir: env.DASSI_HOLD_PROFILE_DIR,
269
277
  bridgePort: Number(env.DASSI_HOLD_BRIDGE_PORT),
270
278
  chromePath: env.DASSI_HOLD_CHROME,
279
+ chromeArgs,
271
280
  });
272
281
  }
273
282
 
@@ -293,7 +302,7 @@ export async function dispatchLaunch(action, params, deps) {
293
302
  * @returns {Promise<number|null>} the Chrome pid, or null on spawn/connect failure.
294
303
  */
295
304
  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' });
305
+ const child = spawn(chromePath, buildFlagArgs({ distPath, profileDir, label: opts.label, bridgePort, chromeArgs: opts.chromeArgs ?? [] }), { detached: true, stdio: 'ignore' });
297
306
  child.on('error', () => {});
298
307
  child.unref();
299
308
  if (typeof child.pid !== 'number') {
@@ -327,6 +336,8 @@ async function spawnHelperAndConnect({ spawn, kill, sendCommand, socketPath, chr
327
336
  DASSI_HOLD_PROFILE_DIR: profileDir,
328
337
  DASSI_HOLD_BRIDGE_PORT: String(bridgePort),
329
338
  DASSI_HOLD_CHROME: chromePath,
339
+ // Reason: JSON, not space-joined — Chrome args may contain spaces.
340
+ DASSI_HOLD_CHROME_ARGS: JSON.stringify(opts.chromeArgs ?? []),
330
341
  },
331
342
  });
332
343
  helper.on('error', () => {});
@@ -349,6 +360,7 @@ async function spawnHelperAndConnect({ spawn, kill, sendCommand, socketPath, chr
349
360
  * debugging session. On SIGTERM/SIGINT (from `dassi launch --stop`) or if Chrome exits, it
350
361
  * tears down and exits.
351
362
  * @param {{ distPath:string; profileDir:string; label:string; bridgePort:number; chromePath:string;
363
+ * chromeArgs?:string[];
352
364
  * spawn?:Function; loadExtension?:Function; onReady?:Function; hold?:()=>Promise<void>;
353
365
  * error?:Function; exit?:Function }} o
354
366
  * @returns {Promise<void>} In production never resolves (hold() is infinite until killed);
@@ -357,11 +369,12 @@ async function spawnHelperAndConnect({ spawn, kill, sendCommand, socketPath, chr
357
369
  export async function runPipeHoldHelper(o) {
358
370
  const {
359
371
  distPath, profileDir, label, bridgePort, chromePath,
372
+ chromeArgs = [],
360
373
  spawn = childSpawn, loadExtension = loadExtensionOverPipe,
361
374
  onReady = () => {}, hold = () => new Promise(() => {}),
362
375
  error = (m) => process.stderr.write(`${m}\n`), exit = process.exit,
363
376
  } = o;
364
- const child = spawn(chromePath, buildLaunchArgs({ profileDir }), { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
377
+ const child = spawn(chromePath, buildLaunchArgs({ profileDir, chromeArgs }), { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
365
378
  let dispose = () => {};
366
379
  let shuttingDown = false;
367
380
  const shutdown = () => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dassi_ai/cli",
3
- "version": "0.4.0",
4
- "description": "CLI for the Dassi Chrome extension run browser automation from the terminal",
3
+ "version": "0.5.0",
4
+ "description": "CLI for the Dassi Chrome extension \u2014 run browser automation from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "dassi": "./dassi.mjs"
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "dassi.mjs",
11
11
  "dassi-daemon.mjs",
12
+ "daemon-client.mjs",
12
13
  "dassi-shared.mjs",
13
14
  "tool-commands.mjs",
14
15
  "format-response.mjs",