@dassi_ai/cli 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # dassi-cli
1
+ # @dassi_ai/cli
2
2
 
3
3
  Standalone CLI for the [Dassi](../extension/README.md) Chrome extension — run browser automation from the terminal.
4
4
 
@@ -6,10 +6,10 @@ Standalone CLI for the [Dassi](../extension/README.md) Chrome extension — run
6
6
 
7
7
  ```bash
8
8
  # Zero-install
9
- npx dassi-cli --help
9
+ npx @dassi_ai/cli --help
10
10
 
11
11
  # Or install globally
12
- npm install -g dassi-cli
12
+ npm install -g @dassi_ai/cli
13
13
  ```
14
14
 
15
15
  Or, for local development from a clone of this repo:
@@ -72,7 +72,7 @@ When `--group`/`--group-title` is used, the CLI expands to member tab ids and ru
72
72
 
73
73
  ## Claude Code Plugin
74
74
 
75
- This package also ships as a Claude Code plugin under the `dassi` namespace. After installation (via either `npm install -g dassi-cli` or `npm link` from this directory), Claude Code auto-discovers two skills:
75
+ This package also ships as a Claude Code plugin under the `dassi` namespace. After installation (via either `npm install -g @dassi_ai/cli` or `npm link` from this directory), Claude Code auto-discovers two skills:
76
76
 
77
77
  - **`dassi:pick-tabs`** — a reusable tab/group picker. Lists open tabs and Chrome tab groups, asks the user to pick, returns the selected Chrome tab IDs.
78
78
  - **`dassi:operate`** — main entry point. Translates natural-language browser asks ("summarize my Research group", "screenshot the active tab", etc.) into `dassi` CLI invocations.
package/dassi-daemon.mjs CHANGED
@@ -238,6 +238,37 @@ async function dispatchToExtension(cmd) {
238
238
  });
239
239
  }
240
240
 
241
+ /**
242
+ * Refresh the ready file when the latest dispatched command was a `status` query.
243
+ *
244
+ * Reason: The daemon previously wrote the ready file exactly once on startup.
245
+ * When the user wasn't signed in at that moment, the file stayed `needs_login`
246
+ * even after waitForLogin's polling confirmed authentication via socket — so
247
+ * every subsequent CLI invocation re-read the stale file and re-triggered the
248
+ * login flow. Refreshing on every `status` response — which waitForLogin's
249
+ * poll loop drives naturally — converges the file to the truth without needing
250
+ * a separate signal channel.
251
+ *
252
+ * @param {string} readyFile - Absolute path to the daemon's ready file.
253
+ * @param {{ action?: string }} cmd - The command that was dispatched.
254
+ * @param {unknown} result - The dispatch result (extension's status payload).
255
+ * @returns {void}
256
+ */
257
+ export function refreshReadyFileFromStatusResult(readyFile, cmd, result) {
258
+ if (cmd?.action !== 'status') return;
259
+ if (!result || typeof result !== 'object') return;
260
+ try {
261
+ fs.writeFileSync(readyFile, JSON.stringify(buildReadyPayload(result)), { mode: 0o600 });
262
+ // Reason: writeFileSync's `mode` is ignored when the file already exists.
263
+ // chmod explicitly so installs with a pre-hardening 0o644 ready file get
264
+ // tightened. The file contains the signed-in email — owner-only.
265
+ try { fs.chmodSync(readyFile, 0o600); } catch { /* best-effort */ }
266
+ } catch {
267
+ // Reason: best-effort sync; don't fail the command if the FS write itself
268
+ // races (e.g. file permission flake). The next status query will retry.
269
+ }
270
+ }
271
+
241
272
  // ─── Daemon setup helpers ─────────────────────────────────────────────────────
242
273
 
243
274
  /**
@@ -251,8 +282,17 @@ function initDaemonProcess(session) {
251
282
  const readyFile = getReadyFile(session);
252
283
 
253
284
  fs.mkdirSync(appDir, { recursive: true, mode: 0o700 });
285
+ // Reason: mkdirSync's `mode` is ignored when the directory already exists.
286
+ // Normalize perms on every start so installs created before the mode arg
287
+ // was added (or with a permissive umask) get tightened to owner-only.
288
+ // The socket and ready/pid files live here; loose perms would let other
289
+ // local users connect to the socket and dispatch CLI commands.
290
+ try { fs.chmodSync(appDir, 0o700); } catch { /* best-effort */ }
254
291
  cleanupDaemonFiles(session);
