@dassi_ai/cli 0.4.0 → 0.7.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/dassi.mjs CHANGED
@@ -4,56 +4,52 @@
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';
10
+ import { randomUUID } from 'node:crypto';
11
11
  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';
12
+ import { getSocketPath, validateSession, getAppDir, getLaunchesFile } from './dassi-shared.mjs';
13
+ import { ensureDaemonRunning, sendCommand, sendAndWait, ensureDaemonReady } from './daemon-client.mjs';
22
14
  import { dispatchLaunch } from './launch.mjs';
23
- import { parseToolCommand, requireTarget, parseStrictInt } from './tool-commands.mjs';
15
+ import { parseToolCommand, requireTarget, parseStrictInt, parseTarget, parseWait } from './tool-commands.mjs';
24
16
  import { runWithGroupExpansion } from './group-expansion.mjs';
25
17
  import { formatResponse } from './format-response.mjs';
26
18
  import { HELP_TEXT } from './help-text.mjs';
27
19
 
28
20
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
- const DAEMON_SCRIPT = path.join(__dirname, 'dassi-daemon.mjs');
30
21
  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}`;
36
22
 
37
23
  // ─── Arg parsing ──────────────────────────────────────────────────────────────
38
24
 
39
25
  /**
40
26
  * Parses CLI arguments into a command descriptor.
41
- * Supports browser tool commands (navigate, click, fill, type, read-page, etc.),
27
+ * Supports live tool discovery and generic browser calls,
42
28
  * agent commands (run, list-tabs, status, raw), and top-level flags (--version, --help).
43
29
  * Run `dassi --help` for the full command reference.
44
30
  * @param {string[]} argv process.argv.slice(2)
45
31
  * @returns {{ action: string; params: Record<string, unknown>; session: string; json: boolean; profile: string | null }}
46
32
  */
47
- export function parseCliArgs(argv) {
33
+ export function parseCliArgs(argv, { interactive = false } = {}) {
48
34
  const args = [...argv];
49
35
 
36
+ // Reason: hoist the `--` split to BEFORE global flag parsing so that Chrome
37
+ // passthrough tokens like --json, --label, --profile, or --stop can never be
38
+ // mistakenly consumed as CLI flags. Everything after the first `--` token is
39
+ // sliced out here and stored verbatim in `chromeArgs`; the remaining `args`
40
+ // array is then parsed as usual with no risk of collision. Non-launch commands
41
+ // that supply a `--` separator are rejected loudly below (after the action is
42
+ // known) so callers get a clear error instead of silent data loss.
43
+ const sepIdx = args.indexOf('--');
44
+ const chromeArgs = sepIdx === -1 ? [] : args.splice(sepIdx).slice(1);
45
+
50
46
  // Reason: handle --version and --help before any flag parsing so they work
51
47
  // even when other flags like --session are incomplete (e.g. `dassi --version --session`)
52
48
  if (consumeFlag(args, '--version')) {
53
- return { action: 'version', params: {}, session: 'default', json: false, profile: null };
49
+ return { action: 'version', params: {}, session: 'default', json: args.includes('--json'), profile: null };
54
50
  }
55
51
  if (consumeFlag(args, '--help')) {
56
- return { action: 'help', params: {}, session: 'default', json: false, profile: null };
52
+ return { action: 'help', params: {}, session: 'default', json: args.includes('--json'), profile: null };
57
53
  }
58
54
 
59
55
  const session = validateSession(getFlag(args, '--session') ?? process.env.DASSI_SESSION ?? 'default');
@@ -62,23 +58,51 @@ export function parseCliArgs(argv) {
62
58
 
63
59
  // Reason: centralise the return shape so every command branch includes profile
64
60
  // without editing each return individually
65
- const finish = (action, params) => ({ action, params, session, json, profile });
61
+ const finish = (action, params) => {
62
+ if (args.length) throw new Error(`Unexpected argument: ${args[0]}. Run: dassi --help`);
63
+ const { profileTarget, ...rest } = params;
64
+ if (profile && profileTarget && profile !== profileTarget) throw new Error('Target includes a profile; omit --profile or use the same profile ID.');
65
+ return { action, params: rest, session, json, profile: profileTarget ?? profile };
66
+ };
67
+
68
+ const command = args.shift() ?? (interactive && argv.length === 0 ? 'setup' : undefined);
69
+ if (!command) throw new Error('No command specified. Run dassi setup to connect Chrome, or dassi --help for commands.');
70
+
71
+ // Reason: chromeArgs (tokens after `--`) are only meaningful for the `launch`
72
+ // command. Any other command that supplies `--` tokens would have them silently
73
+ // dropped — fail loudly instead so the caller knows their intent was not honoured.
74
+ if (chromeArgs.length > 0 && command !== 'launch') {
75
+ throw new Error("'--' is only valid with the launch command");
76
+ }
66
77
 
67
- const command = args.shift();
68
- if (!command) throw new Error('No command specified. Run: dassi --help');
78
+ if (command === 'setup') {
79
+ const waitRaw = getFlag(args, '--wait');
80
+ return finish('setup', { waitMs: parseWait(waitRaw ?? '5m'), noOpen: consumeFlag(args, '--no-open') });
81
+ }
82
+
83
+ if (command === 'skill') {
84
+ const pathOnly = consumeFlag(args, '--path');
85
+ const remove = consumeFlag(args, '--remove');
86
+ if (pathOnly && remove) throw new Error('Use either skill --path or skill --remove.');
87
+ return finish(remove ? 'remove_skill' : 'skill', remove ? {} : { pathOnly });
88
+ }
69
89
 
70
90
  if (command === 'list-tabs') {
71
- const all = consumeFlag(args, '--all');
72
- return finish('list_tabs', all ? { all: true } : {});
91
+ consumeFlag(args, '--all');
92
+ return finish('list_tabs', { all: true });
73
93
  }
74
94
 
75
95
  if (command === 'list-groups') {
76
- const all = consumeFlag(args, '--all');
77
- return finish('list_groups', all ? { all: true } : {});
96
+ consumeFlag(args, '--all');
97
+ return finish('list_groups', { all: true });
78
98
  }
79
99
 
80
- if (command === 'status') {
81
- return finish('status', {});
100
+ if (command === 'status' || command === 'stop') {
101
+ const waitRaw = getFlag(args, '--wait');
102
+ const taskId = args[0] && !args[0].startsWith('-') ? args.shift() : undefined;
103
+ if (!taskId && (command === 'stop' || waitRaw !== undefined)) throw new Error(`${command} requires a task ID`);
104
+ return finish(taskId ? (command === 'stop' ? 'task_stop' : 'task_status') : 'status',
105
+ taskId ? { taskId, ...(waitRaw !== undefined ? { waitMs: parseWait(waitRaw) } : {}) } : {});
82
106
  }
83
107
 
84
108
  if (command === 'list-profiles') {
@@ -86,10 +110,16 @@ export function parseCliArgs(argv) {
86
110
  }
87
111
 
88
112
  if (command === 'launch') {
89
- if (consumeFlag(args, '--stop-all')) return finish('launch_stop', { all: true });
113
+ if (consumeFlag(args, '--stop-all')) {
114
+ // Reason: chromeArgs with --stop-all makes no sense — reject to prevent confusion.
115
+ if (chromeArgs.length > 0) throw new Error("'--' chrome args cannot be combined with launch --stop-all");
116
+ return finish('launch_stop', { all: true });
117
+ }
90
118
  const stopIdx = args.indexOf('--stop');
91
119
  if (stopIdx !== -1) {
92
120
  args.splice(stopIdx, 1);
121
+ // Reason: chromeArgs with --stop makes no sense — reject to prevent confusion.
122
+ if (chromeArgs.length > 0) throw new Error("'--' chrome args cannot be combined with launch --stop");
93
123
  // Reason: resolve the stop label like the start path does — a positional
94
124
  // token after --stop wins, else the global --label/--profile (parsed into
95
125
  // `profile` before this branch), else the default. Keeps `--stop --label qa`
@@ -117,6 +147,7 @@ export function parseCliArgs(argv) {
117
147
  profileDir: getFlag(args, '--profile-dir') ?? null,
118
148
  loadMode,
119
149
  timeoutMs: timeoutRaw ? parseStrictInt(timeoutRaw, '--timeout') : 30000,
150
+ chromeArgs,
120
151
  });
121
152
  }
122
153
 
@@ -126,10 +157,13 @@ export function parseCliArgs(argv) {
126
157
  if (command === 'run') {
127
158
  const prompt = args.shift();
128
159
  if (!prompt) throw new Error('run requires a prompt argument');
129
- const target = requireTarget('run', args, getFlag);
130
- const timeoutRaw = getFlag(args, '--timeout');
131
- const timeoutMs = timeoutRaw ? parseStrictInt(timeoutRaw, '--timeout') : undefined;
132
- const base = { prompt, ...(timeoutMs !== undefined ? { timeoutMs } : {}) };
160
+ const taskId = getFlag(args, '--task');
161
+ if (args.includes('--timeout')) throw new Error('run no longer accepts --timeout. Use --wait to wait locally, or stop <task-id> to cancel.');
162
+ if (taskId && ['--tab', '--group', '--group-title'].some(flag => args.includes(flag))) throw new Error('Use --task to continue its original browser context; omit tab/group selectors.');
163
+ const target = taskId ? { taskId } : requireTarget('run', args, getFlag);
164
+ const waitRaw = getFlag(args, '--wait');
165
+ const waitMs = waitRaw !== undefined ? parseWait(waitRaw) : 0;
166
+ const base = { prompt, ...(waitMs ? { waitMs } : {}) };
133
167
  return finish('run', { ...base, ...target });
134
168
  }
135
169
 
@@ -141,13 +175,13 @@ export function parseCliArgs(argv) {
141
175
  if (command === 'panel-screenshot') {
142
176
  const tabRaw = getFlag(args, '--tab');
143
177
  if (!tabRaw) throw new Error('panel-screenshot requires --tab <tabId>');
144
- const tabId = parseStrictInt(tabRaw, '--tab');
178
+ const { id: tabId, ...target } = parseTarget(tabRaw, '--tab');
145
179
  const output = getFlag(args, '-o') ?? getFlag(args, '--output') ?? null;
146
180
  const widthRaw = getFlag(args, '--width');
147
181
  const heightRaw = getFlag(args, '--height');
148
182
  const width = widthRaw ? parseStrictInt(widthRaw, '--width') : undefined;
149
183
  const height = heightRaw ? parseStrictInt(heightRaw, '--height') : undefined;
150
- return finish('panel_screenshot', { tabId, ...(output ? { _output: output } : {}), ...(width ? { width } : {}), ...(height ? { height } : {}) });
184
+ return finish('panel_screenshot', { tabId, ...target, ...(output ? { _output: output } : {}), ...(width ? { width } : {}), ...(height ? { height } : {}) });
151
185
  }
152
186
 
153
187
  if (command === 'raw') {
@@ -159,14 +193,18 @@ export function parseCliArgs(argv) {
159
193
  return finish('raw', cmd);
160
194
  }
161
195
 
162
- // Reason: Tool commands are extracted to tool-commands.mjs to keep parseCliArgs
163
- // and dassi.mjs within CLAUDE.md size limits (40 lines / 500 lines).
164
196
  const toolParams = parseToolCommand(command, args, getFlag, consumeFlag);
165
197
  if (toolParams) {
198
+ // Reason: some tool commands resolve to dedicated bridge methods rather
199
+ // than tool_exec (open --window → open_tab).
200
+ if (toolParams.__bridgeAction) {
201
+ const { __bridgeAction, ...params } = toolParams;
202
+ return finish(__bridgeAction, params);
203
+ }
166
204
  return finish('tool_exec', toolParams);
167
205
  }
168
206
 
169
- throw new Error(`Unknown command: "${command}". Run: dassi --help`);
207
+ throw new Error(`Unknown command: "${command}". Browser commands use dassi tools and dassi call. Run: dassi --help`);
170
208
  }
171
209
 
172
210
  /**
@@ -199,258 +237,78 @@ function consumeFlag(args, flag) {
199
237
  return true;
200
238
  }
201
239
 
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
240
  // ─── Immediate actions ────────────────────────────────────────────────────────
364
241
 
365
242
  // HELP_TEXT lives in ./help-text.mjs (extracted to keep this file within the size limit).
366
243
 
367
- /**
368
- * Handles --version and --help, which don't need a daemon. Returns true if handled.
369
- * @param {string} action
370
- * @returns {boolean}
371
- */
372
- function handleImmediateAction(action) {
373
- if (action === 'version') {
374
- console.log(VERSION);
375
- process.exit(0);
376
- }
377
- if (action === 'help') {
378
- console.log(HELP_TEXT);
379
- process.exit(0);
380
- }
381
- return false;
382
- }
383
-
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;
244
+ function handleImmediateAction({ action, params, json }) {
245
+ let text;
246
+ if (action === 'version') text = VERSION;
247
+ else if (action === 'help') text = HELP_TEXT;
248
+ else if (action === 'skill') {
249
+ const skillDir = path.join(__dirname, 'skills', 'dassi');
250
+ text = params.pathOnly ? skillDir : fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8');
251
+ } else return false;
252
+ console.log(json ? JSON.stringify({ success: true, data: text }) : text);
253
+ return true;
419
254
  }
420
255
 
421
256
  // ─── Entry point ──────────────────────────────────────────────────────────────
422
257
 
423
258
  /**
424
- * Main CLI entry point. Parses args, ensures daemon is running, handles
425
- * onboarding, sends command, prints response.
259
+ * Parses arguments, connects to the daemon, and prints the command result.
426
260
  * @returns {Promise<void>}
427
261
  */
428
- export async function run() {
262
+ export async function run(argv = process.argv.slice(2)) {
429
263
  let parsed;
430
264
  try {
431
- parsed = parseCliArgs(process.argv.slice(2));
265
+ parsed = parseCliArgs(argv, { interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY) });
266
+ await execute(parsed);
432
267
  } catch (err) {
433
- console.error(err.message);
434
- process.exit(1);
268
+ const args = argv.slice(0, argv.indexOf('--') === -1 ? argv.length : argv.indexOf('--'));
269
+ const json = parsed?.json ?? args.includes('--json');
270
+ const response = { success: false, error: err instanceof Error ? err.message : String(err), ...(err?.outcome === 'unknown' ? { outcome: 'unknown' } : {}) };
271
+ if (json) console.log(formatResponse('error', response, true));
272
+ else console.error(formatResponse('error', response, false));
273
+ process.exitCode = 1;
435
274
  }
275
+ }
436
276
 
277
+ async function execute(parsed) {
437
278
  const { action, params, session, json, profile } = parsed;
438
- handleImmediateAction(action);
279
+ if (handleImmediateAction(parsed)) return;
280
+ if (action === 'remove_skill') {
281
+ const { unregisterSkills } = await import('./setup.mjs');
282
+ const skills = await unregisterSkills();
283
+ console.log(json ? JSON.stringify({ success: true, data: { skills } }) : [
284
+ ...skills.map(skill => `${skill.status}: ${skill.path}`),
285
+ 'To remove the CLI, run: npm uninstall -g @dassi_ai/cli',
286
+ ].join('\n'));
287
+ return;
288
+ }
289
+ if (action === 'setup') {
290
+ const { setup, formatSetup } = await import('./setup.mjs');
291
+ const data = await setup({ ...params, profile, session, interactive: !json && Boolean(process.stdin.isTTY) });
292
+ const response = { success: data.ready, data, ...(!data.ready ? { error: data.next } : {}) };
293
+ console.log(json ? JSON.stringify(response) : formatSetup(data));
294
+ if (!data.ready) process.exitCode = 1;
295
+ return;
296
+ }
439
297
 
440
298
  // Launch family (launch / --stop / __launch-hold) → launch.mjs.
441
299
  if (await dispatchLaunch(action, params, { spawn: child_process.spawn, ensureDaemonRunning, getSocketPath, sendCommand, appDir: getAppDir(), launchesFile: getLaunchesFile() })) return;
442
300
 
443
301
  const socketPath = await ensureDaemonReady(session);
444
302
 
445
- const id = `cli_${Date.now()}`;
303
+ const id = `cli_${randomUUID()}`;
446
304
 
447
305
  // Group expansion: if action=run or action=tool_exec with groupId/groupTitle, expand and fan out sequentially
448
306
  if (
449
307
  (action === 'run' || action === 'tool_exec') &&
450
308
  (params.groupId !== undefined || params.groupTitle !== undefined)
451
309
  ) {
452
- const allOk = await runWithGroupExpansion(socketPath, action, params, json, sendCommand, formatResponse, profile);
453
- if (!allOk) process.exit(1);
310
+ const allOk = await runWithGroupExpansion(socketPath, action, params, json, sendAndWait, formatResponse, profile);
311
+ if (!allOk) process.exitCode = 1;
454
312
  return;
455
313
  }
456
314
 
@@ -459,10 +317,10 @@ export async function run() {
459
317
  const command = action === 'raw'
460
318
  ? { id, ...params }
461
319
  : { id, action, ...params, ...(profile ? { target: profile } : {}) };
462
- const response = await sendCommand(socketPath, command);
320
+ const response = await sendAndWait(socketPath, command);
463
321
 
464
322
  console.log(formatResponse(action, response, json, params));
465
- if (!response.success) process.exit(1);
323
+ if (!response.success || (command.action !== 'task_stop' && ['failed', 'stopped'].includes(response.data?.status))) process.exitCode = 1;
466
324
  }
467
325
 
468
326
  // ── Entry point guard ─────────────────────────────────────────────────────────
@@ -493,8 +351,5 @@ export function isMainModule(metaUrl, argvPath) {
493
351
  }
494
352
 
495
353
  if (isMainModule(import.meta.url, process.argv[1])) {
496
- run().catch((err) => {
497
- console.error(`❌ ${err.message}`);
498
- process.exit(1);
499
- });
354
+ await run();
500
355
  }
@@ -8,13 +8,13 @@ import * as fs from 'fs';
8
8
  /** @param {{ success: boolean; data?: unknown }} response */
9
9
  function formatListTabs(response) {
10
10
  const tabs = /** @type {Array<{tabId:number;title:string;url:string;active:boolean}>} */ (response.data ?? []);
11
- if (tabs.length === 0) return '(no open tabs)';
12
- const header = 'TAB ID TITLE URL';
11
+ if (tabs.length === 0) return '(no open tabs — open Dassi in Chrome, then retry)';
12
+ const header = 'PROFILE TARGET TITLE / URL';
13
13
  const rows = tabs.map((t) => {
14
14
  const id = String(t.tabId).padEnd(7);
15
15
  const prefix = t.active ? '* ' : ' ';
16
16
  const title = (prefix + t.title).slice(0, 42).padEnd(42);
17
- return `${id} ${title} ${t.url}`;
17
+ return `${(t.profile ?? '').padEnd(16)} ${(t.target ?? id).padEnd(48)} ${title} ${t.url}`;
18
18
  });
19
19
  return [header, ...rows].join('\n');
20
20
  }
@@ -23,22 +23,27 @@ function formatListTabs(response) {
23
23
  function formatListGroups(response) {
24
24
  const groups = /** @type {Array<{id:number;title:string;color:string;windowId:number;tabCount:number}>} */ (response.data ?? []);
25
25
  if (groups.length === 0) return '(no tab groups)';
26
- const header = 'GROUP ID COLOR TABS WINDOW TITLE';
26
+ const header = 'PROFILE TARGET COLOR TABS WINDOW TITLE';
27
27
  const rows = groups.map((g) => {
28
28
  const id = String(g.id).padEnd(9);
29
29
  const color = (g.color ?? '').padEnd(8);
30
30
  const tabs = String(g.tabCount).padEnd(6);
31
31
  const win = String(g.windowId).padEnd(8);
32
- return `${id} ${color} ${tabs} ${win} ${g.title || '(untitled)'}`;
32
+ return `${(g.profile ?? '').padEnd(16)} ${(g.target ?? id).padEnd(48)} ${color} ${tabs} ${win} ${g.title || '(untitled)'}`;
33
33
  });
34
34
  return [header, ...rows].join('\n');
35
35
  }
36
36
 
37
37
  /** @param {{ success: boolean; data?: unknown }} response */
38
38
  function formatRun(response) {
39
- const d = /** @type {{ answer?: string; toolCalls?: number; durationMs?: number }} */ (response.data ?? {});
40
- const footer = `\n(${d.toolCalls ?? 0} tool calls, ${((d.durationMs ?? 0) / 1000).toFixed(1)}s)`;
41
- return (d.answer ?? '') + footer;
39
+ const d = response.data ?? {};
40
+ if (!d.taskId) return `${d.answer ?? ''}\n(${d.toolCalls ?? 0} tool calls, ${((d.durationMs ?? 0) / 1000).toFixed(1)}s)`;
41
+ const status = d.status[0].toUpperCase() + d.status.slice(1);
42
+ const lines = [`${status} · ${Math.floor((d.durationMs ?? 0) / 60000)} minutes · ${d.profile ?? ''}`, `Task: ${d.taskId}`];
43
+ if (d.answer) lines.push(d.answer);
44
+ if (d.reason) lines.push(d.reason);
45
+ if (d.status === 'running' || d.status === 'stopping') lines.push(`Check: dassi status '${d.taskId}'`, `Stop: dassi stop '${d.taskId}'`);
46
+ return lines.join('\n');
42
47
  }
43
48
 
44
49
  /**
@@ -99,13 +104,14 @@ function formatExportLogs(response, params) {
99
104
  function formatListProfiles(response) {
100
105
  const rows = /** @type {Array<{label:string;port:number}>} */ (response.data ?? []);
101
106
  if (rows.length === 0) return '(no profiles connected)';
102
- const header = 'PROFILE PORT';
103
- const body = rows.map((r) => `${String(r.label).padEnd(32)} ${r.port}`);
107
+ const header = 'PROFILE ID STATUS';
108
+ const body = rows.map((r) => `${String(r.label).padEnd(16)} ${String(r.id ?? r.port).padEnd(37)} ${r.error ?? (r.authenticated ? 'Signed in' : 'Sign in required')}`);
104
109
  return [header, ...body].join('\n');
105
110
  }
106
111
 
107
112
  /** @param {{ success: boolean; data?: unknown }} response */
108
113
  function formatStatus(response) {
114
+ if (Array.isArray(response.data)) return formatListProfiles(response);
109
115
  const d = /** @type {{ authenticated?: boolean; email?: string | null }} */ (response.data ?? {});
110
116
  if (d.authenticated) {
111
117
  return `✓ Signed in as ${d.email}`;
@@ -119,6 +125,9 @@ const FORMATTERS = {
119
125
  list_tabs: (r, _p, _a) => formatListTabs(r),
120
126
  list_groups: (r, _p, _a) => formatListGroups(r),
121
127
  run: (r, _p, _a) => formatRun(r),
128
+ task_status: (r) => formatRun(r),
129
+ task_stop: (r) => formatRun(r),
130
+ list_tools: (r) => Array.isArray(r.data) ? r.data.map(t => `${t.name} ${t.description}`).join('\n') : JSON.stringify(r.data, null, 2),
122
131
  tool_exec: (r, p, a) => formatToolExec(r, p, a),
123
132
  panel_screenshot: (r, p, a) => formatToolExec(r, p, a),
124
133
  export_logs: (r, p, _a) => formatExportLogs(r, p),
@@ -138,7 +147,13 @@ export function formatResponse(action, response, rawJson, params) {
138
147
  if (rawJson) return JSON.stringify(response);
139
148
  if (!response.success) return `❌ Error: ${response.error ?? 'Unknown error'}`;
140
149
  const formatter = FORMATTERS[action];
141
- if (formatter) return formatter(response, params, action);
150
+ if (formatter) {
151
+ const output = formatter(response, params, action);
152
+ const errors = ['list_tabs', 'list_groups'].includes(action)
153
+ ? (response.profiles ?? []).flatMap(p => [p.error, p.authError].filter(Boolean).map(error => `${p.label} (${p.id}): ${error}`))
154
+ : [];
155
+ return [output, ...errors].join('\n');
156
+ }
142
157
  // Default: pretty-print data
143
158
  return JSON.stringify(response.data, null, 2);
144
159
  }