@dassi_ai/cli 0.1.0 → 0.2.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
@@ -1,4 +1,4 @@
1
- # dassi-cli
1
+ # @dassi_ai/cli
2
2
 
3
3
  Standalone CLI for the [Dassi](../extension/README.md) Chrome extension — run browser automation from the terminal.
4
4
 
@@ -6,10 +6,10 @@ Standalone CLI for the [Dassi](../extension/README.md) Chrome extension — run
6
6
 
7
7
  ```bash
8
8
  # Zero-install
9
- npx dassi-cli --help
9
+ npx @dassi_ai/cli --help
10
10
 
11
11
  # Or install globally
12
- npm install -g dassi-cli
12
+ npm install -g @dassi_ai/cli
13
13
  ```
14
14
 
15
15
  Or, for local development from a clone of this repo:
@@ -47,6 +47,13 @@ dassi list-tabs --json
47
47
  # Use a named session
48
48
  dassi list-tabs --session work
49
49
 
50
+ # List connected profiles (multiple Chrome profiles can connect at once)
51
+ dassi list-profiles
52
+
53
+ # Target a specific profile by label when more than one is connected
54
+ dassi list-tabs --profile dev
55
+ dassi run "summarize" --tab 456 --profile dev # alias: --label
56
+
50
57
  # Show version / help
51
58
  dassi --version
52
59
  dassi --help
@@ -72,7 +79,7 @@ When `--group`/`--group-title` is used, the CLI expands to member tab ids and ru
72
79
 
73
80
  ## Claude Code Plugin
74
81
 
75
- This package also ships as a Claude Code plugin under the `dassi` namespace. After installation (via either `npm install -g dassi-cli` or `npm link` from this directory), Claude Code auto-discovers two skills:
82
+ This package also ships as a Claude Code plugin under the `dassi` namespace. After installation (via either `npm install -g @dassi_ai/cli` or `npm link` from this directory), Claude Code auto-discovers two skills:
76
83
 
77
84
  - **`dassi:pick-tabs`** — a reusable tab/group picker. Lists open tabs and Chrome tab groups, asks the user to pick, returns the selected Chrome tab IDs.
78
85
  - **`dassi:operate`** — main entry point. Translates natural-language browser asks ("summarize my Research group", "screenshot the active tab", etc.) into `dassi` CLI invocations.
package/dassi-daemon.mjs CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  cleanupDaemonFiles,
25
25
  buildReadyPayload,
26
26
  createCommandQueue,
27
+ createConnectionRegistry,
27
28
  } from './dassi-shared.mjs';
28
29
 
29
30
  // ─── Constants ────────────────────────────────────────────────────────────────
