@dassi_ai/cli 0.2.0 → 0.3.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/README.md +12 -0
- package/dassi-daemon.mjs +7 -11
- package/dassi-shared.mjs +34 -0
- package/dassi.mjs +48 -39
- package/help-text.mjs +46 -0
- package/launch.mjs +273 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -54,6 +54,18 @@ dassi list-profiles
|
|
|
54
54
|
dassi list-tabs --profile dev
|
|
55
55
|
dassi run "summarize" --tab 456 --profile dev # alias: --label
|
|
56
56
|
|
|
57
|
+
# Launch a fresh Chrome with a locally-built dev dist (for testing extension changes)
|
|
58
|
+
pnpm build # build extension/dist first
|
|
59
|
+
dassi launch # loads extension/dist as profile "dev"
|
|
60
|
+
dassi launch --label qa --dist some/dist # custom label + dist
|
|
61
|
+
# Drive the launched Chrome — run still needs a tab/group target; --profile selects which Chrome:
|
|
62
|
+
dassi list-tabs --profile dev # find a tab id in the launched profile
|
|
63
|
+
dassi run "summarize this page" --tab <id> --profile dev
|
|
64
|
+
dassi launch --stop # close the "dev" Chrome (or --stop-all)
|
|
65
|
+
# Note: launch reuses the default daemon (port 18790). To launch on an isolated
|
|
66
|
+
# port (DASSI_BRIDGE_PORT=18791 dassi launch), no default daemon may be running —
|
|
67
|
+
# it errors clearly otherwise, since it can't confirm the running daemon's port.
|
|
68
|
+
|
|
57
69
|
# Show version / help
|
|
58
70
|
dassi --version
|
|
59
71
|
dassi --help
|
package/dassi-daemon.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
buildReadyPayload,
|
|
26
26
|
createCommandQueue,
|
|
27
27
|
createConnectionRegistry,
|
|
28
|
+
getDaemonBridgePort,
|
|
28
29
|
} from './dassi-shared.mjs';
|
|
29
30
|
|
|
30
31
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
@@ -153,17 +154,12 @@ async function handleRegistration(ws, data, ctx, resolve, reject, port) {
|
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
// Reason: Port is configurable via DASSI_BRIDGE_PORT
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
// ephemeral port — leaving the extension's known-port connect attempt hanging.
|
|
163
|
-
const _rawPort = parseInt(process.env.DASSI_BRIDGE_PORT ?? '', 10);
|
|
164
|
-
const BRIDGE_PORT = Number.isInteger(_rawPort) && _rawPort > 0 && _rawPort <= 65535
|
|
165
|
-
? _rawPort
|
|
166
|
-
: 18790;
|
|
157
|
+
// Reason: Port is configurable via DASSI_BRIDGE_PORT (single source of truth in
|
|
158
|
+
// getDaemonBridgePort — also seeded into launched extensions by `dassi launch`).
|
|
159
|
+
// Defaults to 18790, the production port. The validation guards against an
|
|
160
|
+
// empty/garbage env var yielding NaN, which `new WSServer({ port: NaN })` would
|
|
161
|
+
// silently accept and bind to a random ephemeral port.
|
|
162
|
+
const BRIDGE_PORT = getDaemonBridgePort();
|
|
167
163
|
|
|
168
164
|
/**
|
|
169
165
|
* Starts a WebSocket server on the configured bridge port (default 18790).
|
package/dassi-shared.mjs
CHANGED
|
@@ -11,6 +11,15 @@ import * as fs from 'fs';
|
|
|
11
11
|
import * as path from 'path';
|
|
12
12
|
import * as os from 'os';
|
|
13
13
|
|
|
14
|
+
// ─── Extension identity ───────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Stable Dassi extension ID. The extension manifest ships a fixed `key`, so the
|
|
18
|
+
* unpacked dev build gets the SAME id as the Web Store build regardless of load
|
|
19
|
+
* path — letting `dassi launch` open the extension's options page directly.
|
|
20
|
+
*/
|
|
21
|
+
export const DASSI_EXTENSION_ID = 'bjcngahpcjeililljmfegmlanlpgibdi';
|
|
22
|
+
|
|
14
23
|
// ─── Path helpers ─────────────────────────────────────────────────────────────
|
|
15
24
|
|
|
16
25
|
/**
|
|
@@ -53,6 +62,31 @@ export function getReadyFile(session) {
|
|
|
53
62
|
return path.join(getAppDir(), `${session}.ready`);
|
|
54
63
|
}
|
|
55
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the bridge (WebSocket) port the daemon listens on. Configurable via
|
|
67
|
+
* DASSI_BRIDGE_PORT (e.g. the benchmark harness uses 18791 to isolate from a
|
|
68
|
+
* developer's real Chrome on 18790); falls back to 18790 on empty/invalid input.
|
|
69
|
+
* Single source of truth shared by the daemon (which binds it) and `dassi launch`
|
|
70
|
+
* (which seeds it into the launched extension so it connects to the same port).
|
|
71
|
+
* @param {Record<string, string|undefined>} [env]
|
|
72
|
+
* @returns {number} A valid TCP port (1–65535).
|
|
73
|
+
*/
|
|
74
|
+
export const DEFAULT_BRIDGE_PORT = 18790;
|
|
75
|
+
|
|
76
|
+
export function getDaemonBridgePort(env = process.env) {
|
|
77
|
+
const p = parseInt(env.DASSI_BRIDGE_PORT ?? '', 10);
|
|
78
|
+
return Number.isInteger(p) && p > 0 && p <= 65535 ? p : DEFAULT_BRIDGE_PORT;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Path to the JSON file tracking Chrome instances started by `dassi launch`,
|
|
83
|
+
* so `dassi launch --stop` can find and kill them.
|
|
84
|
+
* @returns {string} Absolute path to launches.json under the app dir.
|
|
85
|
+
*/
|
|
86
|
+
export function getLaunchesFile() {
|
|
87
|
+
return path.join(getAppDir(), 'launches.json');
|
|
88
|
+
}
|
|
89
|
+
|
|
56
90
|
// ─── Session validation ───────────────────────────────────────────────────────
|
|
57
91
|
|
|
58
92
|
/**
|
package/dassi.mjs
CHANGED
|
@@ -15,10 +15,15 @@ import {
|
|
|
15
15
|
isDaemonRunning,
|
|
16
16
|
parseReadyPayload,
|
|
17
17
|
validateSession,
|
|
18
|
+
DASSI_EXTENSION_ID,
|
|
19
|
+
getAppDir,
|
|
20
|
+
getLaunchesFile,
|
|
18
21
|
} from './dassi-shared.mjs';
|
|
22
|
+
import { handleLaunch, handleStop } from './launch.mjs';
|
|
19
23
|
import { parseToolCommand, requireTarget, parseStrictInt } from './tool-commands.mjs';
|
|
20
24
|
import { runWithGroupExpansion } from './group-expansion.mjs';
|
|
21
25
|
import { formatResponse } from './format-response.mjs';
|
|
26
|
+
import { HELP_TEXT } from './help-text.mjs';
|
|
22
27
|
|
|
23
28
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
29
|
const DAEMON_SCRIPT = path.join(__dirname, 'dassi-daemon.mjs');
|
|
@@ -27,7 +32,7 @@ const READY_POLL_MS = 100;
|
|
|
27
32
|
const READY_TIMEOUT_MS = 30_000;
|
|
28
33
|
const LOGIN_POLL_MS = 2_000;
|
|
29
34
|
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
30
|
-
const CHROME_WEB_STORE_URL =
|
|
35
|
+
const CHROME_WEB_STORE_URL = `https://chromewebstore.google.com/detail/dassi-ai-browser-agent-fo/${DASSI_EXTENSION_ID}`;
|
|
31
36
|
|
|
32
37
|
// ─── Arg parsing ──────────────────────────────────────────────────────────────
|
|
33
38
|
|
|
@@ -80,6 +85,38 @@ export function parseCliArgs(argv) {
|
|
|
80
85
|
return finish('list_profiles', {});
|
|
81
86
|
}
|
|
82
87
|
|
|
88
|
+
if (command === 'launch') {
|
|
89
|
+
if (consumeFlag(args, '--stop-all')) return finish('launch_stop', { all: true });
|
|
90
|
+
const stopIdx = args.indexOf('--stop');
|
|
91
|
+
if (stopIdx !== -1) {
|
|
92
|
+
args.splice(stopIdx, 1);
|
|
93
|
+
// Reason: resolve the stop label like the start path does — a positional
|
|
94
|
+
// token after --stop wins, else the global --label/--profile (parsed into
|
|
95
|
+
// `profile` before this branch), else the default. Keeps `--stop --label qa`
|
|
96
|
+
// symmetric with `launch --label qa` instead of silently targeting "dev".
|
|
97
|
+
const positional = args[0] && !args[0].startsWith('--') ? args.shift() : null;
|
|
98
|
+
return finish('launch_stop', { label: positional ?? profile ?? 'dev', all: false });
|
|
99
|
+
}
|
|
100
|
+
const timeoutRaw = getFlag(args, '--timeout');
|
|
101
|
+
// Reason: --label is consumed early as a global flag (aliased to --profile).
|
|
102
|
+
// The launch branch reads it from `profile` as the canonical source; any
|
|
103
|
+
// remaining --label token in args is a second occurrence and takes precedence.
|
|
104
|
+
const launchLabel = getFlag(args, '--label') ?? profile ?? 'dev';
|
|
105
|
+
// Reason: the label becomes a path segment (per-label profile dir) + the seed URL,
|
|
106
|
+
// so restrict to a path-safe token (blocks "x/../../.ssh" traversal) within the
|
|
107
|
+
// extension's 64-char cap — same character set as validateSession.
|
|
108
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(launchLabel)) {
|
|
109
|
+
throw new Error('--label must be 1–64 characters of letters, digits, hyphen, or underscore.');
|
|
110
|
+
}
|
|
111
|
+
return finish('launch', {
|
|
112
|
+
label: launchLabel,
|
|
113
|
+
dist: getFlag(args, '--dist') ?? null,
|
|
114
|
+
chrome: getFlag(args, '--chrome') ?? null,
|
|
115
|
+
profileDir: getFlag(args, '--profile-dir') ?? null,
|
|
116
|
+
timeoutMs: timeoutRaw ? parseStrictInt(timeoutRaw, '--timeout') : 30000,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
83
120
|
if (command === 'run') {
|
|
84
121
|
const prompt = args.shift();
|
|
85
122
|
if (!prompt) throw new Error('run requires a prompt argument');
|
|
@@ -319,44 +356,7 @@ export async function waitForLogin(socketPath, optionsUrl) {
|
|
|
319
356
|
|
|
320
357
|
// ─── Immediate actions ────────────────────────────────────────────────────────
|
|
321
358
|
|
|
322
|
-
|
|
323
|
-
'Usage: dassi [options] <command>\n\n' +
|
|
324
|
-
'Browser commands (each accepts --tab <id> | --group <id> | --group-title <name>):\n' +
|
|
325
|
-
' navigate <url> --tab <id> Navigate to URL\n' +
|
|
326
|
-
' click <ref> --tab <id> Click element by ref\n' +
|
|
327
|
-
' fill <ref> <text> --tab <id> Fill input with text (instant)\n' +
|
|
328
|
-
' type <ref> <text> --tab <id> Type text with keyboard events\n' +
|
|
329
|
-
' read-page --tab <id> Read page accessibility tree\n' +
|
|
330
|
-
' get-text --tab <id> Extract page text\n' +
|
|
331
|
-
' screenshot --tab <id> [-o file] Capture viewport screenshot\n' +
|
|
332
|
-
' panel-screenshot --tab <id> [-o f] [--width W] [--height H]\n' +
|
|
333
|
-
' Capture side panel UI (default 360x800)\n' +
|
|
334
|
-
' eval <code> --tab <id> Execute JavaScript\n' +
|
|
335
|
-
' tabs --tab <id> List tabs in same group\n' +
|
|
336
|
-
' open [url] --tab <id> Open new tab in group\n' +
|
|
337
|
-
' close --tab <id> Close tab\n\n' +
|
|
338
|
-
'Agent commands:\n' +
|
|
339
|
-
' run <prompt> --tab <id> | --group <id> | --group-title <name>\n' +
|
|
340
|
-
' Run AI agent on a tab or group (group = sequential)\n' +
|
|
341
|
-
' list-tabs [--all] List tabs in groups dassi has open (--all = every Chrome tab)\n' +
|
|
342
|
-
' list-groups [--all] List dassi-open tab groups (--all = every Chrome group)\n' +
|
|
343
|
-
' list-profiles List connected profiles (Chrome instances)\n' +
|
|
344
|
-
' status Check extension status\n' +
|
|
345
|
-
' bug-report [-o file] Export debug logs from all contexts\n' +
|
|
346
|
-
' raw <json> Send raw JSON command\n\n' +
|
|
347
|
-
'Options:\n' +
|
|
348
|
-
' --tab <id> Chrome tab ID (use list-tabs to find)\n' +
|
|
349
|
-
' --all list-tabs/list-groups: include every Chrome tab/group\n' +
|
|
350
|
-
' --timeout <ms> Timeout for run command (default: 300000)\n' +
|
|
351
|
-
' --session <name> Daemon session name (default: "default")\n' +
|
|
352
|
-
' --profile <label> Target a specific connected profile (alias: --label)\n' +
|
|
353
|
-
' --json Output raw JSON\n' +
|
|
354
|
-
' --filter <type> Filter for read-page (interactive|all)\n' +
|
|
355
|
-
' --depth <n> Depth for read-page tree\n' +
|
|
356
|
-
' --await Await promise in eval\n' +
|
|
357
|
-
' -o, --output <f> Output file for screenshot\n' +
|
|
358
|
-
' --version Show version\n' +
|
|
359
|
-
' --help Show help';
|
|
359
|
+
// HELP_TEXT lives in ./help-text.mjs (extracted to keep this file within the size limit).
|
|
360
360
|
|
|
361
361
|
/**
|
|
362
362
|
* Handles --version and --help, which don't need a daemon. Returns true if handled.
|
|
@@ -431,6 +431,15 @@ export async function run() {
|
|
|
431
431
|
const { action, params, session, json, profile } = parsed;
|
|
432
432
|
handleImmediateAction(action);
|
|
433
433
|
|
|
434
|
+
if (action === 'launch') {
|
|
435
|
+
await handleLaunch(params, { spawn: child_process.spawn, ensureDaemonRunning, getSocketPath, sendCommand, appDir: getAppDir(), launchesFile: getLaunchesFile() });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (action === 'launch_stop') {
|
|
439
|
+
handleStop(params, { launchesFile: getLaunchesFile() });
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
434
443
|
const socketPath = await ensureDaemonReady(session);
|
|
435
444
|
|
|
436
445
|
const id = `cli_${Date.now()}`;
|
package/help-text.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
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>]\n' +
|
|
31
|
+
' Open Chrome with a dev dist loaded\n' +
|
|
32
|
+
' launch --stop [label] | --stop-all\n' +
|
|
33
|
+
' Stop a launched Chrome\n\n' +
|
|
34
|
+
'Options:\n' +
|
|
35
|
+
' --tab <id> Chrome tab ID (use list-tabs to find)\n' +
|
|
36
|
+
' --all list-tabs/list-groups: include every Chrome tab/group\n' +
|
|
37
|
+
' --timeout <ms> Timeout for run command (default: 300000)\n' +
|
|
38
|
+
' --session <name> Daemon session name (default: "default")\n' +
|
|
39
|
+
' --profile <label> Target a specific connected profile (alias: --label)\n' +
|
|
40
|
+
' --json Output raw JSON\n' +
|
|
41
|
+
' --filter <type> Filter for read-page (interactive|all)\n' +
|
|
42
|
+
' --depth <n> Depth for read-page tree\n' +
|
|
43
|
+
' --await Await promise in eval\n' +
|
|
44
|
+
' -o, --output <f> Output file for screenshot\n' +
|
|
45
|
+
' --version Show version\n' +
|
|
46
|
+
' --help Show help';
|
package/launch.mjs
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for `dassi launch` — locating Chrome, resolving the dist,
|
|
3
|
+
* building Chrome launch args, and tracking launched instances.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import { DASSI_EXTENSION_ID, getDaemonBridgePort, DEFAULT_BRIDGE_PORT, isDaemonRunning as isDaemonRunningImpl } from './dassi-shared.mjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the path to an installed Chrome binary.
|
|
11
|
+
* @param {{ platform?: string; env?: Record<string,string|undefined>; override?: string|null; exists?: (p: string) => boolean }} [opts]
|
|
12
|
+
* @returns {string | null} The first existing Chrome path, the override, or null.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveChromePath({ platform = process.platform, env = process.env, override = null, exists = existsSync } = {}) {
|
|
15
|
+
if (override) return override;
|
|
16
|
+
const candidates = [];
|
|
17
|
+
if (platform === 'darwin') {
|
|
18
|
+
candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome');
|
|
19
|
+
} else if (platform === 'win32') {
|
|
20
|
+
const pf = env['ProgramFiles'] ?? 'C:\\Program Files';
|
|
21
|
+
const pf86 = env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)';
|
|
22
|
+
candidates.push(`${pf}\\Google\\Chrome\\Application\\chrome.exe`, `${pf86}\\Google\\Chrome\\Application\\chrome.exe`);
|
|
23
|
+
if (env['LOCALAPPDATA']) candidates.push(`${env['LOCALAPPDATA']}\\Google\\Chrome\\Application\\chrome.exe`);
|
|
24
|
+
} else {
|
|
25
|
+
candidates.push('/opt/google/chrome/chrome', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser');
|
|
26
|
+
}
|
|
27
|
+
return candidates.find((p) => exists(p)) ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve and validate the extension dist directory to load.
|
|
32
|
+
* @param {{ dist?: string|null; cwd?: string; exists?: (p: string) => boolean }} [opts]
|
|
33
|
+
* @returns {string} Absolute path to the dist directory.
|
|
34
|
+
* @throws if the directory has no manifest.json.
|
|
35
|
+
*/
|
|
36
|
+
export function resolveDistPath({ dist = null, cwd = process.cwd(), exists = existsSync } = {}) {
|
|
37
|
+
const base = dist ? path.resolve(cwd, dist) : path.resolve(cwd, 'extension/dist');
|
|
38
|
+
if (!exists(path.join(base, 'manifest.json'))) {
|
|
39
|
+
throw new Error(`No built extension at ${base} (manifest.json missing). Run \`pnpm build\`, or pass --dist <path>.`);
|
|
40
|
+
}
|
|
41
|
+
return base;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the Chrome command-line args that load the dev dist into an isolated
|
|
46
|
+
* profile and open the options page to seed the profile label.
|
|
47
|
+
* @param {{ distPath: string; profileDir: string; label: string; bridgePort: number }} opts
|
|
48
|
+
* bridgePort: the daemon port to seed (use getDaemonBridgePort() — no default
|
|
49
|
+
* here, to avoid duplicating its canonical 18790).
|
|
50
|
+
* @returns {string[]} Chrome args (the final entry is the seed URL to open).
|
|
51
|
+
*/
|
|
52
|
+
export function buildLaunchArgs({ distPath, profileDir, label, bridgePort }) {
|
|
53
|
+
// Reason: seed BOTH label and bridgePort — the options page persists them so the
|
|
54
|
+
// launched extension connects to the daemon's actual port (matters when
|
|
55
|
+
// DASSI_BRIDGE_PORT moves the daemon off the default 18790; without it the
|
|
56
|
+
// extension falls back to 18790 and the isolated-port launch times out).
|
|
57
|
+
const seed = `options.html?label=${encodeURIComponent(label)}&bridgePort=${bridgePort}`;
|
|
58
|
+
return [
|
|
59
|
+
`--load-extension=${distPath}`,
|
|
60
|
+
`--disable-extensions-except=${distPath}`,
|
|
61
|
+
`--user-data-dir=${profileDir}`,
|
|
62
|
+
'--no-first-run',
|
|
63
|
+
'--no-default-browser-check',
|
|
64
|
+
`chrome-extension://${DASSI_EXTENSION_ID}/${seed}`,
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read the launches registry. Returns [] if the file is missing or unparseable.
|
|
70
|
+
* @param {string} file
|
|
71
|
+
* @returns {Array<{label: string; pid: number; profileDir: string; startedAt: number}>}
|
|
72
|
+
*/
|
|
73
|
+
export function readLaunches(file) {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
76
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
77
|
+
} catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Record a launched Chrome, replacing any prior entry for the same label.
|
|
84
|
+
*
|
|
85
|
+
* Reason: read-modify-write is not cross-process locked. For this dev-only CLI
|
|
86
|
+
* that's acceptable — concurrent `dassi launch`/`--stop` of *different* labels is
|
|
87
|
+
* rare, and the worst case (a lost entry) is recoverable via `--stop-all` + a
|
|
88
|
+
* manual Chrome close. A lock is intentionally omitted (YAGNI for a dev tool).
|
|
89
|
+
* @param {string} file
|
|
90
|
+
* @param {{label: string; pid: number; profileDir: string; startedAt: number}} entry
|
|
91
|
+
*/
|
|
92
|
+
export function recordLaunch(file, entry) {
|
|
93
|
+
const list = readLaunches(file).filter((e) => e.label !== entry.label);
|
|
94
|
+
list.push(entry);
|
|
95
|
+
// Reason: ensure the dir exists so the write can't ENOENT after Chrome is already
|
|
96
|
+
// spawned+connected (which would orphan it with nothing recorded to --stop).
|
|
97
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
98
|
+
writeFileSync(file, JSON.stringify(list, null, 2), { mode: 0o600 });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Remove the entry for a label. Returns the removed entry, or null if absent.
|
|
103
|
+
* @param {string} file
|
|
104
|
+
* @param {string} label
|
|
105
|
+
* @returns {object | null}
|
|
106
|
+
*/
|
|
107
|
+
export function removeLaunch(file, label) {
|
|
108
|
+
const list = readLaunches(file);
|
|
109
|
+
const found = list.find((e) => e.label === label) ?? null;
|
|
110
|
+
if (found) writeFileSync(file, JSON.stringify(list.filter((e) => e.label !== label), null, 2), { mode: 0o600 });
|
|
111
|
+
return found;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Launch orchestrators (exported for testability; deps-injected to avoid circular imports) ───
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Poll the daemon's list_profiles until `label` registers or the timeout elapses.
|
|
118
|
+
* @param {{ sendCommand: Function; socketPath: string; label: string; timeoutMs: number; sleep?: (ms:number)=>Promise<void> }} deps
|
|
119
|
+
* @returns {Promise<boolean>} true if the label connected in time.
|
|
120
|
+
*/
|
|
121
|
+
export async function waitForProfile({ sendCommand, socketPath, label, timeoutMs, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
122
|
+
const deadline = Date.now() + timeoutMs;
|
|
123
|
+
while (Date.now() < deadline) {
|
|
124
|
+
try {
|
|
125
|
+
const resp = await sendCommand(socketPath, { id: `cli_lp_${Date.now()}`, action: 'list_profiles' });
|
|
126
|
+
if (resp.success && Array.isArray(resp.data) && resp.data.some((p) => p.label === label)) return true;
|
|
127
|
+
} catch { /* daemon still coming up — keep polling */ }
|
|
128
|
+
await sleep(500);
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One-shot check: is a profile with `label` already connected to the daemon?
|
|
135
|
+
* Tolerates the daemon/socket not being up yet (returns false).
|
|
136
|
+
* @param {{ sendCommand: Function; socketPath: string; label: string }} deps
|
|
137
|
+
* @returns {Promise<boolean>}
|
|
138
|
+
*/
|
|
139
|
+
async function isLabelConnected({ sendCommand, socketPath, label }) {
|
|
140
|
+
try {
|
|
141
|
+
const resp = await sendCommand(socketPath, { id: `cli_pre_${Date.now()}`, action: 'list_profiles' });
|
|
142
|
+
return resp.success && Array.isArray(resp.data) && resp.data.some((p) => p.label === label);
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Launch Chrome (detached) with the dev dist loaded under a dedicated profile,
|
|
150
|
+
* wait for the extension to register under `label`, then record it.
|
|
151
|
+
* @param {{label:string; dist:string|null; chrome:string|null; profileDir:string|null; timeoutMs:number}} opts
|
|
152
|
+
* @param {{ spawn: Function; ensureDaemonRunning: Function; getSocketPath: Function; sendCommand: Function;
|
|
153
|
+
* appDir: string; launchesFile: string;
|
|
154
|
+
* log?: Function; error?: Function; exit?: Function; mkdir?: Function }} deps
|
|
155
|
+
*/
|
|
156
|
+
export async function handleLaunch(opts, deps) {
|
|
157
|
+
const {
|
|
158
|
+
spawn, ensureDaemonRunning, getSocketPath, sendCommand, appDir, launchesFile,
|
|
159
|
+
isDaemonRunning = isDaemonRunningImpl,
|
|
160
|
+
log = console.log, error = console.error, exit = process.exit,
|
|
161
|
+
mkdir = (d) => mkdirSync(d, { recursive: true }), kill = (pid) => process.kill(pid),
|
|
162
|
+
} = deps;
|
|
163
|
+
|
|
164
|
+
const target = resolveLaunchTarget(opts, { error, exit, appDir, mkdir });
|
|
165
|
+
if (!target) return; // resolution failed (exit already called)
|
|
166
|
+
const { distPath, chromePath, profileDir } = target;
|
|
167
|
+
|
|
168
|
+
// Reason: `dassi launch` reuses the 'default' daemon session, but the session PID
|
|
169
|
+
// doesn't tell us its port. If a non-default DASSI_BRIDGE_PORT is requested while a
|
|
170
|
+
// default daemon is already running, we can't confirm it's on that port — fail
|
|
171
|
+
// clearly rather than seed a port the extension can't reach (a silent timeout).
|
|
172
|
+
const bridgePort = getDaemonBridgePort();
|
|
173
|
+
if (bridgePort !== DEFAULT_BRIDGE_PORT && isDaemonRunning('default')) {
|
|
174
|
+
error(`❌ DASSI_BRIDGE_PORT=${bridgePort} is set, but a daemon is already running for the default session (it may be on a different port). Stop it first, or omit DASSI_BRIDGE_PORT to use the running daemon.`);
|
|
175
|
+
return exit(1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Reason: only SPAWN the daemon (non-blocking). NOT ensureDaemonReady — that waits
|
|
179
|
+
// for the ready file the daemon writes only after an extension connects; none is yet.
|
|
180
|
+
ensureDaemonRunning('default');
|
|
181
|
+
const socketPath = getSocketPath('default');
|
|
182
|
+
|
|
183
|
+
// Reason: a same-label Chrome already running makes waitForProfile falsely succeed
|
|
184
|
+
// (a second launch with the same --user-data-dir hands off to the existing process).
|
|
185
|
+
if (await isLabelConnected({ sendCommand, socketPath, label: opts.label })) {
|
|
186
|
+
error(`❌ A profile labelled "${opts.label}" is already connected. Run \`dassi launch --stop ${opts.label}\` first, or use a different --label.`);
|
|
187
|
+
return exit(1);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const pid = await spawnAndConnect({ spawn, kill, sendCommand, socketPath, chromePath, distPath, profileDir, bridgePort, opts, error });
|
|
191
|
+
if (pid === null) return exit(1);
|
|
192
|
+
|
|
193
|
+
recordLaunch(launchesFile, { label: opts.label, pid, profileDir, startedAt: Date.now() });
|
|
194
|
+
log(`✅ launched "${opts.label}" (pid ${pid}) — drive it with: dassi run "…" --profile ${opts.label}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Spawn the detached Chrome and wait for `label` to register on the daemon.
|
|
199
|
+
* @returns {Promise<number|null>} the Chrome pid on success, or null (after
|
|
200
|
+
* killing any orphan + logging) on a failed spawn or connect-timeout.
|
|
201
|
+
*/
|
|
202
|
+
async function spawnAndConnect({ spawn, kill, sendCommand, socketPath, chromePath, distPath, profileDir, bridgePort, opts, error }) {
|
|
203
|
+
// Reason: bridgePort (the daemon's actual port) is seeded so the launched extension
|
|
204
|
+
// connects to it — the daemon inherits DASSI_BRIDGE_PORT via ensureDaemonRunning.
|
|
205
|
+
const child = spawn(chromePath, buildLaunchArgs({ distPath, profileDir, label: opts.label, bridgePort }), {
|
|
206
|
+
detached: true,
|
|
207
|
+
stdio: 'ignore',
|
|
208
|
+
});
|
|
209
|
+
// Reason: a missing/invalid binary leaves pid undefined and emits 'error' async —
|
|
210
|
+
// swallow the event (else it throws) and fast-fail on the synchronous pid check.
|
|
211
|
+
child.on('error', () => {});
|
|
212
|
+
child.unref();
|
|
213
|
+
if (typeof child.pid !== 'number') {
|
|
214
|
+
error('❌ Failed to launch Chrome — check the Chrome path (--chrome).');
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
error(`Launching Chrome (pid ${child.pid}) with ${distPath} — waiting for "${opts.label}" to connect…`);
|
|
219
|
+
if (await waitForProfile({ sendCommand, socketPath, label: opts.label, timeoutMs: opts.timeoutMs })) return child.pid;
|
|
220
|
+
|
|
221
|
+
// Reason: kill the detached Chrome we started so a connect-timeout never orphans a browser.
|
|
222
|
+
try { kill(child.pid); } catch { /* already gone */ }
|
|
223
|
+
error(`❌ "${opts.label}" did not connect within ${opts.timeoutMs}ms (Chrome already using ${profileDir}, bridge disabled, or dist too old).`);
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Resolve the dist dir, Chrome binary, and profile dir for a launch.
|
|
229
|
+
* Calls `exit(1)` and returns null on any failure (extracted to keep
|
|
230
|
+
* handleLaunch within the function-length limit).
|
|
231
|
+
* @param {{label:string; dist:string|null; chrome:string|null; profileDir:string|null}} opts
|
|
232
|
+
* @param {{ error: Function; exit: Function; appDir: string; mkdir: Function }} deps
|
|
233
|
+
* @returns {{distPath:string; chromePath:string; profileDir:string} | null}
|
|
234
|
+
*/
|
|
235
|
+
function resolveLaunchTarget(opts, { error, exit, appDir, mkdir }) {
|
|
236
|
+
let distPath;
|
|
237
|
+
try { distPath = resolveDistPath({ dist: opts.dist }); }
|
|
238
|
+
catch (err) { error(`❌ ${err.message}`); exit(1); return null; }
|
|
239
|
+
|
|
240
|
+
const chromePath = resolveChromePath({ override: opts.chrome });
|
|
241
|
+
if (!chromePath) {
|
|
242
|
+
error('❌ Could not find Chrome. Pass --chrome <path> to the Chrome/Chromium binary.');
|
|
243
|
+
exit(1);
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const profileDir = opts.profileDir ?? path.join(appDir, `launch-${opts.label}`);
|
|
248
|
+
mkdir(profileDir);
|
|
249
|
+
return { distPath, chromePath, profileDir };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Kill the Chrome instance(s) recorded by `dassi launch`.
|
|
254
|
+
* @param {{label?: string; all?: boolean}} opts
|
|
255
|
+
* @param {{ launchesFile: string; kill?: (pid:number)=>void; log?: Function }} deps
|
|
256
|
+
*/
|
|
257
|
+
export function handleStop(opts, deps) {
|
|
258
|
+
const { launchesFile, kill = (pid) => process.kill(pid), log = console.log } = deps;
|
|
259
|
+
const list = readLaunches(launchesFile);
|
|
260
|
+
const targets = opts.all ? list : list.filter((e) => e.label === opts.label);
|
|
261
|
+
if (targets.length === 0) {
|
|
262
|
+
log(opts.all ? 'No launched Chrome instances to stop.' : `Nothing to stop for "${opts.label}".`);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
for (const e of targets) {
|
|
266
|
+
// Reason: guard against corrupt launches.json entries with non-number pids, which
|
|
267
|
+
// would cause process.kill to throw a misleading TypeError instead of a clear skip.
|
|
268
|
+
if (typeof e.pid !== 'number') { log(`Skipping "${e.label}" — corrupt record (no pid).`); removeLaunch(launchesFile, e.label); continue; }
|
|
269
|
+
try { kill(e.pid); log(`Stopped "${e.label}" (pid ${e.pid}).`); }
|
|
270
|
+
catch { log(`"${e.label}" (pid ${e.pid}) was already gone.`); }
|
|
271
|
+
removeLaunch(launchesFile, e.label);
|
|
272
|
+
}
|
|
273
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dassi_ai/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "CLI for the Dassi Chrome extension — run browser automation from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
"tool-commands.mjs",
|
|
14
14
|
"format-response.mjs",
|
|
15
15
|
"group-expansion.mjs",
|
|
16
|
+
"launch.mjs",
|
|
17
|
+
"help-text.mjs",
|
|
16
18
|
".claude-plugin/",
|
|
17
19
|
"skills/",
|
|
18
20
|
"README.md"
|