@dassi_ai/cli 0.4.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +8 -3
- package/README.md +116 -105
- package/daemon-client.mjs +181 -0
- package/dassi-daemon.mjs +106 -156
- package/dassi-shared.mjs +35 -172
- package/dassi.mjs +125 -270
- package/format-response.mjs +26 -11
- package/group-expansion.mjs +12 -3
- package/help-text.mjs +59 -48
- package/launch.mjs +21 -8
- package/package.json +4 -3
- 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.mjs
CHANGED
|
@@ -4,56 +4,52 @@
|
|
|
4
4
|
* Protocol-compatible with agent-browser: {id, action, ...} → {id, success, data/error}
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import * as net from 'net';
|
|
8
7
|
import * as fs from 'fs';
|
|
9
8
|
import * as path from 'path';
|
|
10
9
|
import * as child_process from 'child_process';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
11
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
getReadyFile,
|
|
15
|
-
isDaemonRunning,
|
|
16
|
-
parseReadyPayload,
|
|
17
|
-
validateSession,
|
|
18
|
-
DASSI_EXTENSION_ID,
|
|
19
|
-
getAppDir,
|
|
20
|
-
getLaunchesFile,
|
|
21
|
-
} from './dassi-shared.mjs';
|
|
12
|
+
import { getSocketPath, validateSession, getAppDir, getLaunchesFile } from './dassi-shared.mjs';
|
|
13
|
+
import { ensureDaemonRunning, sendCommand, sendAndWait, ensureDaemonReady } from './daemon-client.mjs';
|
|
22
14
|
import { dispatchLaunch } from './launch.mjs';
|
|
23
|
-
import { parseToolCommand, requireTarget, parseStrictInt } from './tool-commands.mjs';
|
|
15
|
+
import { parseToolCommand, requireTarget, parseStrictInt, parseTarget, parseWait } from './tool-commands.mjs';
|
|
24
16
|
import { runWithGroupExpansion } from './group-expansion.mjs';
|
|
25
17
|
import { formatResponse } from './format-response.mjs';
|
|
26
18
|
import { HELP_TEXT } from './help-text.mjs';
|
|
27
19
|
|
|
28
20
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
-
const DAEMON_SCRIPT = path.join(__dirname, 'dassi-daemon.mjs');
|
|
30
21
|
const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')).version;
|
|
31
|
-
const READY_POLL_MS = 100;
|
|
32
|
-
const READY_TIMEOUT_MS = 30_000;
|
|
33
|
-
const LOGIN_POLL_MS = 2_000;
|
|
34
|
-
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
35
|
-
const CHROME_WEB_STORE_URL = `https://chromewebstore.google.com/detail/dassi-ai-browser-agent-fo/${DASSI_EXTENSION_ID}`;
|
|
36
22
|
|
|
37
23
|
// ─── Arg parsing ──────────────────────────────────────────────────────────────
|
|
38
24
|
|
|
39
25
|
/**
|
|
40
26
|
* Parses CLI arguments into a command descriptor.
|
|
41
|
-
* Supports
|
|
27
|
+
* Supports live tool discovery and generic browser calls,
|
|
42
28
|
* agent commands (run, list-tabs, status, raw), and top-level flags (--version, --help).
|
|
43
29
|
* Run `dassi --help` for the full command reference.
|
|
44
30
|
* @param {string[]} argv process.argv.slice(2)
|
|
45
31
|
* @returns {{ action: string; params: Record<string, unknown>; session: string; json: boolean; profile: string | null }}
|
|
46
32
|
*/
|
|
47
|
-
export function parseCliArgs(argv) {
|
|
33
|
+
export function parseCliArgs(argv, { interactive = false } = {}) {
|
|
48
34
|
const args = [...argv];
|
|
49
35
|
|
|
36
|
+
// Reason: hoist the `--` split to BEFORE global flag parsing so that Chrome
|
|
37
|
+
// passthrough tokens like --json, --label, --profile, or --stop can never be
|
|
38
|
+
// mistakenly consumed as CLI flags. Everything after the first `--` token is
|
|
39
|
+
// sliced out here and stored verbatim in `chromeArgs`; the remaining `args`
|
|
40
|
+
// array is then parsed as usual with no risk of collision. Non-launch commands
|
|
41
|
+
// that supply a `--` separator are rejected loudly below (after the action is
|
|
42
|
+
// known) so callers get a clear error instead of silent data loss.
|
|
43
|
+
const sepIdx = args.indexOf('--');
|
|
44
|
+
const chromeArgs = sepIdx === -1 ? [] : args.splice(sepIdx).slice(1);
|
|
45
|
+
|
|
50
46
|
// Reason: handle --version and --help before any flag parsing so they work
|
|
51
47
|
// even when other flags like --session are incomplete (e.g. `dassi --version --session`)
|
|
52
48
|
if (consumeFlag(args, '--version')) {
|
|
53
|
-
return { action: 'version', params: {}, session: 'default', json:
|
|
49
|
+
return { action: 'version', params: {}, session: 'default', json: args.includes('--json'), profile: null };
|
|
54
50
|
}
|
|
55
51
|
if (consumeFlag(args, '--help')) {
|
|
56
|
-
return { action: 'help', params: {}, session: 'default', json:
|
|
52
|
+
return { action: 'help', params: {}, session: 'default', json: args.includes('--json'), profile: null };
|
|
57
53
|
}
|
|
58
54
|
|
|
59
55
|
const session = validateSession(getFlag(args, '--session') ?? process.env.DASSI_SESSION ?? 'default');
|
|
@@ -62,23 +58,51 @@ export function parseCliArgs(argv) {
|
|
|
62
58
|
|
|
63
59
|
// Reason: centralise the return shape so every command branch includes profile
|
|
64
60
|
// without editing each return individually
|
|
65
|
-
const finish = (action, params) =>
|
|
61
|
+
const finish = (action, params) => {
|
|
62
|
+
if (args.length) throw new Error(`Unexpected argument: ${args[0]}. Run: dassi --help`);
|
|
63
|
+
const { profileTarget, ...rest } = params;
|
|
64
|
+
if (profile && profileTarget && profile !== profileTarget) throw new Error('Target includes a profile; omit --profile or use the same profile ID.');
|
|
65
|
+
return { action, params: rest, session, json, profile: profileTarget ?? profile };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const command = args.shift() ?? (interactive && argv.length === 0 ? 'setup' : undefined);
|
|
69
|
+
if (!command) throw new Error('No command specified. Run dassi setup to connect Chrome, or dassi --help for commands.');
|
|
70
|
+
|
|
71
|
+
// Reason: chromeArgs (tokens after `--`) are only meaningful for the `launch`
|
|
72
|
+
// command. Any other command that supplies `--` tokens would have them silently
|
|
73
|
+
// dropped — fail loudly instead so the caller knows their intent was not honoured.
|
|
74
|
+
if (chromeArgs.length > 0 && command !== 'launch') {
|
|
75
|
+
throw new Error("'--' is only valid with the launch command");
|
|
76
|
+
}
|
|
66
77
|
|
|
67
|
-
|
|
68
|
-
|
|
78
|
+
if (command === 'setup') {
|
|
79
|
+
const waitRaw = getFlag(args, '--wait');
|
|
80
|
+
return finish('setup', { waitMs: parseWait(waitRaw ?? '5m'), noOpen: consumeFlag(args, '--no-open') });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (command === 'skill') {
|
|
84
|
+
const pathOnly = consumeFlag(args, '--path');
|
|
85
|
+
const remove = consumeFlag(args, '--remove');
|
|
86
|
+
if (pathOnly && remove) throw new Error('Use either skill --path or skill --remove.');
|
|
87
|
+
return finish(remove ? 'remove_skill' : 'skill', remove ? {} : { pathOnly });
|
|
88
|
+
}
|
|
69
89
|
|
|
70
90
|
if (command === 'list-tabs') {
|
|
71
|
-
|
|
72
|
-
return finish('list_tabs',
|
|
91
|
+
consumeFlag(args, '--all');
|
|
92
|
+
return finish('list_tabs', { all: true });
|
|
73
93
|
}
|
|
74
94
|
|
|
75
95
|
if (command === 'list-groups') {
|
|
76
|
-
|
|
77
|
-
return finish('list_groups',
|
|
96
|
+
consumeFlag(args, '--all');
|
|
97
|
+
return finish('list_groups', { all: true });
|
|
78
98
|
}
|
|
79
99
|
|
|
80
|
-
if (command === 'status') {
|
|
81
|
-
|
|
100
|
+
if (command === 'status' || command === 'stop') {
|
|
101
|
+
const waitRaw = getFlag(args, '--wait');
|
|
102
|
+
const taskId = args[0] && !args[0].startsWith('-') ? args.shift() : undefined;
|
|
103
|
+
if (!taskId && (command === 'stop' || waitRaw !== undefined)) throw new Error(`${command} requires a task ID`);
|
|
104
|
+
return finish(taskId ? (command === 'stop' ? 'task_stop' : 'task_status') : 'status',
|
|
105
|
+
taskId ? { taskId, ...(waitRaw !== undefined ? { waitMs: parseWait(waitRaw) } : {}) } : {});
|
|
82
106
|
}
|
|
83
107
|
|
|
84
108
|
if (command === 'list-profiles') {
|
|
@@ -86,10 +110,16 @@ export function parseCliArgs(argv) {
|
|
|
86
110
|
}
|
|
87
111
|
|
|
88
112
|
if (command === 'launch') {
|
|
89
|
-
if (consumeFlag(args, '--stop-all'))
|
|
113
|
+
if (consumeFlag(args, '--stop-all')) {
|
|
114
|
+
// Reason: chromeArgs with --stop-all makes no sense — reject to prevent confusion.
|
|
115
|
+
if (chromeArgs.length > 0) throw new Error("'--' chrome args cannot be combined with launch --stop-all");
|
|
116
|
+
return finish('launch_stop', { all: true });
|
|
117
|
+
}
|
|
90
118
|
const stopIdx = args.indexOf('--stop');
|
|
91
119
|
if (stopIdx !== -1) {
|
|
92
120
|
args.splice(stopIdx, 1);
|
|
121
|
+
// Reason: chromeArgs with --stop makes no sense — reject to prevent confusion.
|
|
122
|
+
if (chromeArgs.length > 0) throw new Error("'--' chrome args cannot be combined with launch --stop");
|
|
93
123
|
// Reason: resolve the stop label like the start path does — a positional
|
|
94
124
|
// token after --stop wins, else the global --label/--profile (parsed into
|
|
95
125
|
// `profile` before this branch), else the default. Keeps `--stop --label qa`
|
|
@@ -117,6 +147,7 @@ export function parseCliArgs(argv) {
|
|
|
117
147
|
profileDir: getFlag(args, '--profile-dir') ?? null,
|
|
118
148
|
loadMode,
|
|
119
149
|
timeoutMs: timeoutRaw ? parseStrictInt(timeoutRaw, '--timeout') : 30000,
|
|
150
|
+
chromeArgs,
|
|
120
151
|
});
|
|
121
152
|
}
|
|
122
153
|
|
|
@@ -126,10 +157,13 @@ export function parseCliArgs(argv) {
|
|
|
126
157
|
if (command === 'run') {
|
|
127
158
|
const prompt = args.shift();
|
|
128
159
|
if (!prompt) throw new Error('run requires a prompt argument');
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
160
|
+
const taskId = getFlag(args, '--task');
|
|
161
|
+
if (args.includes('--timeout')) throw new Error('run no longer accepts --timeout. Use --wait to wait locally, or stop <task-id> to cancel.');
|
|
162
|
+
if (taskId && ['--tab', '--group', '--group-title'].some(flag => args.includes(flag))) throw new Error('Use --task to continue its original browser context; omit tab/group selectors.');
|
|
163
|
+
const target = taskId ? { taskId } : requireTarget('run', args, getFlag);
|
|
164
|
+
const waitRaw = getFlag(args, '--wait');
|
|
165
|
+
const waitMs = waitRaw !== undefined ? parseWait(waitRaw) : 0;
|
|
166
|
+
const base = { prompt, ...(waitMs ? { waitMs } : {}) };
|
|
133
167
|
return finish('run', { ...base, ...target });
|
|
134
168
|
}
|
|
135
169
|
|
|
@@ -141,13 +175,13 @@ export function parseCliArgs(argv) {
|
|
|
141
175
|
if (command === 'panel-screenshot') {
|
|
142
176
|
const tabRaw = getFlag(args, '--tab');
|
|
143
177
|
if (!tabRaw) throw new Error('panel-screenshot requires --tab <tabId>');
|
|
144
|
-
const tabId =
|
|
178
|
+
const { id: tabId, ...target } = parseTarget(tabRaw, '--tab');
|
|
145
179
|
const output = getFlag(args, '-o') ?? getFlag(args, '--output') ?? null;
|
|
146
180
|
const widthRaw = getFlag(args, '--width');
|
|
147
181
|
const heightRaw = getFlag(args, '--height');
|
|
148
182
|
const width = widthRaw ? parseStrictInt(widthRaw, '--width') : undefined;
|
|
149
183
|
const height = heightRaw ? parseStrictInt(heightRaw, '--height') : undefined;
|
|
150
|
-
return finish('panel_screenshot', { tabId, ...(output ? { _output: output } : {}), ...(width ? { width } : {}), ...(height ? { height } : {}) });
|
|
184
|
+
return finish('panel_screenshot', { tabId, ...target, ...(output ? { _output: output } : {}), ...(width ? { width } : {}), ...(height ? { height } : {}) });
|
|
151
185
|
}
|
|
152
186
|
|
|
153
187
|
if (command === 'raw') {
|
|
@@ -159,14 +193,18 @@ export function parseCliArgs(argv) {
|
|
|
159
193
|
return finish('raw', cmd);
|
|
160
194
|
}
|
|
161
195
|
|
|
162
|
-
// Reason: Tool commands are extracted to tool-commands.mjs to keep parseCliArgs
|
|
163
|
-
// and dassi.mjs within CLAUDE.md size limits (40 lines / 500 lines).
|
|
164
196
|
const toolParams = parseToolCommand(command, args, getFlag, consumeFlag);
|
|
165
197
|
if (toolParams) {
|
|
198
|
+
// Reason: some tool commands resolve to dedicated bridge methods rather
|
|
199
|
+
// than tool_exec (open --window → open_tab).
|
|
200
|
+
if (toolParams.__bridgeAction) {
|
|
201
|
+
const { __bridgeAction, ...params } = toolParams;
|
|
202
|
+
return finish(__bridgeAction, params);
|
|
203
|
+
}
|
|
166
204
|
return finish('tool_exec', toolParams);
|
|
167
205
|
}
|
|
168
206
|
|
|
169
|
-
throw new Error(`Unknown command: "${command}". Run: dassi --help`);
|
|
207
|
+
throw new Error(`Unknown command: "${command}". Browser commands use dassi tools and dassi call. Run: dassi --help`);
|
|
170
208
|
}
|
|
171
209
|
|
|
172
210
|
/**
|
|
@@ -199,258 +237,78 @@ function consumeFlag(args, flag) {
|
|
|
199
237
|
return true;
|
|
200
238
|
}
|
|
201
239
|
|
|
202
|
-
// ─── Daemon management ────────────────────────────────────────────────────────
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Ensures the daemon is running for the given session.
|
|
206
|
-
* Spawns it as a detached background process if the PID file is missing or stale.
|
|
207
|
-
* @param {string} session
|
|
208
|
-
*/
|
|
209
|
-
export function ensureDaemonRunning(session) {
|
|
210
|
-
if (isDaemonRunning(session)) return;
|
|
211
|
-
|
|
212
|
-
// Reason: detached + unref means the daemon outlives the CLI process
|
|
213
|
-
const daemon = child_process.spawn(process.execPath, [DAEMON_SCRIPT], {
|
|
214
|
-
detached: true,
|
|
215
|
-
stdio: 'ignore',
|
|
216
|
-
env: { ...process.env, DASSI_SESSION: session },
|
|
217
|
-
});
|
|
218
|
-
daemon.unref();
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* Polls for the ready file until it appears or the timeout elapses.
|
|
223
|
-
* Resolves with the parsed ready payload.
|
|
224
|
-
* @param {string} readyFile
|
|
225
|
-
* @param {number} [timeoutMs]
|
|
226
|
-
* @returns {Promise<{ status: string; [key: string]: unknown }>}
|
|
227
|
-
*/
|
|
228
|
-
export function waitForReady(readyFile, timeoutMs = READY_TIMEOUT_MS) {
|
|
229
|
-
return new Promise((resolve, reject) => {
|
|
230
|
-
const deadline = Date.now() + timeoutMs;
|
|
231
|
-
const check = () => {
|
|
232
|
-
if (fs.existsSync(readyFile)) {
|
|
233
|
-
try {
|
|
234
|
-
resolve(parseReadyPayload(fs.readFileSync(readyFile, 'utf8')));
|
|
235
|
-
return;
|
|
236
|
-
} catch { /* file may be partially written — keep polling */ }
|
|
237
|
-
}
|
|
238
|
-
if (Date.now() >= deadline) {
|
|
239
|
-
const seconds = Math.round(timeoutMs / 1000);
|
|
240
|
-
reject(new Error(`Timed out waiting for Dassi daemon to start (${seconds}s)`));
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
setTimeout(check, READY_POLL_MS);
|
|
244
|
-
};
|
|
245
|
-
check();
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Connects to the daemon Unix socket, sends one NDJSON command, and reads the response.
|
|
251
|
-
* @param {string} socketPath - Unix socket path.
|
|
252
|
-
* @param {object} command - Command object to send (will be JSON-stringified).
|
|
253
|
-
* @returns {Promise<object>} Parsed response object.
|
|
254
|
-
*/
|
|
255
|
-
export function sendCommand(socketPath, command) {
|
|
256
|
-
return new Promise((resolve, reject) => {
|
|
257
|
-
const socket = net.createConnection(socketPath);
|
|
258
|
-
let buffer = '';
|
|
259
|
-
let settled = false;
|
|
260
|
-
|
|
261
|
-
// Reason: guard against double-settlement since 'close' fires after socket.destroy()
|
|
262
|
-
// in the normal data path, and we don't want the close handler to re-resolve
|
|
263
|
-
const settle = (fn, val) => {
|
|
264
|
-
if (settled) return;
|
|
265
|
-
settled = true;
|
|
266
|
-
fn(val);
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
socket.on('connect', () => {
|
|
270
|
-
socket.write(JSON.stringify(command) + '\n');
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
socket.on('data', (chunk) => {
|
|
274
|
-
buffer += chunk.toString();
|
|
275
|
-
const nl = buffer.indexOf('\n');
|
|
276
|
-
if (nl !== -1) {
|
|
277
|
-
const line = buffer.slice(0, nl);
|
|
278
|
-
socket.destroy();
|
|
279
|
-
try { settle(resolve, JSON.parse(line)); }
|
|
280
|
-
catch { settle(reject, new Error(`Invalid JSON from daemon: ${line}`)); }
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
socket.on('error', (err) => settle(reject, err));
|
|
285
|
-
|
|
286
|
-
socket.on('close', () => {
|
|
287
|
-
if (settled) return;
|
|
288
|
-
// Reason: attempt to parse whatever arrived before the socket closed unexpectedly
|
|
289
|
-
if (buffer.trim()) {
|
|
290
|
-
try { settle(resolve, JSON.parse(buffer.trim())); }
|
|
291
|
-
catch { settle(reject, new Error(`Invalid JSON from daemon: ${buffer.trim()}`)); }
|
|
292
|
-
} else {
|
|
293
|
-
settle(reject, new Error('Daemon closed connection without a response'));
|
|
294
|
-
}
|
|
295
|
-
});
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// ─── Login helpers ────────────────────────────────────────────────────────────
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Defense-in-depth check for `optionsUrl` before handing it to the `open`
|
|
303
|
-
* package. Legit URLs always come from `chrome.runtime.getURL('options.html')`
|
|
304
|
-
* → `chrome-extension://<id>/options.html`. Anything else is unexpected and
|
|
305
|
-
* we should not auto-launch it (the `open` package shells out to the OS URL
|
|
306
|
-
* handler).
|
|
307
|
-
* @param {unknown} optionsUrl
|
|
308
|
-
* @returns {boolean}
|
|
309
|
-
*/
|
|
310
|
-
export function isValidOptionsUrl(optionsUrl) {
|
|
311
|
-
return typeof optionsUrl === 'string' && /^chrome-extension:\/\/[a-z]{32}\//.test(optionsUrl);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Polls the daemon's status until the user signs in (extension reports
|
|
316
|
-
* authenticated=true) or the LOGIN_TIMEOUT_MS deadline elapses. On entry,
|
|
317
|
-
* best-effort auto-opens the extension's options page (subject to the
|
|
318
|
-
* isValidOptionsUrl guard above).
|
|
319
|
-
* @param {string} socketPath - Path to the daemon Unix socket.
|
|
320
|
-
* @param {string | undefined} optionsUrl - URL of the Dassi options page.
|
|
321
|
-
* @returns {Promise<void>} Resolves on successful login; throws on timeout.
|
|
322
|
-
*/
|
|
323
|
-
export async function waitForLogin(socketPath, optionsUrl) {
|
|
324
|
-
console.error(`⚠️ Dassi is installed but you're not signed in.\n Opening the Dassi settings page...\n`);
|
|
325
|
-
|
|
326
|
-
// Auto-open the options page — best-effort (package may not be installed).
|
|
327
|
-
// Reason: only open URLs the extension would legitimately produce
|
|
328
|
-
// (chrome.runtime.getURL → `chrome-extension://<id>/options.html`).
|
|
329
|
-
// Defense-in-depth: even though optionsUrl is sourced from the daemon's
|
|
330
|
-
// ready file (which lives in our owner-only ~/.dassi/ dir), validating the
|
|
331
|
-
// protocol prevents `open` from launching arbitrary URIs/shell-handlers if
|
|
332
|
-
// the file is ever tampered with.
|
|
333
|
-
if (isValidOptionsUrl(optionsUrl)) {
|
|
334
|
-
try {
|
|
335
|
-
const { default: open } = await import('open');
|
|
336
|
-
await open(String(optionsUrl));
|
|
337
|
-
} catch {
|
|
338
|
-
console.error(` Please open: ${optionsUrl}`);
|
|
339
|
-
}
|
|
340
|
-
} else if (optionsUrl) {
|
|
341
|
-
console.error(` Refusing to auto-open unexpected URL: ${optionsUrl}\n Please open the Dassi settings page manually.`);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// Poll the daemon socket every LOGIN_POLL_MS until authenticated
|
|
345
|
-
console.error(' Waiting for sign-in... (Ctrl+C to cancel)');
|
|
346
|
-
const deadline = Date.now() + LOGIN_TIMEOUT_MS;
|
|
347
|
-
let pollIndex = 0;
|
|
348
|
-
while (Date.now() < deadline) {
|
|
349
|
-
await new Promise((r) => setTimeout(r, LOGIN_POLL_MS));
|
|
350
|
-
try {
|
|
351
|
-
// Reason: increment poll index so each status request has a unique id for tracing
|
|
352
|
-
const resp = await sendCommand(socketPath, { id: `poll-auth-${pollIndex++}`, action: 'status' });
|
|
353
|
-
if (resp.success && resp.data?.authenticated) {
|
|
354
|
-
console.error(` ✓ Signed in as ${resp.data.email}\n ✓ Ready\n`);
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
} catch { /* daemon may not be socket-ready yet — keep polling */ }
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
throw new Error('Login timed out. Please sign in and try again.');
|
|
361
|
-
}
|
|
362
|
-
|
|
363
240
|
// ─── Immediate actions ────────────────────────────────────────────────────────
|
|
364
241
|
|
|
365
242
|
// HELP_TEXT lives in ./help-text.mjs (extracted to keep this file within the size limit).
|
|
366
243
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if (action === 'help') {
|
|
378
|
-
console.log(HELP_TEXT);
|
|
379
|
-
process.exit(0);
|
|
380
|
-
}
|
|
381
|
-
return false;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// ─── Daemon readiness ─────────────────────────────────────────────────────────
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Ensures the daemon is running and ready, handling onboarding if needed.
|
|
388
|
-
* @param {string} session
|
|
389
|
-
* @returns {Promise<string>} The daemon's Unix socket path.
|
|
390
|
-
*/
|
|
391
|
-
async function ensureDaemonReady(session) {
|
|
392
|
-
const socketPath = getSocketPath(session);
|
|
393
|
-
const readyFile = getReadyFile(session);
|
|
394
|
-
|
|
395
|
-
// Reason: delete any stale ready file from a previous run so the new daemon
|
|
396
|
-
// writes a fresh one. Skip if a daemon is already running to avoid racing.
|
|
397
|
-
if (!isDaemonRunning(session)) {
|
|
398
|
-
try { fs.unlinkSync(readyFile); } catch { /* ok if missing */ }
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
ensureDaemonRunning(session);
|
|
402
|
-
|
|
403
|
-
const ready = await waitForReady(readyFile);
|
|
404
|
-
|
|
405
|
-
if (ready.status === 'extension_not_installed') {
|
|
406
|
-
console.error(
|
|
407
|
-
`❌ Dassi extension not detected.\n\n` +
|
|
408
|
-
` Install it from:\n ${CHROME_WEB_STORE_URL}\n\n` +
|
|
409
|
-
` Then run this command again.`
|
|
410
|
-
);
|
|
411
|
-
process.exit(1);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
if (ready.status === 'needs_login') {
|
|
415
|
-
await waitForLogin(socketPath, ready.optionsUrl ? String(ready.optionsUrl) : undefined);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
return socketPath;
|
|
244
|
+
function handleImmediateAction({ action, params, json }) {
|
|
245
|
+
let text;
|
|
246
|
+
if (action === 'version') text = VERSION;
|
|
247
|
+
else if (action === 'help') text = HELP_TEXT;
|
|
248
|
+
else if (action === 'skill') {
|
|
249
|
+
const skillDir = path.join(__dirname, 'skills', 'dassi');
|
|
250
|
+
text = params.pathOnly ? skillDir : fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8');
|
|
251
|
+
} else return false;
|
|
252
|
+
console.log(json ? JSON.stringify({ success: true, data: text }) : text);
|
|
253
|
+
return true;
|
|
419
254
|
}
|
|
420
255
|
|
|
421
256
|
// ─── Entry point ──────────────────────────────────────────────────────────────
|
|
422
257
|
|
|
423
258
|
/**
|
|
424
|
-
*
|
|
425
|
-
* onboarding, sends command, prints response.
|
|
259
|
+
* Parses arguments, connects to the daemon, and prints the command result.
|
|
426
260
|
* @returns {Promise<void>}
|
|
427
261
|
*/
|
|
428
|
-
export async function run() {
|
|
262
|
+
export async function run(argv = process.argv.slice(2)) {
|
|
429
263
|
let parsed;
|
|
430
264
|
try {
|
|
431
|
-
parsed = parseCliArgs(process.
|
|
265
|
+
parsed = parseCliArgs(argv, { interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY) });
|
|
266
|
+
await execute(parsed);
|
|
432
267
|
} catch (err) {
|
|
433
|
-
|
|
434
|
-
|
|
268
|
+
const args = argv.slice(0, argv.indexOf('--') === -1 ? argv.length : argv.indexOf('--'));
|
|
269
|
+
const json = parsed?.json ?? args.includes('--json');
|
|
270
|
+
const response = { success: false, error: err instanceof Error ? err.message : String(err), ...(err?.outcome === 'unknown' ? { outcome: 'unknown' } : {}) };
|
|
271
|
+
if (json) console.log(formatResponse('error', response, true));
|
|
272
|
+
else console.error(formatResponse('error', response, false));
|
|
273
|
+
process.exitCode = 1;
|
|
435
274
|
}
|
|
275
|
+
}
|
|
436
276
|
|
|
277
|
+
async function execute(parsed) {
|
|
437
278
|
const { action, params, session, json, profile } = parsed;
|
|
438
|
-
handleImmediateAction(
|
|
279
|
+
if (handleImmediateAction(parsed)) return;
|
|
280
|
+
if (action === 'remove_skill') {
|
|
281
|
+
const { unregisterSkills } = await import('./setup.mjs');
|
|
282
|
+
const skills = await unregisterSkills();
|
|
283
|
+
console.log(json ? JSON.stringify({ success: true, data: { skills } }) : [
|
|
284
|
+
...skills.map(skill => `${skill.status}: ${skill.path}`),
|
|
285
|
+
'To remove the CLI, run: npm uninstall -g @dassi_ai/cli',
|
|
286
|
+
].join('\n'));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (action === 'setup') {
|
|
290
|
+
const { setup, formatSetup } = await import('./setup.mjs');
|
|
291
|
+
const data = await setup({ ...params, profile, session, interactive: !json && Boolean(process.stdin.isTTY) });
|
|
292
|
+
const response = { success: data.ready, data, ...(!data.ready ? { error: data.next } : {}) };
|
|
293
|
+
console.log(json ? JSON.stringify(response) : formatSetup(data));
|
|
294
|
+
if (!data.ready) process.exitCode = 1;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
439
297
|
|
|
440
298
|
// Launch family (launch / --stop / __launch-hold) → launch.mjs.
|
|
441
299
|
if (await dispatchLaunch(action, params, { spawn: child_process.spawn, ensureDaemonRunning, getSocketPath, sendCommand, appDir: getAppDir(), launchesFile: getLaunchesFile() })) return;
|
|
442
300
|
|
|
443
301
|
const socketPath = await ensureDaemonReady(session);
|
|
444
302
|
|
|
445
|
-
const id = `cli_${
|
|
303
|
+
const id = `cli_${randomUUID()}`;
|
|
446
304
|
|
|
447
305
|
// Group expansion: if action=run or action=tool_exec with groupId/groupTitle, expand and fan out sequentially
|
|
448
306
|
if (
|
|
449
307
|
(action === 'run' || action === 'tool_exec') &&
|
|
450
308
|
(params.groupId !== undefined || params.groupTitle !== undefined)
|
|
451
309
|
) {
|
|
452
|
-
const allOk = await runWithGroupExpansion(socketPath, action, params, json,
|
|
453
|
-
if (!allOk) process.
|
|
310
|
+
const allOk = await runWithGroupExpansion(socketPath, action, params, json, sendAndWait, formatResponse, profile);
|
|
311
|
+
if (!allOk) process.exitCode = 1;
|
|
454
312
|
return;
|
|
455
313
|
}
|
|
456
314
|
|
|
@@ -459,10 +317,10 @@ export async function run() {
|
|
|
459
317
|
const command = action === 'raw'
|
|
460
318
|
? { id, ...params }
|
|
461
319
|
: { id, action, ...params, ...(profile ? { target: profile } : {}) };
|
|
462
|
-
const response = await
|
|
320
|
+
const response = await sendAndWait(socketPath, command);
|
|
463
321
|
|
|
464
322
|
console.log(formatResponse(action, response, json, params));
|
|
465
|
-
if (!response.success) process.
|
|
323
|
+
if (!response.success || (command.action !== 'task_stop' && ['failed', 'stopped'].includes(response.data?.status))) process.exitCode = 1;
|
|
466
324
|
}
|
|
467
325
|
|
|
468
326
|
// ── Entry point guard ─────────────────────────────────────────────────────────
|
|
@@ -493,8 +351,5 @@ export function isMainModule(metaUrl, argvPath) {
|
|
|
493
351
|
}
|
|
494
352
|
|
|
495
353
|
if (isMainModule(import.meta.url, process.argv[1])) {
|
|
496
|
-
run()
|
|
497
|
-
console.error(`❌ ${err.message}`);
|
|
498
|
-
process.exit(1);
|
|
499
|
-
});
|
|
354
|
+
await run();
|
|
500
355
|
}
|
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
|
}
|