@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/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
+ }