@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 +4 -4
- package/dassi-daemon.mjs +53 -4
- package/dassi.mjs +70 -18
- package/group-expansion.mjs +8 -2
- package/package.json +1 -1
- package/skills/operate/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
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
|
|
9
|
+
npx @dassi_ai/cli --help
|
|
10
10
|
|
|
11
11
|
# Or install globally
|
|
12
|
-
npm install -g
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* @
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
307
|
-
' list-groups
|
|
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
|
-
|
|
420
|
-
|
|
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);
|
package/group-expansion.mjs
CHANGED
|
@@ -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
|
-
|
|
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
package/skills/operate/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|