@dassi_ai/cli 0.5.0 → 0.7.1

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-shared.mjs CHANGED
@@ -3,13 +3,18 @@
3
3
  *
4
4
  * Pure helper functions shared between the CLI client (dassi.mjs) and the
5
5
  * daemon process (dassi-daemon.mjs). Includes path resolution, session
6
- * validation, daemon lifecycle helpers, ready-file payload handling, and
7
- * the serialized command queue.
6
+ * validation, daemon lifecycle helpers, readiness, and browser identity.
8
7
  */
9
8
 
10
9
  import * as fs from 'fs';
11
10
  import * as path from 'path';
12
11
  import * as os from 'os';
12
+ import { fileURLToPath } from 'url';
13
+
14
+ const PACKAGE = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8'));
15
+ export const CLI_VERSION = PACKAGE.version;
16
+ /** Lives in package.json so the extension release gate can read it from npm (`npm view … dassi.protocol`) without downloading the tarball. */
17
+ export const CLI_PROTOCOL_VERSION = PACKAGE.dassi.protocol;
13
18
 
14
19
  // ─── Extension identity ───────────────────────────────────────────────────────
15
20
 
@@ -19,6 +24,7 @@ import * as os from 'os';
19
24
  * path — letting `dassi launch` open the extension's options page directly.
20
25
  */
21
26
  export const DASSI_EXTENSION_ID = 'bjcngahpcjeililljmfegmlanlpgibdi';
27
+ export const CHROME_WEB_STORE_URL = `https://chromewebstore.google.com/detail/dassi-ai-browser-agent-fo/${DASSI_EXTENSION_ID}`;
22
28
 
23
29
  // ─── Path helpers ─────────────────────────────────────────────────────────────
24
30
 
@@ -54,7 +60,7 @@ export function getPidFile(session) {
54
60
 
55
61
  /**
56
62
  * Returns the ready file path for the given session.
57
- * Written by the daemon once the extension WS is connected and auth is verified.
63
+ * Written when the local command transport is listening.
58
64
  * @param {string} session
59
65
  * @returns {string}
60
66
  */
@@ -132,205 +138,67 @@ export function isDaemonRunning(session) {
132
138
  // Reason: signal 0 checks process existence without delivering a real signal
133
139
  process.kill(pid, 0);
134
140
  return true;
135
- } catch {
141
+ } catch (error) {
142
+ if (error.code === 'EPERM') return true;
136
143
  // Process does not exist — clean up stale files
137
144
  cleanupDaemonFiles(session);
138
145
  return false;
139
146
  }
140
147
  }
141
148
 
142
- // ─── Onboarding payload helpers ───────────────────────────────────────────────
143
-
144
- /**
145
- * Builds the ready file payload from an extension status response.
146
- * @param {{ authenticated: boolean; email: string | null; optionsUrl: string }} status
147
- * @returns {{ status: string; email?: string | null; optionsUrl?: string }}
148
- */
149
- export function buildReadyPayload(status) {
150
- if (status.authenticated) {
151
- return { status: 'ok', email: status.email };
152
- }
153
- return { status: 'needs_login', optionsUrl: status.optionsUrl };
154
- }
149
+ // ─── Readiness payload helpers ───────────────────────────────────────────────
155
150
 
156
151
  /**
157
152
  * Parses the ready file content into a status object.
158
- * Returns extension_not_installed on parse failure as a safe fallback.
153
+ * Throws for incomplete writes so startup can keep polling.
159
154
  * @param {string} raw
160
155
  * @returns {{ status: string; [key: string]: unknown }}
161
156
  */
162
157
  export function parseReadyPayload(raw) {
163
- try {
164
- return JSON.parse(raw);
165
- } catch {
166
- return { status: 'extension_not_installed' };
167
- }
158
+ const payload = JSON.parse(raw);
159
+ if (!payload || typeof payload.status !== 'string') throw new Error('Invalid daemon readiness file');
160
+ return payload;
168
161
  }
169
162
 
