@dassi_ai/cli 0.5.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/dassi.mjs CHANGED
@@ -7,11 +7,12 @@
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
12
  import { getSocketPath, validateSession, getAppDir, getLaunchesFile } from './dassi-shared.mjs';
12
- import { ensureDaemonRunning, sendCommand, ensureDaemonReady } from './daemon-client.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';
@@ -19,22 +20,17 @@ import { HELP_TEXT } from './help-text.mjs';
19
20
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
21
  const VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')).version;
21
22
 
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
-
27
23
  // ─── Arg parsing ──────────────────────────────────────────────────────────────
28
24
 
29
25
  /**
30
26
  * Parses CLI arguments into a command descriptor.
31
- * Supports browser tool commands (navigate, click, fill, type, read-page, etc.),
27
+ * Supports live tool discovery and generic browser calls,
32
28
  * agent commands (run, list-tabs, status, raw), and top-level flags (--version, --help).
33
29
  * Run `dassi --help` for the full command reference.
34
30
  * @param {string[]} argv process.argv.slice(2)
35
31
  * @returns {{ action: string; params: Record<string, unknown>; session: string; json: boolean; profile: string | null }}
36
32
  */
37
- export function parseCliArgs(argv) {
33
+ export function parseCliArgs(argv, { interactive = false } = {}) {
38
34
  const args = [...argv];
39
35
 
40
36
  // Reason: hoist the `--` split to BEFORE global flag parsing so that Chrome
@@ -50,10 +46,10 @@ export function parseCliArgs(argv) {
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: false, profile: null };
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: false, profile: null };
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,10 +58,15 @@ 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) => ({ action, params, session, json, profile });
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
+ };
66
67
 
67
- const command = args.shift();
68
- if (!command) throw new Error('No command specified. Run: dassi --help');
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.');
69
70
 
70
71
  // Reason: chromeArgs (tokens after `--`) are only meaningful for the `launch`
71
72
  // command. Any other command that supplies `--` tokens would have them silently
@@ -74,18 +75,34 @@ export function parseCliArgs(argv) {
74
75
  throw new Error("'--' is only valid with the launch command");
75
76
  }
76
77
 
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
+ }
89
+
77
90
  if (command === 'list-tabs') {
78
- const all = consumeFlag(args, '--all');
79
- return finish('list_tabs', all ? { all: true } : {});
91
+ consumeFlag(args, '--all');
92
+ return finish('list_tabs', { all: true });
80
93
  }
81
94
 
82
95
  if (command === 'list-groups') {
83
- const all = consumeFlag(args, '--all');
84
- return finish('list_groups', all ? { all: true } : {});
96
+ consumeFlag(args, '--all');
97
+ return finish('list_groups', { all: true });
85
98
  }
86
99
 
87
- if (command === 'status') {
88
- return finish('status', {});
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) } : {}) } : {});
89
106
  }
90
107
 
91
108
  if (command === 'list-profiles') {
@@ -140,10 +157,13 @@ export function parseCliArgs(argv) {
140
157
  if (command === 'run') {
141
158
  const prompt = args.shift();
142
159
  if (!prompt) throw new Error('run requires a prompt argument');
143
- const target = requireTarget('run', args, getFlag);
144
- const timeoutRaw = getFlag(args, '--timeout');
145
- const timeoutMs = timeoutRaw ? parseStrictInt(timeoutRaw, '--timeout') : undefined;
146
- const base = { prompt, ...(timeoutMs !== undefined ? { timeoutMs } : {}) };
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 } : {}) };
147
167
  return finish('run', { ...base, ...target });
148
168
  }
149
169
 
@@ -155,13 +175,13 @@ export function parseCliArgs(argv) {
155
175
  if (command === 'panel-screenshot') {
156
176
  const tabRaw = getFlag(args, '--tab');
157
177
  if (!tabRaw) throw new Error('panel-screenshot requires --tab <tabId>');
158
- const tabId = parseStrictInt(tabRaw, '--tab');
178
+ const { id: tabId, ...target } = parseTarget(tabRaw, '--tab');
159
179
  const output = getFlag(args, '-o') ?? getFlag(args, '--output') ?? null;
160
180
  const widthRaw = getFlag(args, '--width');
161
181
  const heightRaw = getFlag(args, '--height');
162
182
  const width = widthRaw ? parseStrictInt(widthRaw, '--width') : undefined;
163
183
  const height = heightRaw ? parseStrictInt(heightRaw, '--height') : undefined;
164
- 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 } : {}) });
165
185
  }
