@duckmind/dm-darwin-x64 0.35.9 → 0.36.3
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/dm +0 -0
- package/extensions/.dm-extensions.json +1 -1
- package/extensions/dm-cua/README.md +9 -2
- package/extensions/dm-cua/bin/browser-cua.mjs +6 -4
- package/extensions/dm-cua/package.json +2 -1
- package/extensions/dm-cua/skills/browser-cua/skill.md +2 -1
- package/extensions/dm-cua/src/browser-cua-lib.mjs +65 -3
- package/extensions/dm-cua/test/browser-cua.test.js +16 -0
- package/extensions/dm-ultradex/commands.test.ts +71 -13
- package/extensions/dm-ultradex/commands.ts +51 -14
- package/extensions/dm-ultradex/package-lock.json +6 -6
- package/extensions/greedysearch-dm/bin/launch.mjs +10 -9
- package/package.json +1 -1
package/dm
CHANGED
|
Binary file
|
|
@@ -4,14 +4,21 @@ Browser-first Computer Use extension for DM.
|
|
|
4
4
|
|
|
5
5
|
What it does:
|
|
6
6
|
|
|
7
|
-
- launches or reuses a dedicated
|
|
7
|
+
- launches or reuses a dedicated browser profile via the bundled `greedysearch-dm` CDP helpers
|
|
8
8
|
- bootstraps a blank tab on fresh empty profiles so browser automation does not fail on the first run
|
|
9
9
|
- exposes one tool, `browser_cua`, for list / navigate / snapshot / screenshot / click / type / evaluate / stop
|
|
10
10
|
- stores browser screenshots under `~/.dm/agent/cua/screenshots/` by default
|
|
11
11
|
|
|
12
|
+
Browser selection:
|
|
13
|
+
|
|
14
|
+
- default: Chrome/Chromium auto-detected by `greedysearch-dm`
|
|
15
|
+
- opt-in CloakBrowser trial: set `DM_CUA_BROWSER=cloak` and one of `DM_CUA_BROWSER_PATH`, `DM_CUA_CLOAK_PATH`, `CLOAKBROWSER_BINARY_PATH`, or `CHROME_PATH` to the CloakBrowser Chromium executable
|
|
16
|
+
- DM does not bundle or auto-download the compiled CloakBrowser binary; install/pre-download it outside DM first because upstream permits use but not redistribution
|
|
17
|
+
- Current `browser_cua` actions still use CDP commands through `greedysearch-dm`; full CloakBrowser stealth parity would require a future Playwright-backed CUA lane.
|
|
18
|
+
|
|
12
19
|
What it does **not** do yet:
|
|
13
20
|
|
|
14
21
|
- it does not replace the macOS `computer-use` Codex plugin lane
|
|
15
22
|
- it does not replace Android `adbridge`
|
|
16
23
|
|
|
17
|
-
Those broader lanes are still tracked separately. This extension closes the most actionable CUA gap from the current DM snapshot: a first-class browser lane that works from a fresh dedicated
|
|
24
|
+
Those broader lanes are still tracked separately. This extension closes the most actionable CUA gap from the current DM snapshot: a first-class browser lane that works from a fresh dedicated browser profile instead of overloading `coding_task`.
|
|
@@ -16,10 +16,12 @@ Usage:
|
|
|
16
16
|
node browser-cua.mjs evaluate --expression <js> [--tab <prefix>] [--json]
|
|
17
17
|
node browser-cua.mjs stop [--json]
|
|
18
18
|
|
|
19
|
-
Notes:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
Notes:
|
|
20
|
+
- This helper reuses DM's bundled greedysearch-dm CDP sidecar.
|
|
21
|
+
- Fresh dedicated browser profiles auto-bootstrap a blank tab.
|
|
22
|
+
- Default browser is Chrome/Chromium. To try CloakBrowser, set DM_CUA_BROWSER=cloak
|
|
23
|
+
and point DM_CUA_BROWSER_PATH or CLOAKBROWSER_BINARY_PATH at its Chromium binary.
|
|
24
|
+
- screenshot defaults to ~/.dm/agent/cua/screenshots when --output is omitted.`);
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
function parseArgs(argv) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dm-cua",
|
|
3
3
|
"version": "0.1.0",
|
|
4
|
-
"description": "First-class browser CUA extension for DM using a dedicated Chrome
|
|
4
|
+
"description": "First-class browser CUA extension for DM using a dedicated Chrome/CDP-compatible profile",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
7
7
|
"index.js",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"browser",
|
|
25
25
|
"cua",
|
|
26
26
|
"chrome",
|
|
27
|
+
"cloakbrowser",
|
|
27
28
|
"automation"
|
|
28
29
|
],
|
|
29
30
|
"peerDependencies": {
|
|
@@ -15,4 +15,5 @@ Rules:
|
|
|
15
15
|
- Start with an inspect action before click/type on unfamiliar pages.
|
|
16
16
|
- Prefer `navigate` for deterministic URLs, including search URLs.
|
|
17
17
|
- Use `screenshot` before complex click loops or when spatial layout matters.
|
|
18
|
-
- Fresh
|
|
18
|
+
- Fresh dedicated browser profiles auto-bootstrap a blank tab, so the first browser action should not fail just because the page list is empty.
|
|
19
|
+
- Default browser is the bundled Chrome/Chromium-compatible CDP lane. To evaluate CloakBrowser, install/pre-download CloakBrowser outside DM, then run with `DM_CUA_BROWSER=cloak` and `DM_CUA_BROWSER_PATH` or `CLOAKBROWSER_BINARY_PATH` pointing at its Chromium executable. DM must not bundle the compiled CloakBrowser binary because upstream permits use but not redistribution.
|
|
@@ -12,6 +12,10 @@ const DEFAULT_CUA_PORT_BASE = 9322;
|
|
|
12
12
|
const DEFAULT_CUA_PORT_RANGE = 400;
|
|
13
13
|
const DEFAULT_TIMEOUT_MS = 60000;
|
|
14
14
|
const URLISH_PATTERN = /^(about:|https?:\/\/|file:|chrome-error:\/\/)/i;
|
|
15
|
+
const BROWSER_DISPLAY_NAMES = {
|
|
16
|
+
chrome: "Chrome",
|
|
17
|
+
cloak: "CloakBrowser",
|
|
18
|
+
};
|
|
15
19
|
|
|
16
20
|
function parsePort(value) {
|
|
17
21
|
const port = Number.parseInt(String(value ?? ""), 10);
|
|
@@ -22,18 +26,40 @@ function hashScope(value) {
|
|
|
22
26
|
return createHash("sha256").update(String(value || "default")).digest("hex");
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
function normalizeBrowserKind(value) {
|
|
30
|
+
const raw = String(value || "chrome").trim().toLowerCase();
|
|
31
|
+
if (raw === "cloakbrowser" || raw === "cloak-browser") return "cloak";
|
|
32
|
+
if (raw === "chromium" || raw === "google-chrome" || raw === "chrome") return "chrome";
|
|
33
|
+
return raw.replace(/[^a-z0-9_.-]/g, "-") || "chrome";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveBrowserExecutablePath(env, browserKind) {
|
|
37
|
+
const explicit =
|
|
38
|
+
env.DM_CUA_BROWSER_PATH
|
|
39
|
+
|| (browserKind === "cloak" ? env.DM_CUA_CLOAK_PATH || env.CLOAKBROWSER_BINARY_PATH : undefined)
|
|
40
|
+
|| env.CHROME_PATH;
|
|
41
|
+
return explicit ? resolve(String(explicit)) : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
25
44
|
export function resolveBrowserRuntimeConfig(env = process.env, scope = process.cwd()) {
|
|
26
45
|
const rawScope = env.DM_CUA_SCOPE || env.DM_CODING_AGENT_DIR || env.PI_CODING_AGENT_DIR || scope;
|
|
27
46
|
const scopeHash = hashScope(resolve(String(rawScope || ".")));
|
|
28
47
|
const instanceId = String(env.DM_CUA_INSTANCE_ID || scopeHash.slice(0, 12)).replace(/[^a-zA-Z0-9_.-]/g, "-");
|
|
48
|
+
const browserKind = normalizeBrowserKind(env.DM_CUA_BROWSER || env.DM_BROWSER_CUA_BROWSER || env.DM_CUA_BROWSER_KIND);
|
|
49
|
+
const browserDisplayName = env.DM_CUA_BROWSER_DISPLAY_NAME || BROWSER_DISPLAY_NAMES[browserKind] || browserKind;
|
|
50
|
+
const browserExecutablePath = resolveBrowserExecutablePath(env, browserKind);
|
|
29
51
|
const port =
|
|
30
52
|
parsePort(env.DM_CUA_PORT)
|
|
31
53
|
?? parsePort(env.GREEDY_SEARCH_PORT)
|
|
32
54
|
?? DEFAULT_CUA_PORT_BASE + (Number.parseInt(scopeHash.slice(0, 8), 16) % DEFAULT_CUA_PORT_RANGE);
|
|
33
|
-
const
|
|
55
|
+
const profileSuffix = browserKind === "chrome" ? "chrome" : browserKind;
|
|
56
|
+
const profileDir = resolve(env.DM_CUA_PROFILE_DIR || join(tmpdir(), `dm-cua-${profileSuffix}-profile-${instanceId}`));
|
|
34
57
|
const socketBaseDir = platform() === "win32" ? tmpdir() : "/tmp";
|
|
35
58
|
return {
|
|
36
59
|
instanceId,
|
|
60
|
+
browserKind,
|
|
61
|
+
browserDisplayName,
|
|
62
|
+
browserExecutablePath,
|
|
37
63
|
port,
|
|
38
64
|
profileDir,
|
|
39
65
|
pidFile: join(profileDir, "chrome.pid"),
|
|
@@ -48,6 +74,31 @@ const CHROME_PORT = BROWSER_CONFIG.port;
|
|
|
48
74
|
|
|
49
75
|
export const BROWSER_PROFILE_DIR = BROWSER_CONFIG.profileDir;
|
|
50
76
|
|
|
77
|
+
function browserSummary() {
|
|
78
|
+
return {
|
|
79
|
+
kind: BROWSER_CONFIG.browserKind,
|
|
80
|
+
displayName: BROWSER_CONFIG.browserDisplayName,
|
|
81
|
+
executablePath: BROWSER_CONFIG.browserExecutablePath,
|
|
82
|
+
profileDir: BROWSER_CONFIG.profileDir,
|
|
83
|
+
port: BROWSER_CONFIG.port,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function ensureBrowserSelectionUsable() {
|
|
88
|
+
if (BROWSER_CONFIG.browserKind === "cloak" && !BROWSER_CONFIG.browserExecutablePath) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
[
|
|
91
|
+
"CloakBrowser selected for browser CUA, but no executable path was provided.",
|
|
92
|
+
"Install/pre-download CloakBrowser yourself, then set DM_CUA_BROWSER_PATH, DM_CUA_CLOAK_PATH, CLOAKBROWSER_BINARY_PATH, or CHROME_PATH.",
|
|
93
|
+
"DM does not bundle the compiled CloakBrowser binary because its upstream license allows use but not redistribution.",
|
|
94
|
+
].join(" "),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (BROWSER_CONFIG.browserExecutablePath && !existsSync(BROWSER_CONFIG.browserExecutablePath)) {
|
|
98
|
+
throw new Error(`${BROWSER_CONFIG.browserDisplayName} executable not found: ${BROWSER_CONFIG.browserExecutablePath}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
51
102
|
export function resolveBrowserHelperPaths() {
|
|
52
103
|
return {
|
|
53
104
|
launchScript: fileURLToPath(new URL("../../greedysearch-dm/bin/launch.mjs", import.meta.url)),
|
|
@@ -63,9 +114,11 @@ function browserEnv() {
|
|
|
63
114
|
"1";
|
|
64
115
|
return {
|
|
65
116
|
...process.env,
|
|
117
|
+
...(BROWSER_CONFIG.browserExecutablePath ? { CHROME_PATH: BROWSER_CONFIG.browserExecutablePath } : {}),
|
|
66
118
|
CDP_PROFILE_DIR: BROWSER_CONFIG.profileDir.replace(/\\/g, "/"),
|
|
67
119
|
CDP_PAGES_CACHE: BROWSER_CONFIG.pagesCache.replace(/\\/g, "/"),
|
|
68
120
|
CDP_SOCKET_DIR: BROWSER_CONFIG.socketDir.replace(/\\/g, "/"),
|
|
121
|
+
GREEDY_SEARCH_BROWSER_LABEL: BROWSER_CONFIG.browserDisplayName,
|
|
69
122
|
GREEDY_SEARCH_PORT: String(BROWSER_CONFIG.port),
|
|
70
123
|
GREEDY_SEARCH_PROFILE_DIR: BROWSER_CONFIG.profileDir.replace(/\\/g, "/"),
|
|
71
124
|
GREEDY_SEARCH_PID_FILE: BROWSER_CONFIG.pidFile.replace(/\\/g, "/"),
|
|
@@ -171,7 +224,7 @@ function requestJson(method, requestPath) {
|
|
|
171
224
|
});
|
|
172
225
|
response.on("end", () => {
|
|
173
226
|
if ((response.statusCode ?? 500) >= 400) {
|
|
174
|
-
rejectPromise(new Error(`
|
|
227
|
+
rejectPromise(new Error(`Browser CDP HTTP ${response.statusCode}: ${body.trim()}`));
|
|
175
228
|
return;
|
|
176
229
|
}
|
|
177
230
|
try {
|
|
@@ -216,6 +269,7 @@ async function ensureBrowserHelpersExist() {
|
|
|
216
269
|
}
|
|
217
270
|
|
|
218
271
|
export async function ensureBrowserReady() {
|
|
272
|
+
ensureBrowserSelectionUsable();
|
|
219
273
|
const { launchScript } = await ensureBrowserHelpersExist();
|
|
220
274
|
await runNodeScript(launchScript, [], { env: browserEnv(), timeoutMs: DEFAULT_TIMEOUT_MS });
|
|
221
275
|
let pages = await listPages();
|
|
@@ -252,7 +306,7 @@ export async function runBrowserAction({
|
|
|
252
306
|
switch (action) {
|
|
253
307
|
case "list": {
|
|
254
308
|
const pages = await ensureBrowserReady();
|
|
255
|
-
return { status: "ok", action, pages };
|
|
309
|
+
return { status: "ok", action, browser: browserSummary(), pages };
|
|
256
310
|
}
|
|
257
311
|
case "navigate": {
|
|
258
312
|
if (!url) throw new Error("url is required for action=navigate");
|
|
@@ -263,6 +317,7 @@ export async function runBrowserAction({
|
|
|
263
317
|
return {
|
|
264
318
|
status: "ok",
|
|
265
319
|
action,
|
|
320
|
+
browser: browserSummary(),
|
|
266
321
|
tab: context.tab,
|
|
267
322
|
url: page?.url || url,
|
|
268
323
|
title: page?.title || "",
|
|
@@ -276,6 +331,7 @@ export async function runBrowserAction({
|
|
|
276
331
|
return {
|
|
277
332
|
status: "ok",
|
|
278
333
|
action,
|
|
334
|
+
browser: browserSummary(),
|
|
279
335
|
tab: context.tab,
|
|
280
336
|
title: context.page?.title || "",
|
|
281
337
|
url: context.page?.url || "",
|
|
@@ -291,6 +347,7 @@ export async function runBrowserAction({
|
|
|
291
347
|
return {
|
|
292
348
|
status: "ok",
|
|
293
349
|
action,
|
|
350
|
+
browser: browserSummary(),
|
|
294
351
|
tab: context.tab,
|
|
295
352
|
title: context.page?.title || "",
|
|
296
353
|
url: context.page?.url || "",
|
|
@@ -307,6 +364,7 @@ export async function runBrowserAction({
|
|
|
307
364
|
return {
|
|
308
365
|
status: "ok",
|
|
309
366
|
action,
|
|
367
|
+
browser: browserSummary(),
|
|
310
368
|
tab: context.tab,
|
|
311
369
|
selector,
|
|
312
370
|
message: result.stdout.trim(),
|
|
@@ -321,6 +379,7 @@ export async function runBrowserAction({
|
|
|
321
379
|
return {
|
|
322
380
|
status: "ok",
|
|
323
381
|
action,
|
|
382
|
+
browser: browserSummary(),
|
|
324
383
|
tab: context.tab,
|
|
325
384
|
x,
|
|
326
385
|
y,
|
|
@@ -334,6 +393,7 @@ export async function runBrowserAction({
|
|
|
334
393
|
return {
|
|
335
394
|
status: "ok",
|
|
336
395
|
action,
|
|
396
|
+
browser: browserSummary(),
|
|
337
397
|
tab: context.tab,
|
|
338
398
|
textLength: text.length,
|
|
339
399
|
message: result.stdout.trim(),
|
|
@@ -346,6 +406,7 @@ export async function runBrowserAction({
|
|
|
346
406
|
return {
|
|
347
407
|
status: "ok",
|
|
348
408
|
action,
|
|
409
|
+
browser: browserSummary(),
|
|
349
410
|
tab: context.tab,
|
|
350
411
|
value: result.stdout.trim(),
|
|
351
412
|
};
|
|
@@ -361,6 +422,7 @@ export async function runBrowserAction({
|
|
|
361
422
|
return {
|
|
362
423
|
status: "ok",
|
|
363
424
|
action,
|
|
425
|
+
browser: browserSummary(),
|
|
364
426
|
message: (result.stdout || "Stopped browser CUA lane.").trim(),
|
|
365
427
|
};
|
|
366
428
|
}
|
|
@@ -63,4 +63,20 @@ test("resolveBrowserRuntimeConfig accepts explicit safe overrides", () => {
|
|
|
63
63
|
assert.equal(config.port, 9456);
|
|
64
64
|
assert.equal(config.instanceId, "session-one");
|
|
65
65
|
assert.equal(config.profileDir, "/tmp/custom-dm-cua-profile");
|
|
66
|
+
assert.equal(config.browserKind, "chrome");
|
|
67
|
+
assert.equal(config.browserDisplayName, "Chrome");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("resolveBrowserRuntimeConfig supports opt-in CloakBrowser executable path", () => {
|
|
71
|
+
const config = resolveBrowserRuntimeConfig(
|
|
72
|
+
{
|
|
73
|
+
DM_CUA_BROWSER: "cloakbrowser",
|
|
74
|
+
CLOAKBROWSER_BINARY_PATH: "/opt/cloak/Chromium.app/Contents/MacOS/Chromium",
|
|
75
|
+
},
|
|
76
|
+
"/tmp/dm-cua-project-c",
|
|
77
|
+
);
|
|
78
|
+
assert.equal(config.browserKind, "cloak");
|
|
79
|
+
assert.equal(config.browserDisplayName, "CloakBrowser");
|
|
80
|
+
assert.equal(config.browserExecutablePath, "/opt/cloak/Chromium.app/Contents/MacOS/Chromium");
|
|
81
|
+
assert.match(config.profileDir, /dm-cua-cloak-profile-[a-f0-9]{12}$/);
|
|
66
82
|
});
|
|
@@ -87,7 +87,7 @@ describe("registerCommands", () => {
|
|
|
87
87
|
);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
it("returns
|
|
90
|
+
it("returns restored autocomplete while hiding repair-only account identifiers", () => {
|
|
91
91
|
const registerCommand = vi.fn();
|
|
92
92
|
registerCommands(
|
|
93
93
|
{ registerCommand } as never,
|
|
@@ -102,21 +102,29 @@ describe("registerCommands", () => {
|
|
|
102
102
|
};
|
|
103
103
|
|
|
104
104
|
const subcommands = commandOptions.getArgumentCompletions("");
|
|
105
|
-
expect(subcommands?.map((item) => item.value)).toEqual([
|
|
105
|
+
expect(subcommands?.map((item) => item.value)).toEqual([
|
|
106
|
+
"sync",
|
|
107
|
+
"use",
|
|
108
|
+
"show",
|
|
109
|
+
"rotation",
|
|
110
|
+
"verify",
|
|
111
|
+
"path",
|
|
112
|
+
"reset",
|
|
113
|
+
"help",
|
|
114
|
+
]);
|
|
106
115
|
expect(subcommands?.map((item) => item.value)).not.toContain("accounts");
|
|
107
|
-
expect(subcommands?.map((item) => item.value)).not.toContain("show");
|
|
108
|
-
expect(subcommands?.map((item) => item.value)).not.toContain("use");
|
|
109
116
|
expect(subcommands?.map((item) => item.value)).not.toContain("refresh");
|
|
110
|
-
expect(subcommands?.map((item) => item.value)).not.toContain("rotation");
|
|
111
117
|
expect(subcommands?.map((item) => item.value)).not.toContain("reauth");
|
|
112
118
|
expect(subcommands?.map((item) => item.value)).not.toContain("footer");
|
|
113
|
-
expect(subcommands?.map((item) => item.value)).not.toContain("verify");
|
|
114
|
-
expect(subcommands?.map((item) => item.value)).not.toContain("path");
|
|
115
|
-
expect(subcommands?.map((item) => item.value)).not.toContain("reset");
|
|
116
|
-
expect(subcommands?.map((item) => item.value)).not.toContain("help");
|
|
117
119
|
|
|
118
120
|
const useAccounts = commandOptions.getArgumentCompletions("use a");
|
|
119
|
-
expect(useAccounts).
|
|
121
|
+
expect(useAccounts).toEqual([
|
|
122
|
+
{ value: "use alpha@example.com", label: "alpha@example.com" },
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
expect(commandOptions.getArgumentCompletions("reset m")).toEqual([
|
|
126
|
+
{ value: "reset manual", label: "manual" },
|
|
127
|
+
]);
|
|
120
128
|
|
|
121
129
|
const refreshAccounts = commandOptions.getArgumentCompletions("refresh a");
|
|
122
130
|
expect(refreshAccounts).toBeNull();
|
|
@@ -140,12 +148,12 @@ describe("registerCommands", () => {
|
|
|
140
148
|
});
|
|
141
149
|
|
|
142
150
|
expect(notify).toHaveBeenCalledWith(
|
|
143
|
-
"/ultradex requires a subcommand in non-interactive mode. Use /ultradex
|
|
151
|
+
"/ultradex requires a subcommand in non-interactive mode. Use /ultradex help.",
|
|
144
152
|
"warning",
|
|
145
153
|
);
|
|
146
154
|
});
|
|
147
155
|
|
|
148
|
-
it("hides
|
|
156
|
+
it("hides repair-only subcommands even when typed directly", async () => {
|
|
149
157
|
const registerCommand = vi.fn();
|
|
150
158
|
registerCommands(
|
|
151
159
|
{ registerCommand } as never,
|
|
@@ -162,7 +170,57 @@ describe("registerCommands", () => {
|
|
|
162
170
|
ui: { notify },
|
|
163
171
|
});
|
|
164
172
|
|
|
165
|
-
expect(notify).toHaveBeenCalledWith(
|
|
173
|
+
expect(notify).toHaveBeenCalledWith(
|
|
174
|
+
expect.stringContaining(
|
|
175
|
+
"use: select, activate, or remove managed account",
|
|
176
|
+
),
|
|
177
|
+
"info",
|
|
178
|
+
);
|
|
179
|
+
const helpText = String(notify.mock.calls.at(-1)?.[0] ?? "");
|
|
180
|
+
for (const label of [
|
|
181
|
+
"sync: download and decrypt DuckMind managed accounts",
|
|
182
|
+
"use: select, activate, or remove managed account",
|
|
183
|
+
"show: managed account and usage summary",
|
|
184
|
+
"rotation: current rotation behavior",
|
|
185
|
+
"verify: runtime health checks",
|
|
186
|
+
"path: storage and settings locations",
|
|
187
|
+
"reset: clear manual or quota state",
|
|
188
|
+
"help: command usage",
|
|
189
|
+
]) {
|
|
190
|
+
expect(helpText).toContain(label);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("runs restored state subcommands instead of falling back to sync-only help", async () => {
|
|
195
|
+
const registerCommand = vi.fn();
|
|
196
|
+
const accountManager = {
|
|
197
|
+
getAccounts: () => [],
|
|
198
|
+
hasManualAccount: vi.fn(() => true),
|
|
199
|
+
clearManualAccount: vi.fn(),
|
|
200
|
+
clearAllQuotaExhaustion: vi.fn(() => 0),
|
|
201
|
+
} as unknown as AccountManager;
|
|
202
|
+
const statusController = createStatusControllerMock();
|
|
203
|
+
registerCommands(
|
|
204
|
+
{ registerCommand } as never,
|
|
205
|
+
accountManager,
|
|
206
|
+
statusController,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const commandOptions = registerCommand.mock.calls[0]?.[1] as {
|
|
210
|
+
handler: (args: string, ctx: unknown) => Promise<void>;
|
|
211
|
+
};
|
|
212
|
+
const notify = vi.fn();
|
|
213
|
+
await commandOptions.handler("reset manual", {
|
|
214
|
+
hasUI: false,
|
|
215
|
+
ui: { notify },
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(accountManager.clearManualAccount).toHaveBeenCalledOnce();
|
|
219
|
+
expect(mocks.syncManagedAccountsFromDuckMind).not.toHaveBeenCalled();
|
|
220
|
+
expect(notify).toHaveBeenCalledWith(
|
|
221
|
+
expect.stringContaining("reset: target=manual"),
|
|
222
|
+
"info",
|
|
223
|
+
);
|
|
166
224
|
});
|
|
167
225
|
|
|
168
226
|
it("uses the explicit sync secret without remembering it after a successful sync", async () => {
|
|
@@ -33,8 +33,17 @@ import { formatResetAt, isUsageUntouched } from "./usage";
|
|
|
33
33
|
const SETTINGS_FILE = getAgentSettingsPath();
|
|
34
34
|
const NO_ACCOUNTS_MESSAGE =
|
|
35
35
|
"No managed accounts found. Run /ultradex sync <secret> to import DuckMind managed accounts.";
|
|
36
|
-
const HELP_TEXT =
|
|
37
|
-
"Usage: /ultradex sync [secret]"
|
|
36
|
+
const HELP_TEXT = [
|
|
37
|
+
"Usage: /ultradex [sync [secret]|use [identifier]|show|rotation|verify|path|reset [manual|quota|all]|help]",
|
|
38
|
+
"sync: download and decrypt DuckMind managed accounts",
|
|
39
|
+
"use: select, activate, or remove managed account",
|
|
40
|
+
"show: managed account and usage summary",
|
|
41
|
+
"rotation: current rotation behavior",
|
|
42
|
+
"verify: runtime health checks",
|
|
43
|
+
"path: storage and settings locations",
|
|
44
|
+
"reset: clear manual or quota state",
|
|
45
|
+
"help: command usage",
|
|
46
|
+
].join("\n");
|
|
38
47
|
const SUBCOMMANDS = [
|
|
39
48
|
"accounts",
|
|
40
49
|
"use",
|
|
@@ -52,6 +61,13 @@ const SUBCOMMANDS = [
|
|
|
52
61
|
const RESET_TARGETS = ["manual", "quota", "all"] as const;
|
|
53
62
|
const VISIBLE_SUBCOMMANDS = [
|
|
54
63
|
"sync",
|
|
64
|
+
"use",
|
|
65
|
+
"show",
|
|
66
|
+
"rotation",
|
|
67
|
+
"verify",
|
|
68
|
+
"path",
|
|
69
|
+
"reset",
|
|
70
|
+
"help",
|
|
55
71
|
] as const;
|
|
56
72
|
|
|
57
73
|
type Subcommand = (typeof SUBCOMMANDS)[number];
|
|
@@ -156,6 +172,10 @@ function getSubcommandCompletions(prefix: string): AutocompleteItem[] | null {
|
|
|
156
172
|
return matches.length > 0 ? toAutocompleteItems(matches) : null;
|
|
157
173
|
}
|
|
158
174
|
|
|
175
|
+
function isVisibleSubcommand(value: Subcommand): boolean {
|
|
176
|
+
return VISIBLE_SUBCOMMANDS.some((subcommand) => subcommand === value);
|
|
177
|
+
}
|
|
178
|
+
|
|
159
179
|
function getAccountCompletions(
|
|
160
180
|
subcommand: "accounts" | "use" | "reauth",
|
|
161
181
|
prefix: string,
|
|
@@ -208,6 +228,16 @@ function getCommandCompletions(
|
|
|
208
228
|
return getSubcommandCompletions(trimmedStart.toLowerCase());
|
|
209
229
|
}
|
|
210
230
|
|
|
231
|
+
const subcommand = trimmedStart.slice(0, firstSpaceIndex).toLowerCase();
|
|
232
|
+
const rest = trimmedStart.slice(firstSpaceIndex + 1).trimStart();
|
|
233
|
+
|
|
234
|
+
if (subcommand === "use") {
|
|
235
|
+
return getAccountCompletions("use", rest, accountManager);
|
|
236
|
+
}
|
|
237
|
+
if (subcommand === "reset") {
|
|
238
|
+
return getResetCompletions(rest);
|
|
239
|
+
}
|
|
240
|
+
|
|
211
241
|
return null;
|
|
212
242
|
}
|
|
213
243
|
|
|
@@ -1016,6 +1046,13 @@ async function openMainPanel(
|
|
|
1016
1046
|
): Promise<void> {
|
|
1017
1047
|
const actions = [
|
|
1018
1048
|
"sync: download and decrypt DuckMind managed accounts",
|
|
1049
|
+
"use: select, activate, or remove managed account",
|
|
1050
|
+
"show: managed account and usage summary",
|
|
1051
|
+
"rotation: current rotation behavior",
|
|
1052
|
+
"verify: runtime health checks",
|
|
1053
|
+
"path: storage and settings locations",
|
|
1054
|
+
"reset: clear manual or quota state",
|
|
1055
|
+
"help: command usage",
|
|
1019
1056
|
];
|
|
1020
1057
|
|
|
1021
1058
|
const selected = await ctx.ui.select("Ultradex", actions);
|
|
@@ -1043,7 +1080,7 @@ export function registerCommands(
|
|
|
1043
1080
|
): void {
|
|
1044
1081
|
pi.registerCommand("ultradex", {
|
|
1045
1082
|
description:
|
|
1046
|
-
"
|
|
1083
|
+
"Manage Ultradex sync, account selection, status, rotation, and health",
|
|
1047
1084
|
getArgumentCompletions: (argumentPrefix: string) =>
|
|
1048
1085
|
getCommandCompletions(argumentPrefix, accountManager),
|
|
1049
1086
|
handler: async (
|
|
@@ -1054,7 +1091,7 @@ export function registerCommands(
|
|
|
1054
1091
|
if (!parsed.subcommand) {
|
|
1055
1092
|
if (!ctx.hasUI) {
|
|
1056
1093
|
ctx.ui.notify(
|
|
1057
|
-
"/ultradex requires a subcommand in non-interactive mode. Use /ultradex
|
|
1094
|
+
"/ultradex requires a subcommand in non-interactive mode. Use /ultradex help.",
|
|
1058
1095
|
"warning",
|
|
1059
1096
|
);
|
|
1060
1097
|
return;
|
|
@@ -1063,17 +1100,17 @@ export function registerCommands(
|
|
|
1063
1100
|
return;
|
|
1064
1101
|
}
|
|
1065
1102
|
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1103
|
+
if (!isSubcommand(parsed.subcommand)) {
|
|
1104
|
+
ctx.ui.notify(`Unknown subcommand: ${parsed.subcommand}`, "warning");
|
|
1105
|
+
runHelpSubcommand(ctx);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
if (!isVisibleSubcommand(parsed.subcommand)) {
|
|
1109
|
+
runHelpSubcommand(ctx);
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1075
1112
|
|
|
1076
|
-
|
|
1113
|
+
await runSubcommand(
|
|
1077
1114
|
parsed.subcommand,
|
|
1078
1115
|
parsed.rest,
|
|
1079
1116
|
pi,
|
|
@@ -3605,9 +3605,9 @@
|
|
|
3605
3605
|
"license": "MIT"
|
|
3606
3606
|
},
|
|
3607
3607
|
"node_modules/cosmiconfig": {
|
|
3608
|
-
"version": "9.0.
|
|
3609
|
-
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.
|
|
3610
|
-
"integrity": "sha512-
|
|
3608
|
+
"version": "9.0.2",
|
|
3609
|
+
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz",
|
|
3610
|
+
"integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==",
|
|
3611
3611
|
"dev": true,
|
|
3612
3612
|
"license": "MIT",
|
|
3613
3613
|
"dependencies": {
|
|
@@ -7448,9 +7448,9 @@
|
|
|
7448
7448
|
}
|
|
7449
7449
|
},
|
|
7450
7450
|
"node_modules/semver": {
|
|
7451
|
-
"version": "7.8.
|
|
7452
|
-
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.
|
|
7453
|
-
"integrity": "sha512-
|
|
7451
|
+
"version": "7.8.3",
|
|
7452
|
+
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz",
|
|
7453
|
+
"integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==",
|
|
7454
7454
|
"dev": true,
|
|
7455
7455
|
"license": "ISC",
|
|
7456
7456
|
"bin": {
|
|
@@ -42,6 +42,7 @@ const ACTIVE_PORT = join(PROFILE_DIR, "DevToolsActivePort");
|
|
|
42
42
|
const PID_FILE = process.env.GREEDY_SEARCH_PID_FILE || join(tmpdir(), "greedysearch-chrome.pid");
|
|
43
43
|
const MODE_FILE = process.env.GREEDY_SEARCH_MODE_FILE || join(tmpdir(), "greedysearch-chrome-mode");
|
|
44
44
|
const ALLOW_PORT_CLEANUP = /^(1|true|yes|on)$/i.test(process.env.GREEDY_SEARCH_ALLOW_PORT_CLEANUP ?? "1");
|
|
45
|
+
const BROWSER_LABEL = process.env.GREEDY_SEARCH_BROWSER_LABEL || "Chrome";
|
|
45
46
|
|
|
46
47
|
function findChrome() {
|
|
47
48
|
const os = platform();
|
|
@@ -283,11 +284,11 @@ function cleanupGhostChrome() {
|
|
|
283
284
|
|
|
284
285
|
if (!ALLOW_PORT_CLEANUP) {
|
|
285
286
|
console.error(
|
|
286
|
-
|
|
287
|
+
`${BROWSER_LABEL}/CDP port ${PORT} is already owned by untracked pid ${portPid}; refusing to kill it. Set GREEDY_SEARCH_PORT or DM_CUA_PORT to a free port.`,
|
|
287
288
|
);
|
|
288
289
|
process.exit(1);
|
|
289
290
|
}
|
|
290
|
-
console.log(`Ghost
|
|
291
|
+
console.log(`Ghost ${BROWSER_LABEL} on port ${PORT} (pid ${portPid}) — cleaning up...`);
|
|
291
292
|
killProcess(portPid);
|
|
292
293
|
try {
|
|
293
294
|
unlinkSync(PID_FILE);
|
|
@@ -351,10 +352,10 @@ async function main() {
|
|
|
351
352
|
if (pid) {
|
|
352
353
|
const ok = killProcess(pid);
|
|
353
354
|
console.log(
|
|
354
|
-
ok ? `Stopped
|
|
355
|
+
ok ? `Stopped ${BROWSER_LABEL} (pid ${pid}).` : `Failed to stop pid ${pid}.`,
|
|
355
356
|
);
|
|
356
357
|
} else {
|
|
357
|
-
console.log(
|
|
358
|
+
console.log(`GreedySearch ${BROWSER_LABEL} is not running.`);
|
|
358
359
|
}
|
|
359
360
|
try {
|
|
360
361
|
unlinkSync(PID_FILE);
|
|
@@ -386,7 +387,7 @@ async function main() {
|
|
|
386
387
|
!process.argv.includes("--headless");
|
|
387
388
|
if (isWantingVisible && isModeFileHeadless()) {
|
|
388
389
|
console.log(
|
|
389
|
-
`Headless
|
|
390
|
+
`Headless ${BROWSER_LABEL} running (pid ${existing}) but visible requested — killing...`,
|
|
390
391
|
);
|
|
391
392
|
killProcess(existing);
|
|
392
393
|
try {
|
|
@@ -399,7 +400,7 @@ async function main() {
|
|
|
399
400
|
} else {
|
|
400
401
|
const ready = await writePortFile(5000);
|
|
401
402
|
if (ready) {
|
|
402
|
-
console.log(`GreedySearch
|
|
403
|
+
console.log(`GreedySearch ${BROWSER_LABEL} already running (pid ${existing}).`);
|
|
403
404
|
return;
|
|
404
405
|
}
|
|
405
406
|
console.log(`Stale PID ${existing} — launching fresh.`);
|
|
@@ -411,13 +412,13 @@ async function main() {
|
|
|
411
412
|
|
|
412
413
|
const CHROME_EXE = process.env.CHROME_PATH || findChrome();
|
|
413
414
|
if (!CHROME_EXE) {
|
|
414
|
-
console.error(
|
|
415
|
+
console.error(`${BROWSER_LABEL} not found. Set CHROME_PATH env var.`);
|
|
415
416
|
process.exit(1);
|
|
416
417
|
}
|
|
417
418
|
|
|
418
419
|
mkdirSync(PROFILE_DIR, { recursive: true });
|
|
419
420
|
|
|
420
|
-
console.log(`Launching GreedySearch
|
|
421
|
+
console.log(`Launching GreedySearch ${BROWSER_LABEL} on port ${PORT}...`);
|
|
421
422
|
if (isHeadless()) {
|
|
422
423
|
console.log("Headless mode — no window will be shown");
|
|
423
424
|
} else if (!isVisible()) {
|
|
@@ -435,7 +436,7 @@ async function main() {
|
|
|
435
436
|
|
|
436
437
|
const portFileReady = await writePortFile();
|
|
437
438
|
if (!portFileReady) {
|
|
438
|
-
console.error(
|
|
439
|
+
console.error(`${BROWSER_LABEL} did not become ready within 15s.`);
|
|
439
440
|
process.exit(1);
|
|
440
441
|
}
|
|
441
442
|
|