@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 +11 -4
- package/dassi-daemon.mjs +125 -61
- package/dassi-shared.mjs +120 -0
- package/dassi.mjs +97 -31
- package/format-response.mjs +10 -0
- package/group-expansion.mjs +21 -7
- package/package.json +7 -2
- package/skills/operate/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
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
|
|
9
|
+
npx @dassi_ai/cli --help
|
|
10
10
|
|
|
11
11
|
# Or install globally
|
|
12
|
-
npm install -g
|
|
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
|
|
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
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
*
|
|
66
|
-
*
|
|
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(
|
|
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 =
|
|
70
|
+
const pending = entry.pending.get(String(resp.id));
|
|
74
71
|
if (!pending) return;
|
|
75
|
-
|
|
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
|
|
86
|
-
*
|
|
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
|
|
85
|
+
function drainPendingResponses(entry) {
|
|
86
|
+
for (const [id, pending] of entry.pending) {
|
|
90
87
|
pending.reject(new Error('Extension disconnected'));
|
|
91
|
-
|
|
88
|
+
entry.pending.delete(id);
|
|
92
89
|
}
|
|
93
90
|
}
|
|
94
91
|
|
|
95
92
|
// ─── Extension server ─────────────────────────────────────────────────────────
|
|
96
93
|
|
|
97
94
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* @param {
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
226
|
+
entry.pending.delete(String(id));
|
|
231
227
|
reject(new Error(`Extension timeout after ${timeoutMs}ms`));
|
|
232
228
|
}, timeoutMs);
|
|
233
229
|
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* @
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
307
|
-
' list-groups
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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);
|
package/format-response.mjs
CHANGED
|
@@ -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
|
/**
|
package/group-expansion.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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": [
|
|
40
|
+
"keywords": [
|
|
41
|
+
"dassi",
|
|
42
|
+
"cli",
|
|
43
|
+
"browser-automation",
|
|
44
|
+
"chrome-extension"
|
|
45
|
+
],
|
|
41
46
|
"license": "MIT"
|
|
42
47
|
}
|
package/skills/operate/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|