@@ -31,12 +32,9 @@ import {
31
32
  const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
32
33
  const IDLE_CHECK_INTERVAL_MS = 5 * 60 * 1000;
33
34
 
34
- // ─── Module-level extension connection state ──────────────────────────────────
35
-
36
- /** Active WebSocket connection to the registered Dassi extension. */
37
- let extensionSocket = null;
38
- /** Pending response callbacks keyed by request ID. */
39
- const pendingExtResponses = new Map();
35
+ // Reason: a registry of connected extensions keyed by profile label replaces
36
+ // the old single-socket model so multiple Chrome profiles can connect at once.
37
+ const registry = createConnectionRegistry();
40
38
 
41
39
  // ─── WebSocket loader helper ──────────────────────────────────────────────────
42
40
 
@@ -62,17 +60,16 @@ async function loadWebSocket() {
62
60
  // ─── Extension response routing ──────────────────────────────────────────────
63
61
 
64
62
  /**
65
- * Sets up the persistent message handler on the extension WebSocket that routes
66
- * responses back to callers waiting in pendingExtResponses.
67
- * @param {import('ws').WebSocket} ws
63
+ * Route responses from one extension connection back to its pending callers.
64
+ * @param {{ ws: any; pending: Map<string, any> }} entry
68
65
  */
69
- function setupResponseRouter(ws) {
70
- ws.on('message', (responseData) => {
66
+ function setupResponseRouter(entry) {
67
+ entry.ws.on('message', (responseData) => {
71
68
  let resp;
72
69
  try { resp = JSON.parse(responseData.toString()); } catch { return; }
73
- const pending = pendingExtResponses.get(String(resp.id));
70
+ const pending = entry.pending.get(String(resp.id));
74
71
  if (!pending) return;
75
- pendingExtResponses.delete(String(resp.id));
72
+ entry.pending.delete(String(resp.id));
76
73
  if (resp.error) {
77
74
  pending.reject(new Error(resp.error.message ?? String(resp.error)));
78
75
  } else {
@@ -82,38 +79,37 @@ function setupResponseRouter(ws) {
82
79
  }
83
80
 
84
81
  /**
85
- * Reject all pending extension responses with a disconnection error.
86
- * Called when the extension WebSocket closes so callers don't hang forever.
82
+ * Reject all pending responses for one connection (called when it closes).
83
+ * @param {{ pending: Map<string, any> }} entry
87
84
  */
88
- function drainPendingResponses() {
89
- for (const [id, pending] of pendingExtResponses) {
85
+ function drainPendingResponses(entry) {
86
+ for (const [id, pending] of entry.pending) {
90
87
  pending.reject(new Error('Extension disconnected'));
91
- pendingExtResponses.delete(id);
88
+ entry.pending.delete(id);
92
89
  }
93
90
  }
94
91
 
95
92
  // ─── Extension server ─────────────────────────────────────────────────────────
96
93
 
97
94
  /**
98
- * Accepts a newly registered extension WebSocket: replaces any existing socket,
99
- * drains pending responses from the old connection, and wires up the new one.
100
- * @param {import('ws').WebSocket} ws - The new WebSocket connection.
95
+ * Register a newly connected extension socket into the registry and wire up
96
+ * its response router + close handler.
97
+ * @param {any} ws
98
+ * @param {{ label: string | null; installId: string }} identity
99
+ * @param {number} port - The port this daemon session listens on.
100
+ * @returns {object} The registry entry.
101
101
  */
102
- function acceptExtensionSocket(ws) {
103
- // Reason: if an old socket exists, close it cleanly before replacing
104
- if (extensionSocket && extensionSocket !== ws) {
105
- try { extensionSocket.close(); } catch { /* already closed */ }
106
- }
107
- drainPendingResponses();
108
- extensionSocket = ws;
102
+ function acceptExtensionSocket(ws, identity, port) {
103
+ const key = registry.add({ ws, label: identity.label, installId: identity.installId, port });
104
+ const entry = registry.getByWs(ws);
109
105
  ws.on('close', () => {
110
- if (extensionSocket === ws) {
111
- extensionSocket = null;
112
- drainPendingResponses();
113
- console.log('[dassi-daemon] Extension disconnected, waiting for reconnect…');
114
- }
106
+ const removed = registry.remove(ws);
107
+ drainPendingResponses(entry);
108
+ if (removed) console.log(`[dassi-daemon] Extension "${removed.label}" disconnected`);
115
109
  });
116
- setupResponseRouter(ws);
110
+ setupResponseRouter(entry);
111
+ console.log(`[dassi-daemon] Extension registered as "${key}"`);
112
+ return entry;
117
113
  }
118
114
 
119
115
  /**
@@ -123,39 +119,37 @@ function acceptExtensionSocket(ws) {
123
119
  * @param {import('ws').WebSocket} ws - The connected WebSocket.
124
120
  * @param {Buffer} data - Raw message data.
125
121
  * @param {{ resolved: boolean; timeout: ReturnType<typeof setTimeout> }} ctx - Shared state.
126
- * @param {import('ws').WebSocketServer} wss - The server (closed on fatal error).
127
122
  * @param {(value: unknown) => void} resolve - Promise resolve callback.
128
123
  * @param {(reason: Error) => void} reject - Promise reject callback.
124
+ * @param {number} port - The port the WS server is bound to.
129
125
  */
130
- async function handleRegistration(ws, data, ctx, wss, resolve, reject) {
126
+ async function handleRegistration(ws, data, ctx, resolve, reject, port) {
131
127
  let msg;
132
128
  try { msg = JSON.parse(data.toString()); } catch { return; }
133
129
  if (msg.type !== 'register' || msg.client !== 'dassi-extension') return;
134
130
 
135
- // Reason: only accept a new registration when the current socket is dead.
136
- // This prevents a rogue local process from hijacking the extension socket
137
- // while a healthy connection exists.
138
- if (extensionSocket && extensionSocket.readyState === 1 /* OPEN */) {
139
- console.log('[dassi-daemon] Rejected registration — existing socket still healthy');
140
- ws.close();
141
- return;
142
- }
143
-
144
- acceptExtensionSocket(ws);
131
+ const entry = acceptExtensionSocket(
132
+ ws,
133
+ { label: typeof msg.label === 'string' ? msg.label : null, installId: String(msg.installId ?? '') },
134
+ port,
135
+ );
145
136
 
146
137
  if (!ctx.resolved) {
147
138
  clearTimeout(ctx.timeout);
148
139
  try {
149
- const status = await dispatchToExtension({ id: 'init', action: 'status' });
140
+ // Reason: route the startup status check to the connection that just registered.
141
+ const status = await dispatchToExtension({ id: 'init', action: 'status', target: entry.label });
150
142
  ctx.resolved = true;
151
143
  resolve(status);
152
144
  } catch (err) {
153
- // Reason: don't close WSS extension may reconnect and retry
145
+ // Reason: leave ctx.resolved false so a reconnecting extension can retry
146
+ // the startup status check rather than failing the daemon permanently.
154
147
  console.error('[dassi-daemon] Initial status check failed:', err.message);
155
148
  reject(err);
156
149
  }
157
150
  } else {
158
- console.log('[dassi-daemon] Extension reconnected');
151
+ // Reason: daemon already started — this is a reconnect or an additional profile.
152
+ console.log('[dassi-daemon] Extension registered (daemon already started)');
159
153
  }
160
154
  }
161
155
 
@@ -199,7 +193,7 @@ async function startExtensionServer() {
199
193
  });
200
194
 
201
195
  wss.on('connection', (ws) => {
202
- ws.once('message', (data) => handleRegistration(ws, data, ctx, wss, resolve, reject));
196
+ ws.once('message', (data) => handleRegistration(ws, data, ctx, resolve, reject, BRIDGE_PORT));
203
197
  });
204
198
  });
205
199
  }
@@ -210,34 +204,82 @@ async function startExtensionServer() {
210
204
  * Dispatches a single command to the Dassi extension via the persistent WS
211
205
  * connection, translating from the daemon's internal format to the extension's
212
206
  * JSON-RPC-style protocol.
213
- * @param {Record<string, unknown>} cmd - Command object with {id, action, ...params}
207
+ * @param {Record<string, unknown>} cmd - Command object with {id, action, target?, ...params}
214
208
  * @returns {Promise<unknown>} The extension's result value (not wrapped in success/data).
215
209
  */
216
210
  async function dispatchToExtension(cmd) {
217
- if (!extensionSocket || extensionSocket.readyState !== 1 /* OPEN */) {
211
+ const { id, action, target = null, ...params } = cmd;
212
+ const entry = registry.resolve(target); // throws with a helpful message if ambiguous/unknown/none
213
+ if (!entry.ws || entry.ws.readyState !== 1 /* OPEN */) {
218
214
  throw new Error('Extension not connected');
219
215
  }
220
216
 
221
- const { id, action, ...params } = cmd;
222
- // Reason: the extension expects JSON-RPC style {id, method, params}, not {id, action, ...}
217
+ // Reason: the extension expects JSON-RPC {id, method, params}; `target` is a
218
+ // daemon-only routing field and must not leak into the extension params.
223
219
  const message = { id, method: action ?? String(id), params };
224
- extensionSocket.send(JSON.stringify(message));
220
+ entry.ws.send(JSON.stringify(message));
225
221
 
226
222
  const timeoutMs = typeof params.timeoutMs === 'number' ? params.timeoutMs + 10_000 : 65_000;
227
223
 
228
224
  return new Promise((resolve, reject) => {
229
225
  const timer = setTimeout(() => {
230
- pendingExtResponses.delete(String(id));
226
+ entry.pending.delete(String(id));
231
227
  reject(new Error(`Extension timeout after ${timeoutMs}ms`));
232
228
  }, timeoutMs);
233
229
 
234
- pendingExtResponses.set(String(id), {
230
+ entry.pending.set(String(id), {
235
231
  resolve: (result) => { clearTimeout(timer); resolve(result); },
236
232
  reject: (err) => { clearTimeout(timer); reject(err); },
237
233
  });
238
234
  });
239
235
  }
240
236
 
237
+ /**
238
+ * Handle commands the daemon answers itself (no extension round-trip).
239
+ * Returns a response object, or null if the command should be dispatched to
240
+ * an extension instead.
241
+ * @param {Record<string, unknown>} cmd
242
+ * @param {ReturnType<typeof createConnectionRegistry>} reg
243
+ * @returns {object | null}
244
+ */
245
+ export function handleDaemonLocalCommand(cmd, reg) {
246
+ if (cmd?.action === 'list_profiles') {
247
+ return { id: String(cmd.id ?? 'unknown'), success: true, data: reg.list() };
248
+ }
249
+ return null;
250
+ }
251
+
252
+ /**
253
+ * Refresh the ready file when the latest dispatched command was a `status` query.
254
+ *
255
+ * Reason: The daemon previously wrote the ready file exactly once on startup.
256
+ * When the user wasn't signed in at that moment, the file stayed `needs_login`
257
+ * even after waitForLogin's polling confirmed authentication via socket — so
258
+ * every subsequent CLI invocation re-read the stale file and re-triggered the
259
+ * login flow. Refreshing on every `status` response — which waitForLogin's
260
+ * poll loop drives naturally — converges the file to the truth without needing
261
+ * a separate signal channel.
262
+ *
263
+ * @param {string} readyFile - Absolute path to the daemon's ready file.
264
+ * @param {{ action?: string }} cmd - The command that was dispatched.
265
+ * @param {unknown} result - The dispatch result (extension's status payload).
266
+ * @returns {void}
267
+ */
268
+ export function refreshReadyFileFromStatusResult(readyFile, cmd, result) {
269
+ if (cmd?.action !== 'status') return;
270
+ if (!result || typeof result !== 'object') return;
271
+ try {
272
+ fs.writeFileSync(readyFile, JSON.stringify(buildReadyPayload(result)), { mode: 0o600 });
273
+ // Reason: writeFileSync's `mode` is ignored when the file already exists.
274
+ // chmod explicitly so installs with a pre-hardening 0o644 ready file get
275
+ // tightened. The file contains the signed-in email — owner-only.
276
+ try { fs.chmodSync(readyFile, 0o600); } catch { /* best-effort */ }
277
+ } catch {
278
+ // Reason: best-effort sync; don't fail the command if the FS write itself
279
+ // races (e.g. file permission flake). The next status query will retry.
280
+ }
281
+ }
282
+
241
283
  // ─── Daemon setup helpers ─────────────────────────────────────────────────────
242
284
 
243
285
  /**
@@ -251,8 +293,17 @@ function initDaemonProcess(session) {
251
293
  const readyFile = getReadyFile(session);
252
294
 
253
295
  fs.mkdirSync(appDir, { recursive: true, mode: 0o700 });
296
+ // Reason: mkdirSync's `mode` is ignored when the directory already exists.
297
+ // Normalize perms on every start so installs created before the mode arg
298
+ // was added (or with a permissive umask) get tightened to owner-only.
299
+ // The socket and ready/pid files live here; loose perms would let other
300
+ // local users connect to the socket and dispatch CLI commands.
301
+ try { fs.chmodSync(appDir, 0o700); } catch { /* best-effort */ }
254
302
  cleanupDaemonFiles(session);
255
- fs.writeFileSync(getPidFile(session), String(process.pid));
303
+ const pidFile = getPidFile(session);
304
+ fs.writeFileSync(pidFile, String(process.pid), { mode: 0o600 });
305
+ // Reason: see refreshReadyFileFromStatusResult — chmod normalizes pre-existing files.
306
+ try { fs.chmodSync(pidFile, 0o600); } catch { /* best-effort */ }
256
307
 
257
308
  const shutdown = () => {
258
309
  cleanupDaemonFiles(session);
@@ -301,7 +352,13 @@ function createSocketServer(queue, socketPath) {
301
352
  socket.on('error', () => { /* ignore client disconnects */ });
302
353
  });
303
354
 
304
- server.listen(socketPath);
355
+ // Reason: tighten the Unix socket to owner-only so other local users on a
356
+ // multi-user system can't connect and dispatch CLI commands. chmod has to
357
+ // happen after the socket file is actually created — listen()'s callback
358
+ // fires on the 'listening' event, by which point the inode exists.
359
+ server.listen(socketPath, () => {
360
+ try { fs.chmodSync(socketPath, 0o600); } catch { /* best-effort */ }
361
+ });
305
362
  return server;
306
363
  }
307
364
 
@@ -341,15 +398,21 @@ export async function startDaemon() {
341
398
  } catch {
342
399
  // Reason: write the ready file even on failure so the CLI can read the error state,
343
400
  // then exit so the WSS doesn't keep the process alive as a zombie.
344
- fs.writeFileSync(readyFile, JSON.stringify({ status: 'extension_not_installed' }));
401
+ fs.writeFileSync(readyFile, JSON.stringify({ status: 'extension_not_installed' }), { mode: 0o600 });
402
+ try { fs.chmodSync(readyFile, 0o600); } catch { /* best-effort */ }
345
403
  process.exit(1);
346
404
  }
347
405
 
348
406
  let lastCommandAt = Date.now();
349
407
  const queue = createCommandQueue(async (cmd) => {
350
408
  lastCommandAt = Date.now();
409
+ // Reason: daemon-local commands (e.g. list_profiles) are answered from the
410
+ // registry and intentionally skip extension dispatch + ready-file refresh.
411
+ const local = handleDaemonLocalCommand(cmd, registry);
412
+ if (local) return local;
351
413
  try {
352
414
  const result = await dispatchToExtension(cmd);
415
+ refreshReadyFileFromStatusResult(readyFile, cmd, result);
353
416
  return { id: String(cmd.id ?? 'unknown'), success: true, data: result };
354
417
  } catch (err) {
355
418
  return { id: String(cmd.id ?? 'unknown'), success: false, error: err.message };
@@ -359,7 +422,8 @@ export async function startDaemon() {
359
422
  // Reason: start the socket server before writing the ready file so the CLI
360
423
  // can connect immediately (e.g. for needs_login polling).
361
424
  const server = createSocketServer(queue, socketPath);
362
- fs.writeFileSync(readyFile, JSON.stringify(buildReadyPayload(statusData)));
425
+ fs.writeFileSync(readyFile, JSON.stringify(buildReadyPayload(statusData)), { mode: 0o600 });
426
+ try { fs.chmodSync(readyFile, 0o600); } catch { /* best-effort */ }
363
427
 
364
428
  startIdleShutdown(server, session, () => lastCommandAt);
365
429
  }
package/dassi-shared.mjs CHANGED
@@ -180,3 +180,123 @@ export function createCommandQueue(handler) {
180
180
  },
181
181
  };
182
182
  }
183
+
184
+ // ─── Connection registry ──────────────────────────────────────────────────────
185
+
186
+ /**
187
+ * Creates a registry of connected extension sockets, keyed by a unique label.
188
+ * Replaces the daemon's old single-socket model so multiple Chrome profiles can
189
+ * connect at once and commands can be routed to a chosen one by label.
190
+ *
191
+ * @returns {{
192
+ * add: (conn: { ws: any; label: string | null; installId: string; port: number }) => string;
193
+ * remove: (ws: any) => ({ label: string; installId: string } | null);
194
+ * getByWs: (ws: any) => (object | null);
195
+ * resolve: (target: string | null) => object;
196
+ * list: () => Array<{ label: string; port: number }>;
197
+ * size: () => number;
198
+ * }}
199
+ */
200
+ export function createConnectionRegistry() {
201
+ /** @type {Map<string, { ws: any; label: string; installId: string; port: number; pending: Map<string, any> }>} */
202
+ const conns = new Map();
203
+
204
+ // Reason: label is the routing key; if two profiles share a label, suffix the
205
+ // installId so a connection never silently fails to register (suffix > reject).
206
+ function uniqueKey(label) {
207
+ if (!conns.has(label)) return label;
208
+ let i = 2;
209
+ while (conns.has(`${label}#${i}`)) i++;
210
+ return `${label}#${i}`;
211
+ }
212
+
213
+ function labels() {
214
+ return [...conns.values()].map((c) => c.label);
215
+ }
216
+
217
+ return {
218
+ /**
219
+ * Register a new extension connection. Returns the unique key (label) assigned.
220
+ * Duplicate labels are disambiguated by appending a numeric suffix.
221
+ * @param {{ ws: any; label: string | null; installId: string; port: number }} conn
222
+ * @returns {string}
223
+ */
224
+ add({ ws, label, installId, port }) {
225
+ // Reason: on reconnect (port/label change) the new TCP connection can register
226
+ // before the daemon observes the old socket's close. Evict any stale entry for
227
+ // the same install first, so we don't end up with phantom duplicates (dev +
228
+ // dev#2) that make resolve(null) wrongly report "multiple profiles".
229
+ if (installId) {
230
+ for (const [k, c] of conns) {
231
+ if (c.installId === installId) { conns.delete(k); break; }
232
+ }
233
+ }
234
+ // Reason: identity fallback chain — label, then installId, then port — so a
235
+ // connection is always addressable even if a legacy build reports neither.
236
+ const base =
237
+ (label && String(label).trim()) || (installId && String(installId).trim()) || String(port);
238
+ const key = uniqueKey(base);
239
+ conns.set(key, { ws, label: key, installId, port, pending: new Map() });
240
+ return key;
241
+ },
242
+
243
+ /**
244
+ * Remove the connection associated with the given WebSocket.
245
+ * @param {any} ws
246
+ * @returns {{ label: string; installId: string } | null}
247
+ */
248
+ remove(ws) {
249
+ for (const [key, c] of conns) {
250
+ if (c.ws === ws) {
251
+ conns.delete(key);
252
+ return { label: c.label, installId: c.installId };
253
+ }
254
+ }
255
+ return null;
256
+ },
257
+
258
+ /**
259
+ * Look up the registry entry for a given WebSocket (includes its pending map).
260
+ * @param {any} ws
261
+ * @returns {object | null}
262
+ */
263
+ getByWs(ws) {
264
+ for (const c of conns.values()) if (c.ws === ws) return c;
265
+ return null;
266
+ },
267
+
268
+ /**
269
+ * Resolve a target label to its registry entry.
270
+ * Passing null auto-resolves when exactly one connection is registered.
271
+ * Throws descriptive errors for no connections, ambiguity, or unknown label.
272
+ * @param {string | null} target
273
+ * @returns {object}
274
+ */
275
+ resolve(target) {
276
+ if (conns.size === 0) throw new Error('No extension connected');
277
+ if (target == null) {
278
+ if (conns.size === 1) return [...conns.values()][0];
279
+ throw new Error(`Multiple profiles connected (${labels().join(', ')}). Specify --profile <label>.`);
280
+ }
281
+ const entry = conns.get(String(target));
282
+ if (!entry) throw new Error(`Unknown profile "${target}". Connected: ${labels().join(', ')}`);
283
+ return entry;
284
+ },
285
+
286
+ /**
287
+ * Returns a shallow list of all connected profiles (label + port only).
288
+ * @returns {Array<{ label: string; port: number }>}
289
+ */
290
+ list() {
291
+ return [...conns.values()].map((c) => ({ label: c.label, port: c.port }));
292
+ },
293
+
294
+ /**
295
+ * Returns the number of currently connected profiles.
296
+ * @returns {number}
297
+ */
298
+ size() {
299
+ return conns.size;
300
+ },
301
+ };
302
+ }
package/dassi.mjs CHANGED
@@ -37,7 +37,7 @@ const CHROME_WEB_STORE_URL = 'https://chromewebstore.google.com/detail/dassi-ai-
37
37
  * agent commands (run, list-tabs, status, raw), and top-level flags (--version, --help).
38
38
  * Run `dassi --help` for the full command reference.
39
39
  * @param {string[]} argv process.argv.slice(2)
40
- * @returns {{ action: string; params: Record<string, unknown>; session: string; json: boolean }}
40
+ * @returns {{ action: string; params: Record<string, unknown>; session: string; json: boolean; profile: string | null }}
41
41
  */
42
42
  export function parseCliArgs(argv) {
43
43
  const args = [...argv];
@@ -45,28 +45,39 @@ export function parseCliArgs(argv) {
45
45
  // Reason: handle --version and --help before any flag parsing so they work
46
46
  // even when other flags like --session are incomplete (e.g. `dassi --version --session`)
47
47
  if (consumeFlag(args, '--version')) {
48
- return { action: 'version', params: {}, session: 'default', json: false };
48
+ return { action: 'version', params: {}, session: 'default', json: false, profile: null };
49
49
  }
50
50
  if (consumeFlag(args, '--help')) {
51
- return { action: 'help', params: {}, session: 'default', json: false };
51
+ return { action: 'help', params: {}, session: 'default', json: false, profile: null };
52
52
  }
53
53
 
54
54
  const session = validateSession(getFlag(args, '--session') ?? process.env.DASSI_SESSION ?? 'default');
55
55
  const json = consumeFlag(args, '--json');
56
+ const profile = getFlag(args, '--profile') ?? getFlag(args, '--label') ?? null;
57
+
58
+ // Reason: centralise the return shape so every command branch includes profile
59
+ // without editing each return individually
60
+ const finish = (action, params) => ({ action, params, session, json, profile });
56
61
 
57
62
  const command = args.shift();
58
63
  if (!command) throw new Error('No command specified. Run: dassi --help');
59
64
 
60
65
  if (command === 'list-tabs') {
61
- return { action: 'list_tabs', params: {}, session, json };
66
+ const all = consumeFlag(args, '--all');
67
+ return finish('list_tabs', all ? { all: true } : {});
62
68
  }
63
69
 
64
70
  if (command === 'list-groups') {
65
- return { action: 'list_groups', params: {}, session, json };
71
+ const all = consumeFlag(args, '--all');
72
+ return finish('list_groups', all ? { all: true } : {});
66
73
  }
67
74
 
68
75
  if (command === 'status') {
69
- return { action: 'status', params: {}, session, json };
76
+ return finish('status', {});
77
+ }
78
+
79
+ if (command === 'list-profiles') {
80
+ return finish('list_profiles', {});
70
81
  }
71
82
 
72
83
  if (command === 'run') {
@@ -76,12 +87,12 @@ export function parseCliArgs(argv) {
76
87
  const timeoutRaw = getFlag(args, '--timeout');
77
88
  const timeoutMs = timeoutRaw ? parseStrictInt(timeoutRaw, '--timeout') : undefined;
78
89
  const base = { prompt, ...(timeoutMs !== undefined ? { timeoutMs } : {}) };
79
- return { action: 'run', params: { ...base, ...target }, session, json };
90
+ return finish('run', { ...base, ...target });
80
91
  }
81
92
 
82
93
  if (command === 'bug-report') {
83
94
  const output = getFlag(args, '-o') ?? getFlag(args, '--output');
84
- return { action: 'export_logs', params: { output }, session, json };
95
+ return finish('export_logs', { output });
85
96
  }
86
97
 
87
98
  if (command === 'panel-screenshot') {
@@ -93,7 +104,7 @@ export function parseCliArgs(argv) {
93
104
  const heightRaw = getFlag(args, '--height');
94
105
  const width = widthRaw ? parseStrictInt(widthRaw, '--width') : undefined;
95
106
  const height = heightRaw ? parseStrictInt(heightRaw, '--height') : undefined;
96
- return { action: 'panel_screenshot', params: { tabId, ...(output ? { _output: output } : {}), ...(width ? { width } : {}), ...(height ? { height } : {}) }, session, json };
107
+ return finish('panel_screenshot', { tabId, ...(output ? { _output: output } : {}), ...(width ? { width } : {}), ...(height ? { height } : {}) });
97
108
  }
98
109
 
99
110
  if (command === 'raw') {
@@ -102,14 +113,14 @@ export function parseCliArgs(argv) {
102
113
  const cmd = JSON.parse(rawArg);
103
114
  // Reason: store action='raw' as a sentinel so run() can send the full cmd envelope
104
115
  // verbatim, letting the user control every field (including action) without reconstruction
105
- return { action: 'raw', params: cmd, session, json };
116
+ return finish('raw', cmd);
106
117
  }
107
118
 
108
119
  // Reason: Tool commands are extracted to tool-commands.mjs to keep parseCliArgs
109
120
  // and dassi.mjs within CLAUDE.md size limits (40 lines / 500 lines).
110
121
  const toolParams = parseToolCommand(command, args, getFlag, consumeFlag);
111
122
  if (toolParams) {
112
- return { action: 'tool_exec', params: toolParams, session, json };
123
+ return finish('tool_exec', toolParams);
113
124
  }
114
125
 
115
126
  throw new Error(`Unknown command: "${command}". Run: dassi --help`);
@@ -245,22 +256,46 @@ export function sendCommand(socketPath, command) {
245
256
  // ─── Login helpers ────────────────────────────────────────────────────────────
246
257
 
247
258
  /**
248
- * Handles the `needs_login` onboarding flow: opens the options page in the
249
- * default browser, then polls the daemon socket until the user authenticates
250
- * or the timeout elapses.
251
- * @param {string} socketPath Unix socket path for the daemon
252
- * @param {string | undefined} optionsUrl URL of the Dassi options page to open
253
- * @returns {Promise<void>} Resolves on successful login; throws on timeout
259
+ * Defense-in-depth check for `optionsUrl` before handing it to the `open`
260
+ * package. Legit URLs always come from `chrome.runtime.getURL('options.html')`
261
+ * `chrome-extension://<id>/options.html`. Anything else is unexpected and
262
+ * we should not auto-launch it (the `open` package shells out to the OS URL
263
+ * handler).
264
+ * @param {unknown} optionsUrl
265
+ * @returns {boolean}
266
+ */
267
+ export function isValidOptionsUrl(optionsUrl) {
268
+ return typeof optionsUrl === 'string' && /^chrome-extension:\/\/[a-z]{32}\//.test(optionsUrl);
269
+ }
270
+
271
+ /**
272
+ * Polls the daemon's status until the user signs in (extension reports
273
+ * authenticated=true) or the LOGIN_TIMEOUT_MS deadline elapses. On entry,
274
+ * best-effort auto-opens the extension's options page (subject to the
275
+ * isValidOptionsUrl guard above).
276
+ * @param {string} socketPath - Path to the daemon Unix socket.
277
+ * @param {string | undefined} optionsUrl - URL of the Dassi options page.
278
+ * @returns {Promise<void>} Resolves on successful login; throws on timeout.
254
279
  */
255
280
  export async function waitForLogin(socketPath, optionsUrl) {
256
281
  console.error(`⚠️ Dassi is installed but you're not signed in.\n Opening the Dassi settings page...\n`);
257
282
 
258
- // Auto-open the options page — best-effort (package may not be installed)
259
- try {
260
- const { default: open } = await import('open');
261
- if (optionsUrl) await open(String(optionsUrl));
262
- } catch {
263
- console.error(` Please open: ${optionsUrl}`);
283
+ // Auto-open the options page — best-effort (package may not be installed).
284
+ // Reason: only open URLs the extension would legitimately produce
285
+ // (chrome.runtime.getURL `chrome-extension://<id>/options.html`).
286
+ // Defense-in-depth: even though optionsUrl is sourced from the daemon's
287
+ // ready file (which lives in our owner-only ~/.dassi/ dir), validating the
288
+ // protocol prevents `open` from launching arbitrary URIs/shell-handlers if
289
+ // the file is ever tampered with.
290
+ if (isValidOptionsUrl(optionsUrl)) {
291
+ try {
292
+ const { default: open } = await import('open');
293
+ await open(String(optionsUrl));
294
+ } catch {
295
+ console.error(` Please open: ${optionsUrl}`);
296
+ }
297
+ } else if (optionsUrl) {
298
+ console.error(` Refusing to auto-open unexpected URL: ${optionsUrl}\n Please open the Dassi settings page manually.`);
264
299
  }
265
300
 
266
301
  // Poll the daemon socket every LOGIN_POLL_MS until authenticated
@@ -303,15 +338,18 @@ const HELP_TEXT =
303
338
  'Agent commands:\n' +
304
339
  ' run <prompt> --tab <id> | --group <id> | --group-title <name>\n' +
305
340
  ' Run AI agent on a tab or group (group = sequential)\n' +
306
- ' list-tabs List all open Chrome tabs\n' +
307
- ' list-groups List Chrome tab groups (with tab counts)\n' +
341
+ ' list-tabs [--all] List tabs in groups dassi has open (--all = every Chrome tab)\n' +
342
+ ' list-groups [--all] List dassi-open tab groups (--all = every Chrome group)\n' +
343
+ ' list-profiles List connected profiles (Chrome instances)\n' +
308
344
  ' status Check extension status\n' +
309
345
  ' bug-report [-o file] Export debug logs from all contexts\n' +
310
346
  ' raw <json> Send raw JSON command\n\n' +
311
347
  'Options:\n' +
312
348
  ' --tab <id> Chrome tab ID (use list-tabs to find)\n' +
349
+ ' --all list-tabs/list-groups: include every Chrome tab/group\n' +
313
350
  ' --timeout <ms> Timeout for run command (default: 300000)\n' +
314
351
  ' --session <name> Daemon session name (default: "default")\n' +
352
+ ' --profile <label> Target a specific connected profile (alias: --label)\n' +
315
353
  ' --json Output raw JSON\n' +
316
354
  ' --filter <type> Filter for read-page (interactive|all)\n' +
317
355
  ' --depth <n> Depth for read-page tree\n' +
@@ -390,7 +428,7 @@ export async function run() {
390
428
  process.exit(1);
391
429
  }
392
430
 
393
- const { action, params, session, json } = parsed;
431
+ const { action, params, session, json, profile } = parsed;
394
432
  handleImmediateAction(action);
395
433
 
396
434
  const socketPath = await ensureDaemonReady(session);
@@ -402,13 +440,16 @@ export async function run() {
402
440
  (action === 'run' || action === 'tool_exec') &&
403
441
  (params.groupId !== undefined || params.groupTitle !== undefined)
404
442
  ) {
405
- const allOk = await runWithGroupExpansion(socketPath, action, params, json, sendCommand, formatResponse);
443
+ const allOk = await runWithGroupExpansion(socketPath, action, params, json, sendCommand, formatResponse, profile);
406
444
  if (!allOk) process.exit(1);
407
445
  return;
408
446
  }
409
447
 
410
- // Reason: for 'raw' the user controls the full envelope — send params verbatim
411
- const command = action === 'raw' ? { id, ...params } : { id, action, ...params };
448
+ // Reason: for 'raw' the user controls the full envelope — send params verbatim (no target injection).
449
+ // For all other commands, include target only when --profile/--label was given so back-compat is exact.
450
+ const command = action === 'raw'
451
+ ? { id, ...params }
452
+ : { id, action, ...params, ...(profile ? { target: profile } : {}) };
412
453
  const response = await sendCommand(socketPath, command);
413
454
 
414
455
  console.log(formatResponse(action, response, json, params));
@@ -416,8 +457,33 @@ export async function run() {
416
457
  }
417
458
 
418
459
  // ── Entry point guard ─────────────────────────────────────────────────────────
419
- // Reason: guard allows this file to be imported by tests without running the CLI
420
- if (import.meta.url === pathToFileURL(process.argv[1]).href) {
460
+
461
+ /**
462
+ * Returns true when this module is being executed as the CLI entry point,
463
+ * not imported by another module (e.g. tests).
464
+ *
465
+ * Reason: `npm i -g` installs a symlink in the bin dir; `import.meta.url`
466
+ * resolves symlinks but `process.argv[1]` does not, so they only match after
467
+ * canonicalizing argvPath via realpathSync. If realpathSync throws (e.g.
468
+ * `node -` makes argv[1] equal `"-"` which is not a real file), fall back to
469
+ * the raw argvPath so the module can still be imported safely.
470
+ *
471
+ * @param {string} metaUrl - import.meta.url of the candidate entry module.
472
+ * @param {string | undefined} argvPath - process.argv[1].
473
+ * @returns {boolean}
474
+ */
475
+ export function isMainModule(metaUrl, argvPath) {
476
+ if (!argvPath) return false;
477
+ let resolvedPath = argvPath;
478
+ try {
479
+ resolvedPath = fs.realpathSync(argvPath);
480
+ } catch {
481
+ // Reason: argvPath may not be a real file (`node -`, REPL, etc.) — fall through with the raw path.
482
+ }
483
+ return metaUrl === pathToFileURL(resolvedPath).href;
484
+ }
485
+
486
+ if (isMainModule(import.meta.url, process.argv[1])) {
421
487
  run().catch((err) => {
422
488
  console.error(`❌ ${err.message}`);
423
489
  process.exit(1);
@@ -95,6 +95,15 @@ function formatExportLogs(response, params) {
95
95
  }
96
96
  }
97
97
 
98
+ /** @param {{ success: boolean; data?: unknown }} response */
99
+ function formatListProfiles(response) {
100
+ const rows = /** @type {Array<{label:string;port:number}>} */ (response.data ?? []);
101
+ 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}`);
104
+ return [header, ...body].join('\n');
105
+ }
106
+
98
107
  /** @param {{ success: boolean; data?: unknown }} response */
99
108
  function formatStatus(response) {
100
109
  const d = /** @type {{ authenticated?: boolean; email?: string | null }} */ (response.data ?? {});
@@ -114,6 +123,7 @@ const FORMATTERS = {
114
123
  panel_screenshot: (r, p, a) => formatToolExec(r, p, a),
115
124
  export_logs: (r, p, _a) => formatExportLogs(r, p),
116
125
  status: (r, _p, _a) => formatStatus(r),
126
+ list_profiles: (r, _p, _a) => formatListProfiles(r),
117
127
  };
118
128
 
119
129
  /**
@@ -39,10 +39,20 @@ export function uniquifyOutputForTab(output, tabId) {
39
39
  * @param {string} socketPath Daemon Unix socket
40
40
  * @param {{ groupId?: number; groupTitle?: string }} ref
41
41
  * @param {(socketPath: string, command: object) => Promise<{success:boolean,data?:any,error?:string}>} sendFn - send function (required, injectable for tests)
42
+ * @param {string | null} [target] - Profile label to route the resolving queries to (when --profile is set).
42
43
  * @returns {Promise<number[]>} Member tab IDs (order: ascending)
43
44
  */
44
- export async function expandGroupToTabIds(socketPath, ref, sendFn) {
45
- const groupsResp = await sendFn(socketPath, { id: `cli_lg_${Date.now()}`, action: 'list_groups' });
45
+ export async function expandGroupToTabIds(socketPath, ref, sendFn, target = null) {
46
+ // Reason: route the group-resolving queries to the same profile the command
47
+ // targets, so resolution isn't ambiguous when multiple profiles are connected.
48
+ const targetField = target ? { target } : {};
49
+ // Reason: pass all:true (flat, NOT nested under `params`) so --group /
50
+ // --group-title can resolve against any Chrome tab group, not just groups
51
+ // dassi currently has open. The daemon's dispatchToExtension does
52
+ // `{ id, action, ...rest } = cmd` and forwards `rest` as JSON-RPC params,
53
+ // so nesting under `params` would arrive at the extension as
54
+ // `params.params.all` — and the filter would NOT bypass.
55
+ const groupsResp = await sendFn(socketPath, { id: `cli_lg_${Date.now()}`, action: 'list_groups', all: true, ...targetField });
46
56
  if (!groupsResp.success) throw new Error(`Failed to list groups: ${groupsResp.error ?? 'unknown'}`);
47
57
  const groups = /** @type {Array<{id:number;title:string;windowId:number}>} */ (groupsResp.data ?? []);
48
58
 
@@ -64,7 +74,7 @@ export async function expandGroupToTabIds(socketPath, ref, sendFn) {
64
74
  throw new Error('expandGroupToTabIds: pass groupId or groupTitle');
65
75
  }
66
76
 
67
- const tabsResp = await sendFn(socketPath, { id: `cli_lt_${Date.now()}`, action: 'list_tabs' });
77
+ const tabsResp = await sendFn(socketPath, { id: `cli_lt_${Date.now()}`, action: 'list_tabs', all: true, ...targetField });
68
78
  if (!tabsResp.success) throw new Error(`Failed to list tabs: ${tabsResp.error ?? 'unknown'}`);
69
79
  const tabs = /** @type {Array<{tabId:number;groupId:number}>} */ (tabsResp.data ?? []);
70
80
  const memberIds = tabs.filter((t) => t.groupId === groupId).map((t) => t.tabId);
@@ -94,14 +104,16 @@ export function formatGroupJsonResults(results) {
94
104
  * @param {number} tabId
95
105
  * @param {boolean} multiTab True when >1 tab in the expansion (triggers output uniquification)
96
106
  * @param {(socketPath: string, command: object) => Promise<{success:boolean,data?:any,error?:string}>} sendFn
107
+ * @param {string | null} [target] - Profile label to route the child command to (when --profile is set).
97
108
  * @returns {Promise<{tabId: number, response: {success:boolean,data?:any,error?:string}, childParams: object}>}
98
109
  */
99
- async function dispatchChildForTab(socketPath, action, childBase, tabId, multiTab, sendFn) {
110
+ async function dispatchChildForTab(socketPath, action, childBase, tabId, multiTab, sendFn, target = null) {
100
111
  // Reason: uniquify _output per tab when fanning out so each file write targets a distinct path
101
112
  const perTabOutput =
102
113
  multiTab && childBase._output ? uniquifyOutputForTab(childBase._output, tabId) : childBase._output;
103
114
  const childParams = { ...childBase, tabId, ...(perTabOutput !== childBase._output ? { _output: perTabOutput } : {}) };
104
- const response = await sendFn(socketPath, { id: `cli_${Date.now()}_${tabId}`, action, ...childParams });
115
+ // Reason: include target only when set so the fan-out routes to the chosen profile.
116
+ const response = await sendFn(socketPath, { id: `cli_${Date.now()}_${tabId}`, action, ...childParams, ...(target ? { target } : {}) });
105
117
  return { tabId, response, childParams };
106
118
  }
107
119
 
@@ -122,13 +134,15 @@ async function dispatchChildForTab(socketPath, action, childBase, tabId, multiTa
122
134
  * @param {boolean} json
123
135
  * @param {(socketPath: string, command: object) => Promise<{success:boolean,data?:any,error?:string}>} sendFn
124
136
  * @param {(action: string, response: object, json: boolean, params: object) => string} formatFn
137
+ * @param {string | null} [target] - Profile label to route every query/child command to (when --profile is set).
125
138
  * @returns {Promise<boolean>} true if all child calls succeeded, false if any failed
126
139
  */
127
- export async function runWithGroupExpansion(socketPath, action, params, json, sendFn, formatFn) {
140
+ export async function runWithGroupExpansion(socketPath, action, params, json, sendFn, formatFn, target = null) {
128
141
  const tabIds = await expandGroupToTabIds(
129
142
  socketPath,
130
143
  { groupId: params.groupId, groupTitle: params.groupTitle },
131
144
  sendFn,
145
+ target,
132
146
  );
133
147
  // Reason: always on stderr so it doesn't pollute JSON output on stdout
134
148
  console.error(`Running on ${tabIds.length} tab${tabIds.length === 1 ? '' : 's'}: ${tabIds.join(', ')}`);
@@ -140,7 +154,7 @@ export async function runWithGroupExpansion(socketPath, action, params, json, se
140
154
  let allOk = true;
141
155
 
142
156
  for (const tabId of tabIds) {
143
- const { response, childParams } = await dispatchChildForTab(socketPath, action, childBase, tabId, multiTab, sendFn);
157
+ const { response, childParams } = await dispatchChildForTab(socketPath, action, childBase, tabId, multiTab, sendFn, target);
144
158
  if (!response.success) allOk = false;
145
159
  if (json) {
146
160
  // Reason: collect for a single JSON array emission after the loop
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dassi_ai/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CLI for the Dassi Chrome extension — run browser automation from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,11 @@
37
37
  "scripts": {
38
38
  "test": "vitest run"
39
39
  },
40
- "keywords": ["dassi", "cli", "browser-automation", "chrome-extension"],
40
+ "keywords": [
41
+ "dassi",
42
+ "cli",
43
+ "browser-automation",
44
+ "chrome-extension"
45
+ ],
41
46
  "license": "MIT"
42
47
  }
@@ -12,7 +12,7 @@ The main entry point for driving the Dassi Chrome extension from Claude Code.
12
12
 
13
13
  ## Prerequisites
14
14
 
15
- `dassi` must be on PATH. Install with `npm install -g dassi-cli` or `npm link` from the CLI package directory.
15
+ `dassi` must be on PATH. Install with `npm install -g @dassi_ai/cli` or `npm link` from the CLI package directory.
16
16
 
17
17
  ## Process
18
18