255
- fs.writeFileSync(getPidFile(session), String(process.pid));
292
+ const pidFile = getPidFile(session);
293
+ fs.writeFileSync(pidFile, String(process.pid), { mode: 0o600 });
294
+ // Reason: see refreshReadyFileFromStatusResult — chmod normalizes pre-existing files.
295
+ try { fs.chmodSync(pidFile, 0o600); } catch { /* best-effort */ }
256
296
 
257
297
  const shutdown = () => {
258
298
  cleanupDaemonFiles(session);
@@ -301,7 +341,13 @@ function createSocketServer(queue, socketPath) {
301
341
  socket.on('error', () => { /* ignore client disconnects */ });
302
342
  });
303
343
 
304
- server.listen(socketPath);
344
+ // Reason: tighten the Unix socket to owner-only so other local users on a
345
+ // multi-user system can't connect and dispatch CLI commands. chmod has to
346
+ // happen after the socket file is actually created — listen()'s callback
347
+ // fires on the 'listening' event, by which point the inode exists.
348
+ server.listen(socketPath, () => {
349
+ try { fs.chmodSync(socketPath, 0o600); } catch { /* best-effort */ }
350
+ });
305
351
  return server;
306
352
  }
307
353
 
@@ -341,7 +387,8 @@ export async function startDaemon() {
341
387
  } catch {
342
388
  // Reason: write the ready file even on failure so the CLI can read the error state,
343
389
  // then exit so the WSS doesn't keep the process alive as a zombie.
344
- fs.writeFileSync(readyFile, JSON.stringify({ status: 'extension_not_installed' }));
390
+ fs.writeFileSync(readyFile, JSON.stringify({ status: 'extension_not_installed' }), { mode: 0o600 });
391
+ try { fs.chmodSync(readyFile, 0o600); } catch { /* best-effort */ }
345
392
  process.exit(1);
346
393
  }
347
394
 
