@dassi_ai/cli 0.1.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 +12 -0
- package/README.md +106 -0
- package/dassi-daemon.mjs +374 -0
- package/dassi-shared.mjs +182 -0
- package/dassi.mjs +425 -0
- package/format-response.mjs +134 -0
- package/group-expansion.mjs +157 -0
- package/package.json +42 -0
- package/skills/operate/SKILL.md +124 -0
- package/skills/operate/command-reference.md +50 -0
- package/skills/pick-tabs/SKILL.md +93 -0
- package/tool-commands.mjs +144 -0
package/dassi.mjs
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Dassi CLI — thin client that talks to the daemon over a Unix socket.
|
|
4
|
+
* Protocol-compatible with agent-browser: {id, action, ...} → {id, success, data/error}
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as net from 'net';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import * as child_process from 'child_process';
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
12
|
+
import {
|
|
13
|
+
getSocketPath,
|
|
14
|
+
getReadyFile,
|
|
15
|
+
isDaemonRunning,
|
|
16
|
+
parseReadyPayload,
|
|
17
|
+
validateSession,
|
|
18
|
+
} from './dassi-shared.mjs';
|
|
19
|
+
import { parseToolCommand, requireTarget, parseStrictInt } from './tool-commands.mjs';
|
|
20
|
+
import { runWithGroupExpansion } from './group-expansion.mjs';
|
|
21
|
+
import { formatResponse } from './format-response.mjs';
|
|
22
|
+
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const DAEMON_SCRIPT = path.join(__dirname, 'dassi-daemon.mjs');
|
|
25
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')).version;
|
|
26
|
+
const READY_POLL_MS = 100;
|
|
27
|
+
const READY_TIMEOUT_MS = 30_000;
|
|
28
|
+
const LOGIN_POLL_MS = 2_000;
|
|
29
|
+
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
30
|
+
const CHROME_WEB_STORE_URL = 'https://chromewebstore.google.com/detail/dassi-ai-browser-agent-fo/bjcngahpcjeililljmfegmlanlpgibdi';
|
|
31
|
+
|
|
32
|
+
// ─── Arg parsing ──────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parses CLI arguments into a command descriptor.
|
|
36
|
+
* Supports browser tool commands (navigate, click, fill, type, read-page, etc.),
|
|
37
|
+
* agent commands (run, list-tabs, status, raw), and top-level flags (--version, --help).
|
|
38
|
+
* Run `dassi --help` for the full command reference.
|
|
39
|
+
* @param {string[]} argv process.argv.slice(2)
|
|
40
|
+
* @returns {{ action: string; params: Record<string, unknown>; session: string; json: boolean }}
|
|
41
|
+
*/
|
|
42
|
+
export function parseCliArgs(argv) {
|
|
43
|
+
const args = [...argv];
|
|
44
|
+
|
|
45
|
+
// Reason: handle --version and --help before any flag parsing so they work
|
|
46
|
+
// even when other flags like --session are incomplete (e.g. `dassi --version --session`)
|
|
47
|
+
if (consumeFlag(args, '--version')) {
|
|
48
|
+
return { action: 'version', params: {}, session: 'default', json: false };
|
|
49
|
+
}
|
|
50
|
+
if (consumeFlag(args, '--help')) {
|
|
51
|
+
return { action: 'help', params: {}, session: 'default', json: false };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const session = validateSession(getFlag(args, '--session') ?? process.env.DASSI_SESSION ?? 'default');
|
|
55
|
+
const json = consumeFlag(args, '--json');
|
|
56
|
+
|
|
57
|
+
const command = args.shift();
|
|
58
|
+
if (!command) throw new Error('No command specified. Run: dassi --help');
|
|
59
|
+
|
|
60
|
+
if (command === 'list-tabs') {
|
|
61
|
+
return { action: 'list_tabs', params: {}, session, json };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (command === 'list-groups') {
|
|
65
|
+
return { action: 'list_groups', params: {}, session, json };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (command === 'status') {
|
|
69
|
+
return { action: 'status', params: {}, session, json };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (command === 'run') {
|
|
73
|
+
const prompt = args.shift();
|
|
74
|
+
if (!prompt) throw new Error('run requires a prompt argument');
|
|
75
|
+
const target = requireTarget('run', args, getFlag);
|
|
76
|
+
const timeoutRaw = getFlag(args, '--timeout');
|
|
77
|
+
const timeoutMs = timeoutRaw ? parseStrictInt(timeoutRaw, '--timeout') : undefined;
|
|
78
|
+
const base = { prompt, ...(timeoutMs !== undefined ? { timeoutMs } : {}) };
|
|
79
|
+
return { action: 'run', params: { ...base, ...target }, session, json };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (command === 'bug-report') {
|
|
83
|
+
const output = getFlag(args, '-o') ?? getFlag(args, '--output');
|
|
84
|
+
return { action: 'export_logs', params: { output }, session, json };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (command === 'panel-screenshot') {
|
|
88
|
+
const tabRaw = getFlag(args, '--tab');
|
|
89
|
+
if (!tabRaw) throw new Error('panel-screenshot requires --tab <tabId>');
|
|
90
|
+
const tabId = parseStrictInt(tabRaw, '--tab');
|
|
91
|
+
const output = getFlag(args, '-o') ?? getFlag(args, '--output') ?? null;
|
|
92
|
+
const widthRaw = getFlag(args, '--width');
|
|
93
|
+
const heightRaw = getFlag(args, '--height');
|
|
94
|
+
const width = widthRaw ? parseStrictInt(widthRaw, '--width') : undefined;
|
|
95
|
+
const height = heightRaw ? parseStrictInt(heightRaw, '--height') : undefined;
|
|
96
|
+
return { action: 'panel_screenshot', params: { tabId, ...(output ? { _output: output } : {}), ...(width ? { width } : {}), ...(height ? { height } : {}) }, session, json };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (command === 'raw') {
|
|
100
|
+
const rawArg = args.shift();
|
|
101
|
+
if (!rawArg) throw new Error('raw requires a JSON string argument');
|
|
102
|
+
const cmd = JSON.parse(rawArg);
|
|
103
|
+
// Reason: store action='raw' as a sentinel so run() can send the full cmd envelope
|
|
104
|
+
// verbatim, letting the user control every field (including action) without reconstruction
|
|
105
|
+
return { action: 'raw', params: cmd, session, json };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Reason: Tool commands are extracted to tool-commands.mjs to keep parseCliArgs
|
|
109
|
+
// and dassi.mjs within CLAUDE.md size limits (40 lines / 500 lines).
|
|
110
|
+
const toolParams = parseToolCommand(command, args, getFlag, consumeFlag);
|
|
111
|
+
if (toolParams) {
|
|
112
|
+
return { action: 'tool_exec', params: toolParams, session, json };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
throw new Error(`Unknown command: "${command}". Run: dassi --help`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Extracts --flag <value> from args array (mutates array, removes both tokens).
|
|
120
|
+
* @param {string[]} args
|
|
121
|
+
* @param {string} flag
|
|
122
|
+
* @returns {string | undefined}
|
|
123
|
+
*/
|
|
124
|
+
function getFlag(args, flag) {
|
|
125
|
+
const idx = args.indexOf(flag);
|
|
126
|
+
if (idx === -1) return undefined;
|
|
127
|
+
if (idx + 1 >= args.length || String(args[idx + 1]).startsWith('--')) {
|
|
128
|
+
throw new Error(`Flag ${flag} requires a value`);
|
|
129
|
+
}
|
|
130
|
+
const val = args[idx + 1];
|
|
131
|
+
args.splice(idx, 2);
|
|
132
|
+
return val;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Removes a boolean flag from args array (mutates). Returns true if it was present.
|
|
137
|
+
* @param {string[]} args
|
|
138
|
+
* @param {string} flag
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
function consumeFlag(args, flag) {
|
|
142
|
+
const idx = args.indexOf(flag);
|
|
143
|
+
if (idx === -1) return false;
|
|
144
|
+
args.splice(idx, 1);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Daemon management ────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Ensures the daemon is running for the given session.
|
|
152
|
+
* Spawns it as a detached background process if the PID file is missing or stale.
|
|
153
|
+
* @param {string} session
|
|
154
|
+
*/
|
|
155
|
+
export function ensureDaemonRunning(session) {
|
|
156
|
+
if (isDaemonRunning(session)) return;
|
|
157
|
+
|
|
158
|
+
// Reason: detached + unref means the daemon outlives the CLI process
|
|
159
|
+
const daemon = child_process.spawn(process.execPath, [DAEMON_SCRIPT], {
|
|
160
|
+
detached: true,
|
|
161
|
+
stdio: 'ignore',
|
|
162
|
+
env: { ...process.env, DASSI_SESSION: session },
|
|
163
|
+
});
|
|
164
|
+
daemon.unref();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Polls for the ready file until it appears or the timeout elapses.
|
|
169
|
+
* Resolves with the parsed ready payload.
|
|
170
|
+
* @param {string} readyFile
|
|
171
|
+
* @param {number} [timeoutMs]
|
|
172
|
+
* @returns {Promise<{ status: string; [key: string]: unknown }>}
|
|
173
|
+
*/
|
|
174
|
+
export function waitForReady(readyFile, timeoutMs = READY_TIMEOUT_MS) {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
const deadline = Date.now() + timeoutMs;
|
|
177
|
+
const check = () => {
|
|
178
|
+
if (fs.existsSync(readyFile)) {
|
|
179
|
+
try {
|
|
180
|
+
resolve(parseReadyPayload(fs.readFileSync(readyFile, 'utf8')));
|
|
181
|
+
return;
|
|
182
|
+
} catch { /* file may be partially written — keep polling */ }
|
|
183
|
+
}
|
|
184
|
+
if (Date.now() >= deadline) {
|
|
185
|
+
const seconds = Math.round(timeoutMs / 1000);
|
|
186
|
+
reject(new Error(`Timed out waiting for Dassi daemon to start (${seconds}s)`));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
setTimeout(check, READY_POLL_MS);
|
|
190
|
+
};
|
|
191
|
+
check();
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Connects to the daemon Unix socket, sends one NDJSON command, and reads the response.
|
|
197
|
+
* @param {string} socketPath - Unix socket path.
|
|
198
|
+
* @param {object} command - Command object to send (will be JSON-stringified).
|
|
199
|
+
* @returns {Promise<object>} Parsed response object.
|
|
200
|
+
*/
|
|
201
|
+
export function sendCommand(socketPath, command) {
|
|
202
|
+
return new Promise((resolve, reject) => {
|
|
203
|
+
const socket = net.createConnection(socketPath);
|
|
204
|
+
let buffer = '';
|
|
205
|
+
let settled = false;
|
|
206
|
+
|
|
207
|
+
// Reason: guard against double-settlement since 'close' fires after socket.destroy()
|
|
208
|
+
// in the normal data path, and we don't want the close handler to re-resolve
|
|
209
|
+
const settle = (fn, val) => {
|
|
210
|
+
if (settled) return;
|
|
211
|
+
settled = true;
|
|
212
|
+
fn(val);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
socket.on('connect', () => {
|
|
216
|
+
socket.write(JSON.stringify(command) + '\n');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
socket.on('data', (chunk) => {
|
|
220
|
+
buffer += chunk.toString();
|
|
221
|
+
const nl = buffer.indexOf('\n');
|
|
222
|
+
if (nl !== -1) {
|
|
223
|
+
const line = buffer.slice(0, nl);
|
|
224
|
+
socket.destroy();
|
|
225
|
+
try { settle(resolve, JSON.parse(line)); }
|
|
226
|
+
catch { settle(reject, new Error(`Invalid JSON from daemon: ${line}`)); }
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
socket.on('error', (err) => settle(reject, err));
|
|
231
|
+
|
|
232
|
+
socket.on('close', () => {
|
|
233
|
+
if (settled) return;
|
|
234
|
+
// Reason: attempt to parse whatever arrived before the socket closed unexpectedly
|
|
235
|
+
if (buffer.trim()) {
|
|
236
|
+
try { settle(resolve, JSON.parse(buffer.trim())); }
|
|
237
|
+
catch { settle(reject, new Error(`Invalid JSON from daemon: ${buffer.trim()}`)); }
|
|
238
|
+
} else {
|
|
239
|
+
settle(reject, new Error('Daemon closed connection without a response'));
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─── Login helpers ────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Handles the `needs_login` onboarding flow: opens the options page in the
|
|
249
|
+
* default browser, then polls the daemon socket until the user authenticates
|
|
250
|
+
* or the timeout elapses.
|
|
251
|
+
* @param {string} socketPath Unix socket path for the daemon
|
|
252
|
+
* @param {string | undefined} optionsUrl URL of the Dassi options page to open
|
|
253
|
+
* @returns {Promise<void>} Resolves on successful login; throws on timeout
|
|
254
|
+
*/
|
|
255
|
+
export async function waitForLogin(socketPath, optionsUrl) {
|
|
256
|
+
console.error(`⚠️ Dassi is installed but you're not signed in.\n Opening the Dassi settings page...\n`);
|
|
257
|
+
|
|
258
|
+
// Auto-open the options page — best-effort (package may not be installed)
|
|
259
|
+
try {
|
|
260
|
+
const { default: open } = await import('open');
|
|
261
|
+
if (optionsUrl) await open(String(optionsUrl));
|
|
262
|
+
} catch {
|
|
263
|
+
console.error(` Please open: ${optionsUrl}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Poll the daemon socket every LOGIN_POLL_MS until authenticated
|
|
267
|
+
console.error(' Waiting for sign-in... (Ctrl+C to cancel)');
|
|
268
|
+
const deadline = Date.now() + LOGIN_TIMEOUT_MS;
|
|
269
|
+
let pollIndex = 0;
|
|
270
|
+
while (Date.now() < deadline) {
|
|
271
|
+
await new Promise((r) => setTimeout(r, LOGIN_POLL_MS));
|
|
272
|
+
try {
|
|
273
|
+
// Reason: increment poll index so each status request has a unique id for tracing
|
|
274
|
+
const resp = await sendCommand(socketPath, { id: `poll-auth-${pollIndex++}`, action: 'status' });
|
|
275
|
+
if (resp.success && resp.data?.authenticated) {
|
|
276
|
+
console.error(` ✓ Signed in as ${resp.data.email}\n ✓ Ready\n`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
} catch { /* daemon may not be socket-ready yet — keep polling */ }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
throw new Error('Login timed out. Please sign in and try again.');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─── Immediate actions ────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
const HELP_TEXT =
|
|
288
|
+
'Usage: dassi [options] <command>\n\n' +
|
|
289
|
+
'Browser commands (each accepts --tab <id> | --group <id> | --group-title <name>):\n' +
|
|
290
|
+
' navigate <url> --tab <id> Navigate to URL\n' +
|
|
291
|
+
' click <ref> --tab <id> Click element by ref\n' +
|
|
292
|
+
' fill <ref> <text> --tab <id> Fill input with text (instant)\n' +
|
|
293
|
+
' type <ref> <text> --tab <id> Type text with keyboard events\n' +
|
|
294
|
+
' read-page --tab <id> Read page accessibility tree\n' +
|
|
295
|
+
' get-text --tab <id> Extract page text\n' +
|
|
296
|
+
' screenshot --tab <id> [-o file] Capture viewport screenshot\n' +
|
|
297
|
+
' panel-screenshot --tab <id> [-o f] [--width W] [--height H]\n' +
|
|
298
|
+
' Capture side panel UI (default 360x800)\n' +
|
|
299
|
+
' eval <code> --tab <id> Execute JavaScript\n' +
|
|
300
|
+
' tabs --tab <id> List tabs in same group\n' +
|
|
301
|
+
' open [url] --tab <id> Open new tab in group\n' +
|
|
302
|
+
' close --tab <id> Close tab\n\n' +
|
|
303
|
+
'Agent commands:\n' +
|
|
304
|
+
' run <prompt> --tab <id> | --group <id> | --group-title <name>\n' +
|
|
305
|
+
' Run AI agent on a tab or group (group = sequential)\n' +
|
|
306
|
+
' list-tabs List all open Chrome tabs\n' +
|
|
307
|
+
' list-groups List Chrome tab groups (with tab counts)\n' +
|
|
308
|
+
' status Check extension status\n' +
|
|
309
|
+
' bug-report [-o file] Export debug logs from all contexts\n' +
|
|
310
|
+
' raw <json> Send raw JSON command\n\n' +
|
|
311
|
+
'Options:\n' +
|
|
312
|
+
' --tab <id> Chrome tab ID (use list-tabs to find)\n' +
|
|
313
|
+
' --timeout <ms> Timeout for run command (default: 300000)\n' +
|
|
314
|
+
' --session <name> Daemon session name (default: "default")\n' +
|
|
315
|
+
' --json Output raw JSON\n' +
|
|
316
|
+
' --filter <type> Filter for read-page (interactive|all)\n' +
|
|
317
|
+
' --depth <n> Depth for read-page tree\n' +
|
|
318
|
+
' --await Await promise in eval\n' +
|
|
319
|
+
' -o, --output <f> Output file for screenshot\n' +
|
|
320
|
+
' --version Show version\n' +
|
|
321
|
+
' --help Show help';
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Handles --version and --help, which don't need a daemon. Returns true if handled.
|
|
325
|
+
* @param {string} action
|
|
326
|
+
* @returns {boolean}
|
|
327
|
+
*/
|
|
328
|
+
function handleImmediateAction(action) {
|
|
329
|
+
if (action === 'version') {
|
|
330
|
+
console.log(VERSION);
|
|
331
|
+
process.exit(0);
|
|
332
|
+
}
|
|
333
|
+
if (action === 'help') {
|
|
334
|
+
console.log(HELP_TEXT);
|
|
335
|
+
process.exit(0);
|
|
336
|
+
}
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ─── Daemon readiness ─────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Ensures the daemon is running and ready, handling onboarding if needed.
|
|
344
|
+
* @param {string} session
|
|
345
|
+
* @returns {Promise<string>} The daemon's Unix socket path.
|
|
346
|
+
*/
|
|
347
|
+
async function ensureDaemonReady(session) {
|
|
348
|
+
const socketPath = getSocketPath(session);
|
|
349
|
+
const readyFile = getReadyFile(session);
|
|
350
|
+
|
|
351
|
+
// Reason: delete any stale ready file from a previous run so the new daemon
|
|
352
|
+
// writes a fresh one. Skip if a daemon is already running to avoid racing.
|
|
353
|
+
if (!isDaemonRunning(session)) {
|
|
354
|
+
try { fs.unlinkSync(readyFile); } catch { /* ok if missing */ }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
ensureDaemonRunning(session);
|
|
358
|
+
|
|
359
|
+
const ready = await waitForReady(readyFile);
|
|
360
|
+
|
|
361
|
+
if (ready.status === 'extension_not_installed') {
|
|
362
|
+
console.error(
|
|
363
|
+
`❌ Dassi extension not detected.\n\n` +
|
|
364
|
+
` Install it from:\n ${CHROME_WEB_STORE_URL}\n\n` +
|
|
365
|
+
` Then run this command again.`
|
|
366
|
+
);
|
|
367
|
+
process.exit(1);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (ready.status === 'needs_login') {
|
|
371
|
+
await waitForLogin(socketPath, ready.optionsUrl ? String(ready.optionsUrl) : undefined);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return socketPath;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ─── Entry point ──────────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Main CLI entry point. Parses args, ensures daemon is running, handles
|
|
381
|
+
* onboarding, sends command, prints response.
|
|
382
|
+
* @returns {Promise<void>}
|
|
383
|
+
*/
|
|
384
|
+
export async function run() {
|
|
385
|
+
let parsed;
|
|
386
|
+
try {
|
|
387
|
+
parsed = parseCliArgs(process.argv.slice(2));
|
|
388
|
+
} catch (err) {
|
|
389
|
+
console.error(err.message);
|
|
390
|
+
process.exit(1);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const { action, params, session, json } = parsed;
|
|
394
|
+
handleImmediateAction(action);
|
|
395
|
+
|
|
396
|
+
const socketPath = await ensureDaemonReady(session);
|
|
397
|
+
|
|
398
|
+
const id = `cli_${Date.now()}`;
|
|
399
|
+
|
|
400
|
+
// Group expansion: if action=run or action=tool_exec with groupId/groupTitle, expand and fan out sequentially
|
|
401
|
+
if (
|
|
402
|
+
(action === 'run' || action === 'tool_exec') &&
|
|
403
|
+
(params.groupId !== undefined || params.groupTitle !== undefined)
|
|
404
|
+
) {
|
|
405
|
+
const allOk = await runWithGroupExpansion(socketPath, action, params, json, sendCommand, formatResponse);
|
|
406
|
+
if (!allOk) process.exit(1);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Reason: for 'raw' the user controls the full envelope — send params verbatim
|
|
411
|
+
const command = action === 'raw' ? { id, ...params } : { id, action, ...params };
|
|
412
|
+
const response = await sendCommand(socketPath, command);
|
|
413
|
+
|
|
414
|
+
console.log(formatResponse(action, response, json, params));
|
|
415
|
+
if (!response.success) process.exit(1);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── Entry point guard ─────────────────────────────────────────────────────────
|
|
419
|
+
// Reason: guard allows this file to be imported by tests without running the CLI
|
|
420
|
+
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
421
|
+
run().catch((err) => {
|
|
422
|
+
console.error(`❌ ${err.message}`);
|
|
423
|
+
process.exit(1);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response formatting for the Dassi CLI.
|
|
3
|
+
* Converts daemon responses into human-readable terminal output.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
|
|
8
|
+
/** @param {{ success: boolean; data?: unknown }} response */
|
|
9
|
+
function formatListTabs(response) {
|
|
10
|
+
const tabs = /** @type {Array<{tabId:number;title:string;url:string;active:boolean}>} */ (response.data ?? []);
|
|
11
|
+
if (tabs.length === 0) return '(no open tabs)';
|
|
12
|
+
const header = 'TAB ID TITLE URL';
|
|
13
|
+
const rows = tabs.map((t) => {
|
|
14
|
+
const id = String(t.tabId).padEnd(7);
|
|
15
|
+
const prefix = t.active ? '* ' : ' ';
|
|
16
|
+
const title = (prefix + t.title).slice(0, 42).padEnd(42);
|
|
17
|
+
return `${id} ${title} ${t.url}`;
|
|
18
|
+
});
|
|
19
|
+
return [header, ...rows].join('\n');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @param {{ success: boolean; data?: unknown }} response */
|
|
23
|
+
function formatListGroups(response) {
|
|
24
|
+
const groups = /** @type {Array<{id:number;title:string;color:string;windowId:number;tabCount:number}>} */ (response.data ?? []);
|
|
25
|
+
if (groups.length === 0) return '(no tab groups)';
|
|
26
|
+
const header = 'GROUP ID COLOR TABS WINDOW TITLE';
|
|
27
|
+
const rows = groups.map((g) => {
|
|
28
|
+
const id = String(g.id).padEnd(9);
|
|
29
|
+
const color = (g.color ?? '').padEnd(8);
|
|
30
|
+
const tabs = String(g.tabCount).padEnd(6);
|
|
31
|
+
const win = String(g.windowId).padEnd(8);
|
|
32
|
+
return `${id} ${color} ${tabs} ${win} ${g.title || '(untitled)'}`;
|
|
33
|
+
});
|
|
34
|
+
return [header, ...rows].join('\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @param {{ success: boolean; data?: unknown }} response */
|
|
38
|
+
function formatRun(response) {
|
|
39
|
+
const d = /** @type {{ answer?: string; toolCalls?: number; durationMs?: number }} */ (response.data ?? {});
|
|
40
|
+
const footer = `\n(${d.toolCalls ?? 0} tool calls, ${((d.durationMs ?? 0) / 1000).toFixed(1)}s)`;
|
|
41
|
+
return (d.answer ?? '') + footer;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Formats tool_exec and panel_screenshot results.
|
|
46
|
+
* Tool results from pi-agent-core have a { content: [{type, text/data}] } shape.
|
|
47
|
+
* @param {{ success: boolean; data?: unknown }} response
|
|
48
|
+
* @param {Record<string, unknown>} [params]
|
|
49
|
+
* @param {string} action
|
|
50
|
+
*/
|
|
51
|
+
function formatToolExec(response, params, action) {
|
|
52
|
+
const data = response.data;
|
|
53
|
+
if (data && typeof data === 'object' && Array.isArray(data.content)) {
|
|
54
|
+
const defaultPrefix = action === 'panel_screenshot' ? 'panel-screenshot' : 'screenshot';
|
|
55
|
+
const parts = [];
|
|
56
|
+
for (const item of data.content) {
|
|
57
|
+
if (item.type === 'text') {
|
|
58
|
+
parts.push(item.text);
|
|
59
|
+
} else if (item.type === 'image' && item.data) {
|
|
60
|
+
// Reason: Save image to file like agent-browser does. Use _output param if provided,
|
|
61
|
+
// otherwise generate a timestamped filename.
|
|
62
|
+
const ext = item.mimeType === 'image/png' ? 'png' : 'jpg';
|
|
63
|
+
const outputPath = params?._output ?? `${defaultPrefix}-${Date.now()}.${ext}`;
|
|
64
|
+
try {
|
|
65
|
+
fs.writeFileSync(outputPath, Buffer.from(item.data, 'base64'));
|
|
66
|
+
parts.push(`Saved ${outputPath}`);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
parts.push(`[Failed to save image: ${err.message}]`);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
parts.push(`[${item.type ?? 'unknown'} content omitted]`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return parts.join('\n');
|
|
75
|
+
}
|
|
76
|
+
return JSON.stringify(data, null, 2);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {{ success: boolean; data?: unknown }} response
|
|
81
|
+
* @param {Record<string, unknown>} [params]
|
|
82
|
+
*/
|
|
83
|
+
function formatExportLogs(response, params) {
|
|
84
|
+
const data = response.data;
|
|
85
|
+
const logsJson = JSON.stringify(data, null, 2);
|
|
86
|
+
const outputPath = params?.output ?? `dassi-debug-logs-${Date.now()}.json`;
|
|
87
|
+
try {
|
|
88
|
+
fs.writeFileSync(outputPath, logsJson);
|
|
89
|
+
const sources = data?.sources ?? {};
|
|
90
|
+
const total = data?.totalEntries ?? '?';
|
|
91
|
+
const summary = Object.entries(sources).map(([k, v]) => `${k}=${v}`).join(', ');
|
|
92
|
+
return `✓ Saved ${total} log entries to ${outputPath}\n Sources: ${summary}`;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return `❌ Failed to save: ${err.message}\n${logsJson}`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {{ success: boolean; data?: unknown }} response */
|
|
99
|
+
function formatStatus(response) {
|
|
100
|
+
const d = /** @type {{ authenticated?: boolean; email?: string | null }} */ (response.data ?? {});
|
|
101
|
+
if (d.authenticated) {
|
|
102
|
+
return `✓ Signed in as ${d.email}`;
|
|
103
|
+
}
|
|
104
|
+
return '✗ Not signed in';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Reason: dispatch table keeps formatResponse small (~10 lines) and lets each
|
|
108
|
+
// action's formatting logic live in a focused, ≤40-line helper.
|
|
109
|
+
const FORMATTERS = {
|
|
110
|
+
list_tabs: (r, _p, _a) => formatListTabs(r),
|
|
111
|
+
list_groups: (r, _p, _a) => formatListGroups(r),
|
|
112
|
+
run: (r, _p, _a) => formatRun(r),
|
|
113
|
+
tool_exec: (r, p, a) => formatToolExec(r, p, a),
|
|
114
|
+
panel_screenshot: (r, p, a) => formatToolExec(r, p, a),
|
|
115
|
+
export_logs: (r, p, _a) => formatExportLogs(r, p),
|
|
116
|
+
status: (r, _p, _a) => formatStatus(r),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Formats a daemon response for terminal output.
|
|
121
|
+
* @param {string} action
|
|
122
|
+
* @param {{ success: boolean; data?: unknown; error?: string }} response
|
|
123
|
+
* @param {boolean} rawJson When true, outputs the raw JSON response
|
|
124
|
+
* @param {Record<string, unknown>} [params] Original command params (used for screenshot output path)
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
export function formatResponse(action, response, rawJson, params) {
|
|
128
|
+
if (rawJson) return JSON.stringify(response);
|
|
129
|
+
if (!response.success) return `❌ Error: ${response.error ?? 'Unknown error'}`;
|
|
130
|
+
const formatter = FORMATTERS[action];
|
|
131
|
+
if (formatter) return formatter(response, params, action);
|
|
132
|
+
// Default: pretty-print data
|
|
133
|
+
return JSON.stringify(response.data, null, 2);
|
|
134
|
+
}
|