166
186
 
167
187
  if (command === 'raw') {
@@ -173,14 +193,18 @@ export function parseCliArgs(argv) {
173
193
  return finish('raw', cmd);
174
194
  }
175
195
 
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
196
  const toolParams = parseToolCommand(command, args, getFlag, consumeFlag);
179
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
+ }
180
204
  return finish('tool_exec', toolParams);
181
205
  }
182
206
 
183
- 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`);
184
208
  }
185
209
 
186
210
  /**
@@ -217,56 +241,74 @@ function consumeFlag(args, flag) {
217
241
 
218
242
  // HELP_TEXT lives in ./help-text.mjs (extracted to keep this file within the size limit).
219
243
 
220
- /**
221
- * Handles --version and --help, which don't need a daemon. Returns true if handled.
222
- * @param {string} action
223
- * @returns {boolean}
224
- */
225
- function handleImmediateAction(action) {
226
- if (action === 'version') {
227
- console.log(VERSION);
228
- process.exit(0);
229
- }
230
- if (action === 'help') {
231
- console.log(HELP_TEXT);
232
- process.exit(0);
233
- }
234
- return false;
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;
235
254
  }
236
255
 
237
256
  // ─── Entry point ──────────────────────────────────────────────────────────────
238
257
 
239
258
  /**
240
- * Main CLI entry point. Parses args, ensures daemon is running, handles
241
- * onboarding, sends command, prints response.
259
+ * Parses arguments, connects to the daemon, and prints the command result.
242
260
  * @returns {Promise<void>}
243
261
  */
244
- export async function run() {
262
+ export async function run(argv = process.argv.slice(2)) {
245
263
  let parsed;
246
264
  try {
247
- parsed = parseCliArgs(process.argv.slice(2));
265
+ parsed = parseCliArgs(argv, { interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY) });
266
+ await execute(parsed);
248
267
  } catch (err) {
249
- console.error(err.message);
250
- process.exit(1);
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;
251
274
  }
275
+ }
252
276
 
277
+ async function execute(parsed) {
253
278
  const { action, params, session, json, profile } = parsed;
254
- handleImmediateAction(action);
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
+ }
255
297
 
256
298
  // Launch family (launch / --stop / __launch-hold) → launch.mjs.
257
299
  if (await dispatchLaunch(action, params, { spawn: child_process.spawn, ensureDaemonRunning, getSocketPath, sendCommand, appDir: getAppDir(), launchesFile: getLaunchesFile() })) return;
258
300
 
259
301
  const socketPath = await ensureDaemonReady(session);
260
302
 
261
- const id = `cli_${Date.now()}`;
303
+ const id = `cli_${randomUUID()}`;
262
304
 
263
305
  // Group expansion: if action=run or action=tool_exec with groupId/groupTitle, expand and fan out sequentially
264
306
  if (
265
307
  (action === 'run' || action === 'tool_exec') &&
266
308
  (params.groupId !== undefined || params.groupTitle !== undefined)
267
309
  ) {
268
- const allOk = await runWithGroupExpansion(socketPath, action, params, json, sendCommand, formatResponse, profile);
269
- if (!allOk) process.exit(1);
310
+ const allOk = await runWithGroupExpansion(socketPath, action, params, json, sendAndWait, formatResponse, profile);
311
+ if (!allOk) process.exitCode = 1;
270
312
  return;
271
313
  }
272
314
 
@@ -275,10 +317,10 @@ export async function run() {
275
317
  const command = action === 'raw'
276
318
  ? { id, ...params }
277
319
  : { id, action, ...params, ...(profile ? { target: profile } : {}) };
278
- const response = await sendCommand(socketPath, command);
320
+ const response = await sendAndWait(socketPath, command);
279
321
 
280
322
  console.log(formatResponse(action, response, json, params));
281
- if (!response.success) process.exit(1);
323
+ if (!response.success || (command.action !== 'task_stop' && ['failed', 'stopped'].includes(response.data?.status))) process.exitCode = 1;
282
324
  }
283
325
 
284
326
  // ── Entry point guard ─────────────────────────────────────────────────────────
@@ -309,8 +351,5 @@ export function isMainModule(metaUrl, argvPath) {
309
351
  }
310
352
 
311
353
  if (isMainModule(import.meta.url, process.argv[1])) {
312
- run().catch((err) => {
313
- console.error(`❌ ${err.message}`);
314
- process.exit(1);
315
- });
354
+ await run();
316
355
  }
@@ -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 = 'TAB ID TITLE URL';
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 = 'GROUP ID COLOR TABS WINDOW TITLE';
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 = /** @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;
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 PORT';
103
- const body = rows.map((r) => `${String(r.label).padEnd(32)} ${r.port}`);
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) return formatter(response, params, action);
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
  }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import * as path from 'path';
8
+ import { randomUUID } from 'node:crypto';
8
9
 
9
10
  /**
10
11
  * Inject `tabId` into a filename before the extension so multi-tab fan-out
@@ -52,8 +53,9 @@ export async function expandGroupToTabIds(socketPath, ref, sendFn, target = null
52
53
  // `{ id, action, ...rest } = cmd` and forwards `rest` as JSON-RPC params,
53
54
  // so nesting under `params` would arrive at the extension as
54
55
  // `params.params.all` — and the filter would NOT bypass.
55
- const groupsResp = await sendFn(socketPath, { id: `cli_lg_${Date.now()}`, action: 'list_groups', all: true, ...targetField });
56
+ const groupsResp = await sendFn(socketPath, { id: `cli_lg_${randomUUID()}`, action: 'list_groups', all: true, ...targetField });
56
57
  if (!groupsResp.success) throw new Error(`Failed to list groups: ${groupsResp.error ?? 'unknown'}`);
58
+ if (!target && groupsResp.profiles?.length > 1) throw new Error('Choose a profile with --profile <id>, or copy a group target from dassi list-groups.');
57
59
  const groups = /** @type {Array<{id:number;title:string;windowId:number}>} */ (groupsResp.data ?? []);
58
60
 
59
61
  let groupId;
@@ -74,7 +76,7 @@ export async function expandGroupToTabIds(socketPath, ref, sendFn, target = null
74
76
  throw new Error('expandGroupToTabIds: pass groupId or groupTitle');
75
77
  }
76
78
 
77
- const tabsResp = await sendFn(socketPath, { id: `cli_lt_${Date.now()}`, action: 'list_tabs', all: true, ...targetField });
79
+ const tabsResp = await sendFn(socketPath, { id: `cli_lt_${randomUUID()}`, action: 'list_tabs', all: true, ...targetField });
78
80
  if (!tabsResp.success) throw new Error(`Failed to list tabs: ${tabsResp.error ?? 'unknown'}`);
79
81
  const tabs = /** @type {Array<{tabId:number;groupId:number}>} */ (tabsResp.data ?? []);
80
82
  const memberIds = tabs.filter((t) => t.groupId === groupId).map((t) => t.tabId);
@@ -113,7 +115,7 @@ async function dispatchChildForTab(socketPath, action, childBase, tabId, multiTa
113
115
  multiTab && childBase._output ? uniquifyOutputForTab(childBase._output, tabId) : childBase._output;
114
116
  const childParams = { ...childBase, tabId, ...(perTabOutput !== childBase._output ? { _output: perTabOutput } : {}) };
115
117
  // Reason: include target only when set so the fan-out routes to the chosen profile.
116
- const response = await sendFn(socketPath, { id: `cli_${Date.now()}_${tabId}`, action, ...childParams, ...(target ? { target } : {}) });
118
+ const response = await sendFn(socketPath, { id: `cli_${randomUUID()}`, action, ...childParams, ...(target ? { target } : {}) });
117
119
  return { tabId, response, childParams };
118
120
  }
119
121
 
@@ -144,6 +146,13 @@ export async function runWithGroupExpansion(socketPath, action, params, json, se
144
146
  sendFn,
145
147
  target,
146
148
  );
149
+ // One group is one conversation: submit an agent prompt once for the whole workspace.
150
+ if (action === 'run') {
151
+ const { groupId: _group, groupTitle: _title, ...base } = params;
152
+ const { response } = await dispatchChildForTab(socketPath, action, base, tabIds[0], false, sendFn, target);
153
+ console.log(formatFn(action, response, json, base));
154
+ return response.success && !['failed', 'stopped'].includes(response.data?.status);
155
+ }
147
156
  // Reason: always on stderr so it doesn't pollute JSON output on stdout
148
157
  console.error(`Running on ${tabIds.length} tab${tabIds.length === 1 ? '' : 's'}: ${tabIds.join(', ')}`);
149
158
  // Strip group fields; substitute tabId per child call
package/help-text.mjs CHANGED
@@ -1,48 +1,59 @@
1
- /**
2
- * `dassi --help` output. Extracted from dassi.mjs to keep that file within the
3
- * CLAUDE.md size limit; this is pure presentation with no runtime dependencies.
4
- */
5
- export const HELP_TEXT =
6
- 'Usage: dassi [options] <command>\n\n' +
7
- 'Browser commands (each accepts --tab <id> | --group <id> | --group-title <name>):\n' +
8
- ' navigate <url> --tab <id> Navigate to URL\n' +
9
- ' click <ref> --tab <id> Click element by ref\n' +
10
- ' fill <ref> <text> --tab <id> Fill input with text (instant)\n' +
11
- ' type <ref> <text> --tab <id> Type text with keyboard events\n' +
12
- ' read-page --tab <id> Read page accessibility tree\n' +
13
- ' get-text --tab <id> Extract page text\n' +
14
- ' screenshot --tab <id> [-o file] Capture viewport screenshot\n' +
15
- ' panel-screenshot --tab <id> [-o f] [--width W] [--height H]\n' +
16
- ' Capture side panel UI (default 360x800)\n' +
17
- ' eval <code> --tab <id> Execute JavaScript\n' +
18
- ' tabs --tab <id> List tabs in same group\n' +
19
- ' open [url] --tab <id> Open new tab in group\n' +
20
- ' close --tab <id> Close tab\n\n' +
21
- 'Agent commands:\n' +
22
- ' run <prompt> --tab <id> | --group <id> | --group-title <name>\n' +
23
- ' Run AI agent on a tab or group (group = sequential)\n' +
24
- ' list-tabs [--all] List tabs in groups dassi has open (--all = every Chrome tab)\n' +
25
- ' list-groups [--all] List dassi-open tab groups (--all = every Chrome group)\n' +
26
- ' list-profiles List connected profiles (Chrome instances)\n' +
27
- ' status Check extension status\n' +
28
- ' bug-report [-o file] Export debug logs from all contexts\n' +
29
- ' raw <json> Send raw JSON command\n' +
30
- ' launch [--label <name>] [--dist <path>] [--chrome <path>]\n' +
31
- ' [--load-mode auto|pipe|flag] [-- <chrome args…>]\n' +
32
- ' Open Chrome with a dev dist loaded\n' +
33
- ' (args after -- go to Chrome, e.g. --headless=new)\n' +
34
- ' launch --stop [label] | --stop-all\n' +
35
- ' Stop a launched Chrome\n\n' +
36
- 'Options:\n' +
37
- ' --tab <id> Chrome tab ID (use list-tabs to find)\n' +
38
- ' --all list-tabs/list-groups: include every Chrome tab/group\n' +
39
- ' --timeout <ms> Timeout for run command (default: 300000)\n' +
40
- ' --session <name> Daemon session name (default: "default")\n' +
41
- ' --profile <label> Target a specific connected profile (alias: --label)\n' +
42
- ' --json Output raw JSON\n' +
43
- ' --filter <type> Filter for read-page (interactive|all)\n' +
44
- ' --depth <n> Depth for read-page tree\n' +
45
- ' --await Await promise in eval\n' +
46
- ' -o, --output <f> Output file for screenshot\n' +
47
- ' --version Show version\n' +
48
- ' --help Show help';
1
+ export const HELP_TEXT = `Usage: dassi [command] [options]
2
+
3
+ Start here:
4
+ dassi Start setup in an interactive terminal
5
+ setup Install agent skills and connect your Chrome profile
6
+ skill Read the local-agent workflow (no browser needed)
7
+ list-tabs Show tabs across connected profiles; copy a TARGET
8
+ run <prompt> --tab <target> Start a new task and return its ID
9
+ run <prompt> --task <task-id> Continue that conversation (queues a follow-up if busy)
10
+ status <task-id> Show progress or the finished result
11
+ stop <task-id> Stop that task
12
+
13
+ Options:
14
+ --wait <duration> Wait for a result (30s, 10m, 1h); never stops the task
15
+ --profile <id-or-name> Choose a profile for unscoped tab/group IDs
16
+ --json Machine-readable output
17
+ --help Show help
18
+ --version Show version
19
+
20
+ Agent setup:
21
+ setup [--profile id] Resume setup for the chosen profile
22
+ setup --wait 0 --json Check setup immediately; report any user action needed
23
+ setup --no-open Print browser instructions without opening Chrome
24
+ skill --path Print the bundled skill directory for installation
25
+ skill --remove Remove Dassi's registered agent skill links
26
+
27
+ Update / remove:
28
+ npm install -g @dassi_ai/cli@latest Update the CLI and its bundled agent skill
29
+ dassi skill --remove Unregister the agent skill
30
+ npm uninstall -g @dassi_ai/cli Remove the npm package
31
+
32
+ Browser tools:
33
+ tools [name] --tab <target> Discover current tools or read one tool's schema
34
+ call <name> --tab <target> Execute a discovered tool with --args '<json>'
35
+ call ... [-o file] Save returned images (omit --json to write files)
36
+ panel-screenshot [-o file] Capture Dassi (--tab, --width, --height)
37
+ open [url] --window <id> Create an ungrouped tab (--foreground to activate)
38
+
39
+ Other commands:
40
+ list-profiles Show profile IDs, labels, and sign-in status
41
+ list-groups Show groups with profile-bound targets
42
+ status Show connected profiles and their status
43
+ bug-report [-o file] Export diagnostic logs
44
+ raw <json> Send a bridge command
45
+
46
+ Groups:
47
+ run <prompt> --group <target> Start one task for the whole group
48
+ --group-title <name> Select a group by name (use --profile if needed)
49
+ call also accepts group targets and executes once per member tab.
50
+
51
+ Development:
52
+ launch [--label name] [--dist path] [--chrome path]
53
+ [--load-mode auto|pipe|flag] [-- <chrome args>]
54
+ launch --stop [label] | --stop-all
55
+
56
+ Browser aliases such as read-page and click have been replaced by tools and call.
57
+ run rejects the old --timeout flag. Use --wait for local waiting; stop to cancel.
58
+ A wait ending or terminal disconnecting does not cancel browser work.
59
+ `;
package/launch.mjs CHANGED
@@ -255,7 +255,7 @@ export async function handleLaunch(opts, deps) {
255
255
  if (pid === null) return exit(1);
256
256
 
257
257
  recordLaunch(launchesFile, { label: opts.label, pid, profileDir, mode: useFlag ? 'flag' : 'pipe', startedAt: Date.now() });
258
- log(`✅ launched "${opts.label}" (pid ${pid}) — drive it with: dassi run "…" --profile ${opts.label}`);
258
+ log(`✅ launched "${opts.label}" (pid ${pid}) — find targets with: dassi list-tabs --profile ${opts.label}`);
259
259
  }
260
260
 
261
261
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dassi_ai/cli",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "CLI for the Dassi Chrome extension \u2014 run browser automation from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "group-expansion.mjs",
17
17
  "launch.mjs",
18
18
  "_launch-pipe.mjs",
19
+ "setup.mjs",
19
20
  "help-text.mjs",
20
21
  ".claude-plugin/",
21
22
  "skills/",
@@ -32,7 +33,6 @@
32
33
  "email": "team@dassi.ai"
33
34
  },
34
35
  "dependencies": {
35
- "open": "^10.1.0",
36
36
  "ws": "^8.18.0"
37
37
  },
38
38
  "devDependencies": {