@@ -350,6 +397,7 @@ export async function startDaemon() {
350
397
  lastCommandAt = Date.now();
351
398
  try {
352
399
  const result = await dispatchToExtension(cmd);
400
+ refreshReadyFileFromStatusResult(readyFile, cmd, result);
353
401
  return { id: String(cmd.id ?? 'unknown'), success: true, data: result };
354
402
  } catch (err) {
355
403
  return { id: String(cmd.id ?? 'unknown'), success: false, error: err.message };
@@ -359,7 +407,8 @@ export async function startDaemon() {
359
407
  // Reason: start the socket server before writing the ready file so the CLI
360
408
  // can connect immediately (e.g. for needs_login polling).
361
409
  const server = createSocketServer(queue, socketPath);
362
- fs.writeFileSync(readyFile, JSON.stringify(buildReadyPayload(statusData)));
410
+ fs.writeFileSync(readyFile, JSON.stringify(buildReadyPayload(statusData)), { mode: 0o600 });
411
+ try { fs.chmodSync(readyFile, 0o600); } catch { /* best-effort */ }
363
412
 
364
413
  startIdleShutdown(server, session, () => lastCommandAt);
365
414
  }
package/dassi.mjs CHANGED
@@ -58,11 +58,13 @@ export function parseCliArgs(argv) {
58
58
  if (!command) throw new Error('No command specified. Run: dassi --help');
59
59
 
60
60
  if (command === 'list-tabs') {
61
- return { action: 'list_tabs', params: {}, session, json };
61
+ const all = consumeFlag(args, '--all');
62
+ return { action: 'list_tabs', params: all ? { all: true } : {}, session, json };
62
63
  }
63
64
 
64
65
  if (command === 'list-groups') {
65
- return { action: 'list_groups', params: {}, session, json };
66
+ const all = consumeFlag(args, '--all');
67
+ return { action: 'list_groups', params: all ? { all: true } : {}, session, json };
66
68
  }
67
69
 
68
70
  if (command === 'status') {
@@ -245,22 +247,46 @@ export function sendCommand(socketPath, command) {
245
247
  // ─── Login helpers ────────────────────────────────────────────────────────────
246
248
 
247
249
  /**
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
250
+ * Defense-in-depth check for `optionsUrl` before handing it to the `open`
251
+ * package. Legit URLs always come from `chrome.runtime.getURL('options.html')`
252
+ * `chrome-extension://<id>/options.html`. Anything else is unexpected and
253
+ * we should not auto-launch it (the `open` package shells out to the OS URL
254
+ * handler).
255
+ * @param {unknown} optionsUrl
256
+ * @returns {boolean}
257
+ */
258
+ export function isValidOptionsUrl(optionsUrl) {
259
+ return typeof optionsUrl === 'string' && /^chrome-extension:\/\/[a-z]{32}\//.test(optionsUrl);
260
+ }
261
+
262
+ /**
263
+ * Polls the daemon's status until the user signs in (extension reports
264
+ * authenticated=true) or the LOGIN_TIMEOUT_MS deadline elapses. On entry,
265
+ * best-effort auto-opens the extension's options page (subject to the
266
+ * isValidOptionsUrl guard above).
267
+ * @param {string} socketPath - Path to the daemon Unix socket.
268
+ * @param {string | undefined} optionsUrl - URL of the Dassi options page.
269
+ * @returns {Promise<void>} Resolves on successful login; throws on timeout.
254
270
  */
255
271
  export async function waitForLogin(socketPath, optionsUrl) {
256
272
  console.error(`⚠️ Dassi is installed but you're not signed in.\n Opening the Dassi settings page...\n`);
257
273
 
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}`);
274
+ // Auto-open the options page — best-effort (package may not be installed).
275
+ // Reason: only open URLs the extension would legitimately produce
276
+ // (chrome.runtime.getURL `chrome-extension://<id>/options.html`).
277
+ // Defense-in-depth: even though optionsUrl is sourced from the daemon's
278
+ // ready file (which lives in our owner-only ~/.dassi/ dir), validating the
279
+ // protocol prevents `open` from launching arbitrary URIs/shell-handlers if
280
+ // the file is ever tampered with.
281
+ if (isValidOptionsUrl(optionsUrl)) {
282
+ try {
283
+ const { default: open } = await import('open');
284
+ await open(String(optionsUrl));
285
+ } catch {
286
+ console.error(` Please open: ${optionsUrl}`);
287
+ }
288
+ } else if (optionsUrl) {
289
+ console.error(` Refusing to auto-open unexpected URL: ${optionsUrl}\n Please open the Dassi settings page manually.`);
264
290
  }
265
291
 
266
292
  // Poll the daemon socket every LOGIN_POLL_MS until authenticated
@@ -303,13 +329,14 @@ const HELP_TEXT =
303
329
  'Agent commands:\n' +
304
330
  ' run <prompt> --tab <id> | --group <id> | --group-title <name>\n' +
305
331
  ' 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' +
332
+ ' list-tabs [--all] List tabs in groups dassi has open (--all = every Chrome tab)\n' +
333
+ ' list-groups [--all] List dassi-open tab groups (--all = every Chrome group)\n' +
308
334
  ' status Check extension status\n' +
309
335
  ' bug-report [-o file] Export debug logs from all contexts\n' +
310
336
  ' raw <json> Send raw JSON command\n\n' +
311
337
  'Options:\n' +
312
338
  ' --tab <id> Chrome tab ID (use list-tabs to find)\n' +
339
+ ' --all list-tabs/list-groups: include every Chrome tab/group\n' +
313
340
  ' --timeout <ms> Timeout for run command (default: 300000)\n' +
314
341
  ' --session <name> Daemon session name (default: "default")\n' +
315
342
  ' --json Output raw JSON\n' +
@@ -416,8 +443,33 @@ export async function run() {
416
443
  }
417
444
 
418
445
  // ── 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) {
446
+
447
+ /**
448
+ * Returns true when this module is being executed as the CLI entry point,
449
+ * not imported by another module (e.g. tests).
450
+ *
451
+ * Reason: `npm i -g` installs a symlink in the bin dir; `import.meta.url`
452
+ * resolves symlinks but `process.argv[1]` does not, so they only match after
453
+ * canonicalizing argvPath via realpathSync. If realpathSync throws (e.g.
454
+ * `node -` makes argv[1] equal `"-"` which is not a real file), fall back to
455
+ * the raw argvPath so the module can still be imported safely.
456
+ *
457
+ * @param {string} metaUrl - import.meta.url of the candidate entry module.
458
+ * @param {string | undefined} argvPath - process.argv[1].
459
+ * @returns {boolean}
460
+ */
461
+ export function isMainModule(metaUrl, argvPath) {
462
+ if (!argvPath) return false;
463
+ let resolvedPath = argvPath;
464
+ try {
465
+ resolvedPath = fs.realpathSync(argvPath);
466
+ } catch {
467
+ // Reason: argvPath may not be a real file (`node -`, REPL, etc.) — fall through with the raw path.
468
+ }
469
+ return metaUrl === pathToFileURL(resolvedPath).href;
470
+ }
471
+
472
+ if (isMainModule(import.meta.url, process.argv[1])) {
421
473
  run().catch((err) => {
422
474
  console.error(`❌ ${err.message}`);
423
475
  process.exit(1);
@@ -42,7 +42,13 @@ export function uniquifyOutputForTab(output, tabId) {
42
42
  * @returns {Promise<number[]>} Member tab IDs (order: ascending)
43
43
  */
44
44
  export async function expandGroupToTabIds(socketPath, ref, sendFn) {
45
- const groupsResp = await sendFn(socketPath, { id: `cli_lg_${Date.now()}`, action: 'list_groups' });
45
+ // Reason: pass all:true (flat, NOT nested under `params`) so --group /
46
+ // --group-title can resolve against any Chrome tab group, not just groups
47
+ // dassi currently has open. The daemon's dispatchToExtension does
48
+ // `{ id, action, ...rest } = cmd` and forwards `rest` as JSON-RPC params,
49
+ // so nesting under `params` would arrive at the extension as
50
+ // `params.params.all` — and the filter would NOT bypass.
51
+ const groupsResp = await sendFn(socketPath, { id: `cli_lg_${Date.now()}`, action: 'list_groups', all: true });
46
52
  if (!groupsResp.success) throw new Error(`Failed to list groups: ${groupsResp.error ?? 'unknown'}`);
47
53
  const groups = /** @type {Array<{id:number;title:string;windowId:number}>} */ (groupsResp.data ?? []);
48
54
 
@@ -64,7 +70,7 @@ export async function expandGroupToTabIds(socketPath, ref, sendFn) {
64
70
  throw new Error('expandGroupToTabIds: pass groupId or groupTitle');
65
71
  }
66
72
 
67
- const tabsResp = await sendFn(socketPath, { id: `cli_lt_${Date.now()}`, action: 'list_tabs' });
73
+ const tabsResp = await sendFn(socketPath, { id: `cli_lt_${Date.now()}`, action: 'list_tabs', all: true });
68
74
  if (!tabsResp.success) throw new Error(`Failed to list tabs: ${tabsResp.error ?? 'unknown'}`);
69
75
  const tabs = /** @type {Array<{tabId:number;groupId:number}>} */ (tabsResp.data ?? []);
70
76
  const memberIds = tabs.filter((t) => t.groupId === groupId).map((t) => t.tabId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dassi_ai/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "CLI for the Dassi Chrome extension — run browser automation from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@ The main entry point for driving the Dassi Chrome extension from Claude Code.
12
12
 
13
13
  ## Prerequisites
14
14
 
15
- `dassi` must be on PATH. Install with `npm install -g dassi-cli` or `npm link` from the CLI package directory.
15
+ `dassi` must be on PATH. Install with `npm install -g @dassi_ai/cli` or `npm link` from the CLI package directory.
16
16
 
17
17
  ## Process
18
18