170
- // ─── Serialized command queue ─────────────────────────────────────────────────
171
-
172
- /**
173
- * Creates a serialized command queue.
174
- * Commands are executed one at a time in FIFO order regardless of how many
175
- * socket clients are connected simultaneously. This prevents concurrent `run`
176
- * calls from racing inside the extension.
177
- *
178
- * @param {(cmd: Record<string, unknown>) => Promise<{id: string; success: boolean; data?: unknown; error?: string}>} handler
179
- * @returns {{ enqueue: (cmd: Record<string, unknown>) => Promise<{id: string; success: boolean; data?: unknown; error?: string}> }}
180
- */
181
- export function createCommandQueue(handler) {
182
- const queue = [];
183
- let processing = false;
184
-
185
- async function processNext() {
186
- if (processing || queue.length === 0) return;
187
- processing = true;
188
- const { cmd, resolve } = queue.shift();
189
- try {
190
- const result = await handler(cmd);
191
- resolve(result);
192
- } catch (err) {
193
- resolve({ id: String(cmd.id ?? 'unknown'), success: false, error: err.message ?? String(err) });
194
- } finally {
195
- processing = false;
196
- // Reason: .catch() prevents unhandled rejection from crashing the daemon
197
- // if the recursive call fails before reaching its own try/catch
198
- processNext().catch(() => {});
199
- }
200
- }
163
+ // ─── Connected browser installations ──────────────────────────────────────────
201
164
 
202
- return {
203
- /**
204
- * Enqueue a command and return a promise that resolves with the response.
205
- * The returned Promise always resolves; errors are encoded as { success: false, error } values.
206
- * @param {Record<string, unknown>} cmd
207
- * @returns {Promise<{id: string; success: boolean; data?: unknown; error?: string}>}
208
- */
209
- enqueue(cmd) {
210
- return new Promise((resolve) => {
211
- queue.push({ cmd, resolve });
212
- processNext().catch(() => {});
213
- });
214
- },
215
- };
165
+ /** A lost transport is not evidence that a browser action failed or stopped. */
166
+ export function unknownToolOutcome(error) {
167
+ return Object.assign(new Error(`${error.message} Tool outcome unknown; it may still be running. Inspect the browser before retrying.`), { outcome: 'unknown' });
216
168
  }
217
169
 
218
- // ─── Connection registry ──────────────────────────────────────────────────────
219
-
220
- /**
221
- * Creates a registry of connected extension sockets, keyed by a unique label.
222
- * Replaces the daemon's old single-socket model so multiple Chrome profiles can
223
- * connect at once and commands can be routed to a chosen one by label.
224
- *
225
- * @returns {{
226
- * add: (conn: { ws: any; label: string | null; installId: string; port: number }) => string;
227
- * remove: (ws: any) => ({ label: string; installId: string } | null);
228
- * getByWs: (ws: any) => (object | null);
229
- * resolve: (target: string | null) => object;
230
- * list: () => Array<{ label: string; port: number }>;
231
- * size: () => number;
232
- * }}
233
- */
234
170
  export function createConnectionRegistry() {
235
- /** @type {Map<string, { ws: any; label: string; installId: string; port: number; pending: Map<string, any> }>} */
236
171
  const conns = new Map();
237
-
238
- // Reason: label is the routing key; if two profiles share a label, suffix the
239
- // installId so a connection never silently fails to register (suffix > reject).
240
- function uniqueKey(label) {
241
- if (!conns.has(label)) return label;
242
- let i = 2;
243
- while (conns.has(`${label}#${i}`)) i++;
244
- return `${label}#${i}`;
245
- }
246
-
247
- function labels() {
248
- return [...conns.values()].map((c) => c.label);
249
- }
250
-
251
172
  return {
252
- /**
253
- * Register a new extension connection. Returns the unique key (label) assigned.
254
- * Duplicate labels are disambiguated by appending a numeric suffix.
255
- * @param {{ ws: any; label: string | null; installId: string; port: number }} conn
256
- * @returns {string}
257
- */
258
173
  add({ ws, label, installId, port }) {
259
- // Reason: on reconnect (port/label change) the new TCP connection can register
260
- // before the daemon observes the old socket's close. Evict any stale entry for
261
- // the same install first, so we don't end up with phantom duplicates (dev +
262
- // dev#2) that make resolve(null) wrongly report "multiple profiles".
263
- if (installId) {
264
- for (const [k, c] of conns) {
265
- if (c.installId === installId) { conns.delete(k); break; }
266
- }
174
+ const id = installId || `legacy-${port}`;
175
+ const old = conns.get(id);
176
+ if (old) {
177
+ for (const pending of old.pending.values()) pending.reject(Object.assign(new Error('Extension reconnected; check the existing task before retrying.'), { outcome: 'unknown' }));
178
+ old.ws.close();
267
179
  }
268
- // Reason: identity fallback chain label, then installId, then port so a
269
- // connection is always addressable even if a legacy build reports neither.
270
- const base =
271
- (label && String(label).trim()) || (installId && String(installId).trim()) || String(port);
272
- const key = uniqueKey(base);
273
- conns.set(key, { ws, label: key, installId, port, pending: new Map() });
274
- return key;
180
+ conns.set(id, { id, installId, label: label?.trim() || id, port, ws, pending: new Map() });
181
+ return id;
275
182
  },
276
-
277
- /**
278
- * Remove the connection associated with the given WebSocket.
279
- * @param {any} ws
280
- * @returns {{ label: string; installId: string } | null}
281
- */
282
183
  remove(ws) {
283
- for (const [key, c] of conns) {
284
- if (c.ws === ws) {
285
- conns.delete(key);
286
- return { label: c.label, installId: c.installId };
287
- }
288
- }
289
- return null;
290
- },
291
-
292
- /**
293
- * Look up the registry entry for a given WebSocket (includes its pending map).
294
- * @param {any} ws
295
- * @returns {object | null}
296
- */
297
- getByWs(ws) {
298
- for (const c of conns.values()) if (c.ws === ws) return c;
299
- return null;
184
+ const entry = this.getByWs(ws);
185
+ if (entry) conns.delete(entry.id);
186
+ return entry ?? null;
300
187
  },
301
-
302
- /**
303
- * Resolve a target label to its registry entry.
304
- * Passing null auto-resolves when exactly one connection is registered.
305
- * Throws descriptive errors for no connections, ambiguity, or unknown label.
306
- * @param {string | null} target
307
- * @returns {object}
308
- */
188
+ getByWs(ws) { return [...conns.values()].find((entry) => entry.ws === ws) ?? null; },
309
189
  resolve(target) {
310
- if (conns.size === 0) throw new Error('No extension connected');
190
+ if (!conns.size) throw new Error('No extension connected. Open Dassi in Chrome, then retry.');
311
191
  if (target == null) {
312
192
  if (conns.size === 1) return [...conns.values()][0];
313
- throw new Error(`Multiple profiles connected (${labels().join(', ')}). Specify --profile <label>.`);
193
+ throw new Error(`Multiple profiles connected (${[...conns.values()].map(e => e.label).join(', ')}). Copy a target from dassi list-tabs or use --profile <id>.`);
314
194
  }
315
- const entry = conns.get(String(target));
316
- if (!entry) throw new Error(`Unknown profile "${target}". Connected: ${labels().join(', ')}`);
317
- return entry;
318
- },
319
-
320
- /**
321
- * Returns a shallow list of all connected profiles (label + port only).
322
- * @returns {Array<{ label: string; port: number }>}
323
- */
324
- list() {
325
- return [...conns.values()].map((c) => ({ label: c.label, port: c.port }));
326
- },
327
-
328
- /**
329
- * Returns the number of currently connected profiles.
330
- * @returns {number}
331
- */
332
- size() {
333
- return conns.size;
195
+ if (conns.has(String(target))) return conns.get(String(target));
196
+ const matches = [...conns.values()].filter(entry => entry.label === target);
197
+ if (matches.length === 1) return matches[0];
198
+ if (matches.length > 1) throw new Error(`Profile name "${target}" is ambiguous. Use a profile ID from dassi list-profiles.`);
199
+ throw new Error(`Unknown profile "${target}". Connected: ${[...conns.values()].map(e => `${e.label} (${e.id})`).join(', ')}`);
334
200
  },
201
+ list() { return [...conns.values()].map(({ id, label, port }) => ({ id, label, port })); },
202
+ size() { return conns.size; },
335
203
  };
336
204
  }
package/dassi.mjs CHANGED
@@ -7,34 +7,29 @@
7
7
  import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
  import * as child_process from 'child_process';
10
+ import { randomUUID } from 'node:crypto';
10
11
  import { fileURLToPath, pathToFileURL } from 'url';
11
- import { getSocketPath, validateSession, getAppDir, getLaunchesFile } from './dassi-shared.mjs';
12
- import { ensureDaemonRunning, sendCommand, ensureDaemonReady } from './daemon-client.mjs';
12
+ import { getSocketPath, validateSession, getAppDir, getLaunchesFile, CLI_VERSION } from './dassi-shared.mjs';
13
+ import { ensureDaemonRunning, sendCommand, sendAndWait, ensureDaemonReady } from './daemon-client.mjs';
13
14
  import { dispatchLaunch } from './launch.mjs';
14
- import { parseToolCommand, requireTarget, parseStrictInt } from './tool-commands.mjs';
15
+ import { parseToolCommand, requireTarget, parseStrictInt, parseTarget, parseWait } from './tool-commands.mjs';
15
16
  import { runWithGroupExpansion } from './group-expansion.mjs';
16
17
  import { formatResponse } from './format-response.mjs';
17
18
  import { HELP_TEXT } from './help-text.mjs';
18
19
 
19
20
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
- const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')).version;
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';
26
21
 
27
22
  // ─── Arg parsing ──────────────────────────────────────────────────────────────
28
23
 
29
24
  /**
30
25
  * Parses CLI arguments into a command descriptor.
31
- * Supports browser tool commands (navigate, click, fill, type, read-page, etc.),
26
+ * Supports live tool discovery and generic browser calls,
32
27
  * agent commands (run, list-tabs, status, raw), and top-level flags (--version, --help).
33
28
  * Run `dassi --help` for the full command reference.
34
29
  * @param {string[]} argv process.argv.slice(2)
35
30
  * @returns {{ action: string; params: Record<string, unknown>; session: string; json: boolean; profile: string | null }}
36
31
  */
37
- export function parseCliArgs(argv) {
32
+ export function parseCliArgs(argv, { interactive = false } = {}) {
38
33
  const args = [...argv];
39
34
 
40
35
  // Reason: hoist the `--` split to BEFORE global flag parsing so that Chrome
@@ -50,10 +45,10 @@ export function parseCliArgs(argv) {
50
45
  // Reason: handle --version and --help before any flag parsing so they work
51
46
  // even when other flags like --session are incomplete (e.g. `dassi --version --session`)
52
47
  if (consumeFlag(args, '--version')) {
53
- return { action: 'version', params: {}, session: 'default', json: false, profile: null };
48
+ return { action: 'version', params: {}, session: 'default', json: args.includes('--json'), profile: null };
54
49
  }
55
50
  if (consumeFlag(args, '--help')) {
56
- return { action: 'help', params: {}, session: 'default', json: false, profile: null };
51
+ return { action: 'help', params: {}, session: 'default', json: args.includes('--json'), profile: null };
57
52
  }
58
53
 
59
54
  const session = validateSession(getFlag(args, '--session') ?? process.env.DASSI_SESSION ?? 'default');
@@ -62,10 +57,15 @@ export function parseCliArgs(argv) {
62
57
 
63
58
  // Reason: centralise the return shape so every command branch includes profile
64
59
  // without editing each return individually
65
- const finish = (action, params) => ({ action, params, session, json, profile });
60
+ const finish = (action, params) => {
61
+ if (args.length) throw new Error(`Unexpected argument: ${args[0]}. Run: dassi --help`);
62
+ const { profileTarget, ...rest } = params;
63
+ if (profile && profileTarget && profile !== profileTarget) throw new Error('Target includes a profile; omit --profile or use the same profile ID.');
64
+ return { action, params: rest, session, json, profile: profileTarget ?? profile };
65
+ };
66
66
 
67
- const command = args.shift();
68
- if (!command) throw new Error('No command specified. Run: dassi --help');
67
+ const command = args.shift() ?? (interactive && argv.length === 0 ? 'setup' : undefined);
68
+ if (!command) throw new Error('No command specified. Run dassi setup to connect Chrome, or dassi --help for commands.');
69
69
 
70
70
  // Reason: chromeArgs (tokens after `--`) are only meaningful for the `launch`
71
71
  // command. Any other command that supplies `--` tokens would have them silently
@@ -74,18 +74,34 @@ export function parseCliArgs(argv) {
74
74
  throw new Error("'--' is only valid with the launch command");
75
75
  }
76
76
 
77
+ if (command === 'setup') {
78
+ const waitRaw = getFlag(args, '--wait');
79
+ return finish('setup', { waitMs: parseWait(waitRaw ?? '5m'), noOpen: consumeFlag(args, '--no-open') });
80
+ }
81
+
82
+ if (command === 'skill') {
83
+ const pathOnly = consumeFlag(args, '--path');
84
+ const remove = consumeFlag(args, '--remove');
85
+ if (pathOnly && remove) throw new Error('Use either skill --path or skill --remove.');
86
+ return finish(remove ? 'remove_skill' : 'skill', remove ? {} : { pathOnly });
87
+ }
88
+
77
89
  if (command === 'list-tabs') {
78
- const all = consumeFlag(args, '--all');
79
- return finish('list_tabs', all ? { all: true } : {});
90
+ consumeFlag(args, '--all');
91
+ return finish('list_tabs', { all: true });
80
92
  }
81
93
 
82
94
  if (command === 'list-groups') {
83
- const all = consumeFlag(args, '--all');
84
- return finish('list_groups', all ? { all: true } : {});
95
+ consumeFlag(args, '--all');
96
+ return finish('list_groups', { all: true });
85
97
  }
86
98
 
87
- if (command === 'status') {
88
- return finish('status', {});
99
+ if (command === 'status' || command === 'stop') {
100
+ const waitRaw = getFlag(args, '--wait');
101
+ const taskId = args[0] && !args[0].startsWith('-') ? args.shift() : undefined;
102
+ if (!taskId && (command === 'stop' || waitRaw !== undefined)) throw new Error(`${command} requires a task ID`);
103
+ return finish(taskId ? (command === 'stop' ? 'task_stop' : 'task_status') : 'status',
104
+ taskId ? { taskId, ...(waitRaw !== undefined ? { waitMs: parseWait(waitRaw) } : {}) } : {});
89
105
  }
90
106
 
91
107
  if (command === 'list-profiles') {
@@ -140,10 +156,13 @@ export function parseCliArgs(argv) {
140
156
  if (command === 'run') {
141
157
  const prompt = args.shift();
142
158
  if (!prompt) throw new Error('run requires a prompt argument');
143
- const target = requireTarget('run', args, getFlag);
144
- const timeoutRaw = getFlag(args, '--timeout');
145
- const timeoutMs = timeoutRaw ? parseStrictInt(timeoutRaw, '--timeout') : undefined;
146
- const base = { prompt, ...(timeoutMs !== undefined ? { timeoutMs } : {}) };
159
+ const taskId = getFlag(args, '--task');
160
+ if (args.includes('--timeout')) throw new Error('run no longer accepts --timeout. Use --wait to wait locally, or stop <task-id> to cancel.');
161
+ 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.');
162
+ const target = taskId ? { taskId } : requireTarget('run', args, getFlag);
163
+ const waitRaw = getFlag(args, '--wait');
164
+ const waitMs = waitRaw !== undefined ? parseWait(waitRaw) : 0;
165
+ const base = { prompt, ...(waitMs ? { waitMs } : {}) };
147
166
  return finish('run', { ...base, ...target });
148
167
  }
149
168
 
@@ -155,13 +174,13 @@ export function parseCliArgs(argv) {
155
174
  if (command === 'panel-screenshot') {
156
175
  const tabRaw = getFlag(args, '--tab');
157
176
  if (!tabRaw) throw new Error('panel-screenshot requires --tab <tabId>');
158
- const tabId = parseStrictInt(tabRaw, '--tab');
177
+ const { id: tabId, ...target } = parseTarget(tabRaw, '--tab');
159
178
  const output = getFlag(args, '-o') ?? getFlag(args, '--output') ?? null;
160
179
  const widthRaw = getFlag(args, '--width');
161
180
  const heightRaw = getFlag(args, '--height');
162
181
  const width = widthRaw ? parseStrictInt(widthRaw, '--width') : undefined;
163
182
  const height = heightRaw ? parseStrictInt(heightRaw, '--height') : undefined;
164
- return finish('panel_screenshot', { tabId, ...(output ? { _output: output } : {}), ...(width ? { width } : {}), ...(height ? { height } : {}) });
183
+ return finish('panel_screenshot', { tabId, ...target, ...(output ? { _output: output } : {}), ...(width ? { width } : {}), ...(height ? { height } : {}) });
165
184
  }
166
185
 
167
186
  if (command === 'raw') {
@@ -173,14 +192,18 @@ export function parseCliArgs(argv) {
173
192
  return finish('raw', cmd);
174
193
  }
175
194
 
176
- // Reason: Tool commands are extracted to tool-commands.mjs to keep parseCliArgs
177
- // and dassi.mjs within CLAUDE.md size limits (40 lines / 500 lines).
178
195
  const toolParams = parseToolCommand(command, args, getFlag, consumeFlag);
179
196
  if (toolParams) {
197
+ // Reason: some tool commands resolve to dedicated bridge methods rather
198
+ // than tool_exec (open --window → open_tab).
199
+ if (toolParams.__bridgeAction) {
200
+ const { __bridgeAction, ...params } = toolParams;
201
+ return finish(__bridgeAction, params);
202
+ }
180
203
  return finish('tool_exec', toolParams);
181
204
  }
182
205
 
183
- throw new Error(`Unknown command: "${command}". Run: dassi --help`);
206
+ throw new Error(`Unknown command: "${command}". Browser commands use dassi tools and dassi call. Run: dassi --help`);
184
207
  }
185
208
 
186
209
  /**
@@ -217,56 +240,74 @@ function consumeFlag(args, flag) {
217
240
 
218
241
  // HELP_TEXT lives in ./help-text.mjs (extracted to keep this file within the size limit).
219
242
 
220
- /**
221
- * Handles --version and --help, which don't need a daemon. Returns true if handled.
222
- * @param {string} action
223
- * @returns {boolean}
224
- */
225
- function handleImmediateAction(action) {
226
- if (action === 'version') {
227
- console.log(VERSION);
228
- process.exit(0);
229
- }
230
- if (action === 'help') {
231
- console.log(HELP_TEXT);
232
- process.exit(0);
233
- }
234
- return false;
243
+ function handleImmediateAction({ action, params, json }) {
244
+ let text;
245
+ if (action === 'version') text = CLI_VERSION;
246
+ else if (action === 'help') text = HELP_TEXT;
247
+ else if (action === 'skill') {
248
+ const skillDir = path.join(__dirname, 'skills', 'dassi');
249
+ text = params.pathOnly ? skillDir : fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8');
250
+ } else return false;
251
+ console.log(json ? JSON.stringify({ success: true, data: text }) : text);
252
+ return true;
235
253
  }
236
254
 
237
255
  // ─── Entry point ──────────────────────────────────────────────────────────────
238
256
 
239
257
  /**
240
- * Main CLI entry point. Parses args, ensures daemon is running, handles
241
- * onboarding, sends command, prints response.
258
+ * Parses arguments, connects to the daemon, and prints the command result.
242
259
  * @returns {Promise<void>}
243
260
  */
244
- export async function run() {
261
+ export async function run(argv = process.argv.slice(2)) {
245
262
  let parsed;
246
263
  try {
247
- parsed = parseCliArgs(process.argv.slice(2));
264
+ parsed = parseCliArgs(argv, { interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY) });
265
+ await execute(parsed);
248
266
  } catch (err) {
249
- console.error(err.message);
250
- process.exit(1);
267
+ const args = argv.slice(0, argv.indexOf('--') === -1 ? argv.length : argv.indexOf('--'));
268
+ const json = parsed?.json ?? args.includes('--json');
269
+ const response = { success: false, error: err instanceof Error ? err.message : String(err), ...(err?.outcome === 'unknown' ? { outcome: 'unknown' } : {}) };
270
+ if (json) console.log(formatResponse('error', response, true));
271
+ else console.error(formatResponse('error', response, false));
272
+ process.exitCode = 1;
251
273
  }
274
+ }
252
275
 
276
+ async function execute(parsed) {
253
277
  const { action, params, session, json, profile } = parsed;
254
- handleImmediateAction(action);
278
+ if (handleImmediateAction(parsed)) return;
279
+ if (action === 'remove_skill') {
280
+ const { unregisterSkills } = await import('./setup.mjs');
281
+ const skills = await unregisterSkills();
282
+ console.log(json ? JSON.stringify({ success: true, data: { skills } }) : [
283
+ ...skills.map(skill => `${skill.status}: ${skill.path}`),
284
+ 'To remove the CLI, run: npm uninstall -g @dassi_ai/cli',
285
+ ].join('\n'));
286
+ return;
287
+ }
288
+ if (action === 'setup') {
289
+ const { setup, formatSetup } = await import('./setup.mjs');
290
+ const data = await setup({ ...params, profile, session, interactive: !json && Boolean(process.stdin.isTTY) });
291
+ const response = { success: data.ready, data, ...(!data.ready ? { error: data.next } : {}) };
292
+ console.log(json ? JSON.stringify(response) : formatSetup(data));
293
+ if (!data.ready) process.exitCode = 1;
294
+ return;
295
+ }
255
296
 
256
297
  // Launch family (launch / --stop / __launch-hold) → launch.mjs.
257
298
  if (await dispatchLaunch(action, params, { spawn: child_process.spawn, ensureDaemonRunning, getSocketPath, sendCommand, appDir: getAppDir(), launchesFile: getLaunchesFile() })) return;
258
299
 
259
300
  const socketPath = await ensureDaemonReady(session);
260
301
 
261
- const id = `cli_${Date.now()}`;
302
+ const id = `cli_${randomUUID()}`;
262
303
 
263
304
  // Group expansion: if action=run or action=tool_exec with groupId/groupTitle, expand and fan out sequentially
264
305
  if (
265
306
  (action === 'run' || action === 'tool_exec') &&
266
307
  (params.groupId !== undefined || params.groupTitle !== undefined)
267
308
  ) {
268
- const allOk = await runWithGroupExpansion(socketPath, action, params, json, sendCommand, formatResponse, profile);
269
- if (!allOk) process.exit(1);
309
+ const allOk = await runWithGroupExpansion(socketPath, action, params, json, sendAndWait, formatResponse, profile);
310
+ if (!allOk) process.exitCode = 1;
270
311
  return;
271
312
  }
272
313
 
@@ -275,10 +316,10 @@ export async function run() {
275
316
  const command = action === 'raw'
276
317
  ? { id, ...params }
277
318
  : { id, action, ...params, ...(profile ? { target: profile } : {}) };
278
- const response = await sendCommand(socketPath, command);
319
+ const response = await sendAndWait(socketPath, command);
279
320
 
280
321
  console.log(formatResponse(action, response, json, params));
281
- if (!response.success) process.exit(1);
322
+ if (!response.success || (command.action !== 'task_stop' && ['failed', 'stopped'].includes(response.data?.status))) process.exitCode = 1;
282
323
  }
283
324
 
284
325
  // ── Entry point guard ─────────────────────────────────────────────────────────
@@ -309,8 +350,5 @@ export function isMainModule(metaUrl, argvPath) {
309
350
  }
310
351
 
311
352
  if (isMainModule(import.meta.url, process.argv[1])) {
312
- run().catch((err) => {
313
- console.error(`❌ ${err.message}`);
314
- process.exit(1);
315
- });
353
+ await run();
316
354
  }
@@ -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
  }