@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/.claude-plugin/plugin.json +8 -3
- package/README.md +116 -108
- package/daemon-client.mjs +41 -85
- package/dassi-daemon.mjs +109 -156
- package/dassi-shared.mjs +40 -172
- package/dassi.mjs +100 -62
- package/format-response.mjs +26 -11
- package/group-expansion.mjs +12 -3
- package/help-text.mjs +59 -48
- package/launch.mjs +1 -1
- package/package.json +5 -2
- package/setup.mjs +200 -0
- package/skills/dassi/SKILL.md +69 -0
- package/skills/dassi/scripts/dassi.mjs +3 -0
- package/tool-commands.mjs +48 -89
- package/skills/operate/SKILL.md +0 -124
- package/skills/operate/command-reference.md +0 -65
- package/skills/pick-tabs/SKILL.md +0 -93
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,
|
|
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
|
|
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
|
-
// ───
|
|
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
|
-
*
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
// ───
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
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 (${
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
return
|
|
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
|
|
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:
|
|
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:
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
79
|
-
return finish('list_tabs',
|
|
90
|
+
consumeFlag(args, '--all');
|
|
91
|
+
return finish('list_tabs', { all: true });
|
|
80
92
|
}
|
|
81
93
|
|
|
82
94
|
if (command === 'list-groups') {
|
|
83
|
-
|
|
84
|
-
return finish('list_groups',
|
|
95
|
+
consumeFlag(args, '--all');
|
|
96
|
+
return finish('list_groups', { all: true });
|
|
85
97
|
}
|
|
86
98
|
|
|
87
|
-
if (command === 'status') {
|
|
88
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
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 =
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
*
|
|
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.
|
|
264
|
+
parsed = parseCliArgs(argv, { interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY) });
|
|
265
|
+
await execute(parsed);
|
|
248
266
|
} catch (err) {
|
|
249
|
-
|
|
250
|
-
|
|
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(
|
|
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_${
|
|
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,
|
|
269
|
-
if (!allOk) process.
|
|
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
|
|
319
|
+
const response = await sendAndWait(socketPath, command);
|
|
279
320
|
|
|
280
321
|
console.log(formatResponse(action, response, json, params));
|
|
281
|
-
if (!response.success) process.
|
|
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()
|
|
313
|
-
console.error(`❌ ${err.message}`);
|
|
314
|
-
process.exit(1);
|
|
315
|
-
});
|
|
353
|
+
await run();
|
|
316
354
|
}
|
package/format-response.mjs
CHANGED
|
@@ -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 = '
|
|
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 = '
|
|
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 =
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
103
|
-
const body = rows.map((r) => `${String(r.label).padEnd(
|
|
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)
|
|
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
|
}
|