@loadmill/droid-cua 2.22.0 → 2.24.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 +25 -0
- package/build/index.js +43 -1
- package/build/src/browser/playwright/actions.js +65 -1
- package/build/src/cli/skill-command.js +240 -0
- package/build/src/commands/help.js +2 -1
- package/build/src/core/cua-models.js +29 -11
- package/build/src/core/droid-cua-run-usage.js +16 -7
- package/build/src/core/execution-engine.js +334 -5
- package/build/src/core/prompts.js +1 -1
- package/build/src/core/report/render-html-report.js +8 -3
- package/build/src/core/report/run-log-model.js +42 -0
- package/build/src/core/runtime-config.js +23 -41
- package/build/src/core/session.js +6 -0
- package/build/src/core/tool-handlers/loadmill.js +3 -0
- package/build/src/core/tool-handlers/test-design-complete.js +1 -0
- package/build/src/core/tool-handlers/type-sensitive-text.js +1 -0
- package/build/src/device/android/actions.js +6 -2
- package/build/src/device/android/connection.js +58 -23
- package/build/src/device/assertions.js +15 -8
- package/build/src/device/ios/connection.js +36 -30
- package/build/src/device/openai.js +68 -2
- package/build/src/device/scroll-gesture.js +22 -9
- package/build/src/integrations/loadmill/client.js +20 -4
- package/build/src/modes/design-mode-ink.js +10 -1
- package/build/src/modes/design-mode.js +7 -1
- package/build/src/modes/execution-mode.js +84 -8
- package/build/src/prompts/design.js +21 -1
- package/build/src/utils/console-output.js +5 -0
- package/build/src/utils/cua-debug-tracer.js +50 -7
- package/package.json +2 -1
- package/skill/droid-cua/SKILL.md +36 -0
- package/skill/droid-cua/agents/openai.yaml +6 -0
- package/skill/droid-cua/assets/droid-cua-app-icon.png +0 -0
- package/skill/droid-cua/references/authoring-tests.md +39 -0
- package/skill/droid-cua/references/onboarding-and-troubleshooting.md +74 -0
- package/skill/droid-cua/references/project-and-auth.md +38 -0
- package/skill/droid-cua/references/running-and-healing.md +70 -0
- package/build/src/core/cua-backends.js +0 -11
package/README.md
CHANGED
|
@@ -134,6 +134,27 @@ Install:
|
|
|
134
134
|
npm install -g @loadmill/droid-cua
|
|
135
135
|
```
|
|
136
136
|
|
|
137
|
+
Install the bundled Agent Skill for your coding agent:
|
|
138
|
+
|
|
139
|
+
```sh
|
|
140
|
+
# Global installation (default)
|
|
141
|
+
droid-cua skill install cursor
|
|
142
|
+
droid-cua skill install claude-code
|
|
143
|
+
droid-cua skill install codex
|
|
144
|
+
|
|
145
|
+
# Or install in the current project
|
|
146
|
+
droid-cua skill install codex --local
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Use `droid-cua skill status <agent>` to inspect an installation and
|
|
150
|
+
`droid-cua skill uninstall <agent>` to remove it. Installed skill
|
|
151
|
+
files are never overwritten or removed after local modification unless `--force`
|
|
152
|
+
is supplied. After upgrading the npm package, update an installed skill explicitly:
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
droid-cua skill install <agent> --force
|
|
156
|
+
```
|
|
157
|
+
|
|
137
158
|
Examples:
|
|
138
159
|
```sh
|
|
139
160
|
# Interactive CLI
|
|
@@ -230,6 +251,10 @@ Supported CLI options include:
|
|
|
230
251
|
- `--report`
|
|
231
252
|
- `--debug`
|
|
232
253
|
|
|
254
|
+
`--cua-model` accepts `loadmill-smart`, `loadmill-beacon`, `gpt-5.6-terra`,
|
|
255
|
+
`gpt-5.6-luna`, and `gpt-5.4`. OpenAI models use direct OpenAI mode and require
|
|
256
|
+
`OPENAI_API_KEY`.
|
|
257
|
+
|
|
233
258
|
Config and precedence rules:
|
|
234
259
|
- Use `--config <file>` to supply prompt-affecting settings for headless runs.
|
|
235
260
|
- CLI flags override config file values.
|
package/build/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import { selectDevice } from "./src/cli/device-selector.js";
|
|
|
15
15
|
import { resolveExecutionAppContext } from "./src/core/app-context.js";
|
|
16
16
|
import { resolveExecutionTestData } from "./src/core/test-data.js";
|
|
17
17
|
import { getDefaultSecretsPath, resolveExecutionSecrets } from "./src/core/secrets.js";
|
|
18
|
-
import { createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
|
|
18
|
+
import { assertRuntimeCredentialsForProvider, createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
|
|
19
19
|
import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
|
|
20
20
|
import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
|
|
21
21
|
import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
|
|
@@ -29,8 +29,20 @@ import { createStatusLine } from "./src/cli/cloud-status-line.js";
|
|
|
29
29
|
import { createLoadmillApiClient } from "./src/integrations/loadmill/client.js";
|
|
30
30
|
import { getCloudProviderAdapter } from "./src/device/cloud/registry.js";
|
|
31
31
|
import { setCloudProgressListener } from "./src/device/cloud/progress.js";
|
|
32
|
+
import { runSkillCommand } from "./src/cli/skill-command.js";
|
|
32
33
|
dotenv.config();
|
|
33
34
|
const args = minimist(process.argv.slice(2));
|
|
35
|
+
if (args._[0] === "skill") {
|
|
36
|
+
try {
|
|
37
|
+
await runSkillCommand({ args });
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
42
|
+
console.error(`\nSkill command failed: ${message}`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
34
46
|
const targetConfig = resolveHeadlessTarget(args);
|
|
35
47
|
let avdName = targetConfig.avdName;
|
|
36
48
|
let platform = targetConfig.platform || null; // 'ios' or 'android'
|
|
@@ -71,6 +83,33 @@ function createCliRuntimeContext(configOverrides = {}) {
|
|
|
71
83
|
loadmillClient,
|
|
72
84
|
};
|
|
73
85
|
}
|
|
86
|
+
async function assertCliRuntimeCredentials({ llmProviderMode }) {
|
|
87
|
+
const credentials = resolveRuntimeCredentialsFromEnv(process.env);
|
|
88
|
+
assertRuntimeCredentialsForProvider({
|
|
89
|
+
llmProviderMode,
|
|
90
|
+
credentials,
|
|
91
|
+
});
|
|
92
|
+
if (llmProviderMode !== "loadmill") {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const loadmillClient = createLoadmillApiClient({
|
|
96
|
+
token: credentials.loadmillApiToken,
|
|
97
|
+
baseUrl: credentials.loadmillBaseUrl,
|
|
98
|
+
});
|
|
99
|
+
let tokenValidation = null;
|
|
100
|
+
try {
|
|
101
|
+
tokenValidation = await loadmillClient.validateApiToken();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw new Error("Could not validate LOADMILL_API_TOKEN.");
|
|
105
|
+
}
|
|
106
|
+
if (tokenValidation?.reason === "invalid") {
|
|
107
|
+
throw new Error("LOADMILL_API_TOKEN is invalid.");
|
|
108
|
+
}
|
|
109
|
+
if (!tokenValidation?.valid) {
|
|
110
|
+
throw new Error("Could not validate LOADMILL_API_TOKEN.");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
74
113
|
function createLambdaTestCliBuildName() {
|
|
75
114
|
return `loadmill-droid-cua-cli-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
76
115
|
}
|
|
@@ -184,6 +223,9 @@ async function main() {
|
|
|
184
223
|
let preloadedSecrets = null;
|
|
185
224
|
if (isHeadlessInstructionsRun) {
|
|
186
225
|
preloadedHeadlessConfig = await resolveHeadlessExecutionConfig(args);
|
|
226
|
+
await assertCliRuntimeCredentials({
|
|
227
|
+
llmProviderMode: preloadedHeadlessConfig.llmProviderMode,
|
|
228
|
+
});
|
|
187
229
|
preloadedHeadlessTarget = await resolveHeadlessInstructionsTarget(instructionsFile);
|
|
188
230
|
preloadedSecrets = await resolveExecutionSecrets({
|
|
189
231
|
secretsPath: getDefaultSecretsPath(process.cwd()),
|
|
@@ -3,7 +3,8 @@ import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js
|
|
|
3
3
|
import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
|
|
4
4
|
import { buildActionEventMeta } from "../../device/action-event-meta.js";
|
|
5
5
|
import { actionForErrorLog, buildActionErrorOutput, buildDebugTextField, buildSensitiveDebugFields, buildTypeActionPayload, buildTypeActionText, isSensitiveAction, } from "../../device/sensitive-action.js";
|
|
6
|
-
import {
|
|
6
|
+
import { normalizeAutomationUrl } from "./url.js";
|
|
7
|
+
import { getActiveBrowserSession, getActiveBrowserPage } from "./session-state.js";
|
|
7
8
|
const KEY_ALIASES = {
|
|
8
9
|
CTRL: "Control",
|
|
9
10
|
CONTROL: "Control",
|
|
@@ -58,8 +59,47 @@ function buildMeta(context, action, payload = {}) {
|
|
|
58
59
|
payload,
|
|
59
60
|
});
|
|
60
61
|
}
|
|
62
|
+
function handleNavigationTimeout(error, description) {
|
|
63
|
+
if (!(error instanceof Error) || error.name !== "TimeoutError") {
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
console.warn(`Page load did not finish within the timeout for ${description}. Continuing with the current page state.`);
|
|
67
|
+
}
|
|
68
|
+
async function gotoWithTimeoutTolerance(page, url) {
|
|
69
|
+
try {
|
|
70
|
+
await page.goto(url, { waitUntil: "load" });
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
handleNavigationTimeout(error, url);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function goBackWithTimeoutTolerance(page) {
|
|
77
|
+
try {
|
|
78
|
+
await page.goBack({ waitUntil: "load" });
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
handleNavigationTimeout(error, "browser history back");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function goForwardWithTimeoutTolerance(page) {
|
|
85
|
+
try {
|
|
86
|
+
await page.goForward({ waitUntil: "load" });
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
handleNavigationTimeout(error, "browser history forward");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function updateSessionUrlFromPage(session, page) {
|
|
93
|
+
const currentUrl = typeof page.url === "function" ? page.url() : null;
|
|
94
|
+
if (!currentUrl) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
session.pageUrls?.set?.(page, currentUrl);
|
|
98
|
+
session.url = currentUrl;
|
|
99
|
+
}
|
|
61
100
|
export async function handleModelAction(browserSessionId, action, scale = 1, context = null) {
|
|
62
101
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
102
|
+
const session = getActiveBrowserSession(browserSessionId);
|
|
63
103
|
const page = getActiveBrowserPage(browserSessionId);
|
|
64
104
|
try {
|
|
65
105
|
emitDesktopDebug("browser.action.execute", "device", {
|
|
@@ -166,6 +206,30 @@ export async function handleModelAction(browserSessionId, action, scale = 1, con
|
|
|
166
206
|
await page.waitForTimeout(timeoutMs);
|
|
167
207
|
break;
|
|
168
208
|
}
|
|
209
|
+
case "goto": {
|
|
210
|
+
const normalizedUrl = normalizeAutomationUrl(action.url);
|
|
211
|
+
addOutput({
|
|
212
|
+
type: "action",
|
|
213
|
+
text: `Navigating to ${normalizedUrl}`,
|
|
214
|
+
...buildMeta(context, action, { url: normalizedUrl }),
|
|
215
|
+
});
|
|
216
|
+
await gotoWithTimeoutTolerance(page, normalizedUrl);
|
|
217
|
+
session.pageUrls?.set?.(page, normalizedUrl);
|
|
218
|
+
session.url = normalizedUrl;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
case "back": {
|
|
222
|
+
addOutput({ type: "action", text: "Going back", ...buildMeta(context, action) });
|
|
223
|
+
await goBackWithTimeoutTolerance(page);
|
|
224
|
+
updateSessionUrlFromPage(session, page);
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case "forward": {
|
|
228
|
+
addOutput({ type: "action", text: "Going forward", ...buildMeta(context, action) });
|
|
229
|
+
await goForwardWithTimeoutTolerance(page);
|
|
230
|
+
updateSessionUrlFromPage(session, page);
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
169
233
|
default:
|
|
170
234
|
addOutput({ type: "info", text: `Unknown action: ${JSON.stringify(action)}` });
|
|
171
235
|
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { cp, lstat, mkdir, readdir, readFile, rm } from "node:fs/promises";
|
|
5
|
+
const SKILL_NAME = "droid-cua";
|
|
6
|
+
const AGENTS = Object.freeze({
|
|
7
|
+
cursor: { displayName: "Cursor", relativePath: path.join(".cursor", "skills", SKILL_NAME) },
|
|
8
|
+
"claude-code": { displayName: "Claude Code", relativePath: path.join(".claude", "skills", SKILL_NAME) },
|
|
9
|
+
codex: { displayName: "Codex", relativePath: path.join(".agents", "skills", SKILL_NAME) },
|
|
10
|
+
});
|
|
11
|
+
const SUBCOMMANDS = new Set(["install", "status", "uninstall"]);
|
|
12
|
+
const ALLOWED_OPTIONS = new Set(["_", "local", "force", "h", "help"]);
|
|
13
|
+
const COMMAND_DESCRIPTIONS = Object.freeze({
|
|
14
|
+
install: "Install Droid CUA skill for a coding agent",
|
|
15
|
+
status: "Show Droid CUA skill status for a coding agent",
|
|
16
|
+
uninstall: "Uninstall Droid CUA skill for a coding agent",
|
|
17
|
+
});
|
|
18
|
+
function skillCommandHelp(subcommand) {
|
|
19
|
+
const localDescriptions = {
|
|
20
|
+
install: "Install in current project instead of global",
|
|
21
|
+
status: "Check current project instead of global",
|
|
22
|
+
uninstall: "Uninstall from current project instead of global",
|
|
23
|
+
};
|
|
24
|
+
const options = [
|
|
25
|
+
` --local ${localDescriptions[subcommand]}`,
|
|
26
|
+
];
|
|
27
|
+
if (subcommand !== "status") {
|
|
28
|
+
options.push(" --force Overwrite or remove modified skill files");
|
|
29
|
+
}
|
|
30
|
+
options.push(" -h, --help display help for command");
|
|
31
|
+
return [
|
|
32
|
+
`Usage: droid-cua skill ${subcommand} [options] [agent]`,
|
|
33
|
+
"",
|
|
34
|
+
COMMAND_DESCRIPTIONS[subcommand],
|
|
35
|
+
"",
|
|
36
|
+
"Agents:",
|
|
37
|
+
" cursor",
|
|
38
|
+
" claude-code",
|
|
39
|
+
" codex",
|
|
40
|
+
"",
|
|
41
|
+
"Example:",
|
|
42
|
+
` droid-cua skill ${subcommand} claude-code`,
|
|
43
|
+
"",
|
|
44
|
+
"Options:",
|
|
45
|
+
...options,
|
|
46
|
+
].join("\n");
|
|
47
|
+
}
|
|
48
|
+
function skillRootHelp() {
|
|
49
|
+
return [
|
|
50
|
+
"Usage: droid-cua skill <command> [options] [agent]",
|
|
51
|
+
"",
|
|
52
|
+
"Manage the bundled Droid CUA skill",
|
|
53
|
+
"",
|
|
54
|
+
"Commands:",
|
|
55
|
+
" install Install the skill",
|
|
56
|
+
" status Show installation status",
|
|
57
|
+
" uninstall Uninstall the skill",
|
|
58
|
+
].join("\n");
|
|
59
|
+
}
|
|
60
|
+
async function pathType(targetPath) {
|
|
61
|
+
try {
|
|
62
|
+
const stats = await lstat(targetPath);
|
|
63
|
+
if (stats.isDirectory())
|
|
64
|
+
return "directory";
|
|
65
|
+
if (stats.isFile())
|
|
66
|
+
return "file";
|
|
67
|
+
if (stats.isSymbolicLink())
|
|
68
|
+
return "symlink";
|
|
69
|
+
return "other";
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error?.code === "ENOENT")
|
|
73
|
+
return "missing";
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function snapshotDirectory(rootPath, relativePath = "") {
|
|
78
|
+
const absolutePath = relativePath ? path.join(rootPath, relativePath) : rootPath;
|
|
79
|
+
const entries = await readdir(absolutePath, { withFileTypes: true });
|
|
80
|
+
const snapshot = [];
|
|
81
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
82
|
+
const childRelativePath = relativePath ? path.join(relativePath, entry.name) : entry.name;
|
|
83
|
+
if (entry.isDirectory()) {
|
|
84
|
+
snapshot.push({ path: `${childRelativePath}/`, type: "directory" });
|
|
85
|
+
snapshot.push(...await snapshotDirectory(rootPath, childRelativePath));
|
|
86
|
+
}
|
|
87
|
+
else if (entry.isFile()) {
|
|
88
|
+
const contents = await readFile(path.join(rootPath, childRelativePath));
|
|
89
|
+
snapshot.push({ path: childRelativePath, type: "file", contents: contents.toString("base64") });
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
snapshot.push({ path: childRelativePath, type: entry.isSymbolicLink() ? "symlink" : "other" });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return snapshot;
|
|
96
|
+
}
|
|
97
|
+
async function directoryMatches(sourcePath, destinationPath) {
|
|
98
|
+
if (await pathType(destinationPath) !== "directory")
|
|
99
|
+
return false;
|
|
100
|
+
const [sourceSnapshot, destinationSnapshot] = await Promise.all([
|
|
101
|
+
snapshotDirectory(sourcePath),
|
|
102
|
+
snapshotDirectory(destinationPath),
|
|
103
|
+
]);
|
|
104
|
+
return JSON.stringify(sourceSnapshot) === JSON.stringify(destinationSnapshot);
|
|
105
|
+
}
|
|
106
|
+
async function getInstallationState(sourcePath, destinationPath) {
|
|
107
|
+
if (await pathType(destinationPath) === "missing")
|
|
108
|
+
return "not-installed";
|
|
109
|
+
return await directoryMatches(sourcePath, destinationPath) ? "installed" : "modified";
|
|
110
|
+
}
|
|
111
|
+
function normalizeStringOption(value, label) {
|
|
112
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
113
|
+
throw new Error(`${label} requires a value.`);
|
|
114
|
+
}
|
|
115
|
+
return value.trim().toLowerCase();
|
|
116
|
+
}
|
|
117
|
+
function parseSkillCommandArgs(args) {
|
|
118
|
+
const unknownOption = Object.keys(args).find((key) => !ALLOWED_OPTIONS.has(key));
|
|
119
|
+
if (unknownOption)
|
|
120
|
+
throw new Error(`Unknown skill option --${unknownOption}.`);
|
|
121
|
+
if (!Array.isArray(args._) || args._[0] !== "skill") {
|
|
122
|
+
throw new Error("Usage: droid-cua skill <command> [options] [agent]");
|
|
123
|
+
}
|
|
124
|
+
if (args._.length === 1) {
|
|
125
|
+
return { help: skillRootHelp() };
|
|
126
|
+
}
|
|
127
|
+
const subcommand = normalizeStringOption(args._[1], "Skill subcommand");
|
|
128
|
+
if (!SUBCOMMANDS.has(subcommand)) {
|
|
129
|
+
throw new Error(`Unknown skill subcommand "${subcommand}". Expected install, status, or uninstall.`);
|
|
130
|
+
}
|
|
131
|
+
for (const option of ["local", "force", "h", "help"]) {
|
|
132
|
+
if (args[option] != null && typeof args[option] !== "boolean") {
|
|
133
|
+
throw new Error(`--${option === "h" ? "help" : option} does not accept a value.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (args.help === true || args.h === true || args._.length === 2) {
|
|
137
|
+
return { help: skillCommandHelp(subcommand) };
|
|
138
|
+
}
|
|
139
|
+
if (args._.length !== 3)
|
|
140
|
+
throw new Error(skillCommandHelp(subcommand));
|
|
141
|
+
if (subcommand === "status" && args.force != null) {
|
|
142
|
+
throw new Error("--force is only supported by install and uninstall.");
|
|
143
|
+
}
|
|
144
|
+
const agent = normalizeStringOption(args._[2], "Agent");
|
|
145
|
+
if (!Object.hasOwn(AGENTS, agent)) {
|
|
146
|
+
throw new Error(`Unknown agent "${agent}". Expected cursor, claude-code, or codex.`);
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
subcommand,
|
|
150
|
+
agent,
|
|
151
|
+
local: args.local === true,
|
|
152
|
+
force: args.force === true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
export function resolveSkillDestination({ agent, local = false, cwd = process.cwd(), home = os.homedir() }) {
|
|
156
|
+
const basePath = local ? cwd : home;
|
|
157
|
+
return {
|
|
158
|
+
agent,
|
|
159
|
+
...AGENTS[agent],
|
|
160
|
+
path: path.join(basePath, AGENTS[agent].relativePath),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
export async function resolveBundledSkillPath({ startDir = path.dirname(fileURLToPath(import.meta.url)) } = {}) {
|
|
164
|
+
let currentPath = path.resolve(startDir);
|
|
165
|
+
while (true) {
|
|
166
|
+
const candidate = path.join(currentPath, "skill", SKILL_NAME);
|
|
167
|
+
if (await pathType(path.join(candidate, "SKILL.md")) === "file")
|
|
168
|
+
return candidate;
|
|
169
|
+
const parentPath = path.dirname(currentPath);
|
|
170
|
+
if (parentPath === currentPath)
|
|
171
|
+
break;
|
|
172
|
+
currentPath = parentPath;
|
|
173
|
+
}
|
|
174
|
+
throw new Error("Bundled Droid CUA skill was not found. Reinstall @loadmill/droid-cua and try again.");
|
|
175
|
+
}
|
|
176
|
+
function stateLabel(state) {
|
|
177
|
+
if (state === "not-installed")
|
|
178
|
+
return "not installed";
|
|
179
|
+
return state;
|
|
180
|
+
}
|
|
181
|
+
function shouldUseColor() {
|
|
182
|
+
return Boolean(process.stdout.isTTY) && !Object.hasOwn(process.env, "NO_COLOR");
|
|
183
|
+
}
|
|
184
|
+
function formatInstallSuccess(destination, { alreadyInstalled, color }) {
|
|
185
|
+
const checkmark = color ? "\u001b[32m✓\u001b[39m" : "✓";
|
|
186
|
+
const skillFilePath = path.join(destination.path, "SKILL.md");
|
|
187
|
+
const displayedPath = color ? `\u001b[2m${skillFilePath}\u001b[22m` : skillFilePath;
|
|
188
|
+
const message = alreadyInstalled
|
|
189
|
+
? `${destination.agent} skill already installed`
|
|
190
|
+
: `Installed ${destination.agent} skill`;
|
|
191
|
+
return `${checkmark} ${message}\n${displayedPath}`;
|
|
192
|
+
}
|
|
193
|
+
export async function runSkillCommand({ args, cwd = process.cwd(), home = os.homedir(), sourcePath = null, output = console, color = shouldUseColor(), } = {}) {
|
|
194
|
+
const command = parseSkillCommandArgs(args ?? {});
|
|
195
|
+
if (command.help) {
|
|
196
|
+
output.log(command.help);
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
const bundledSkillPath = sourcePath ?? await resolveBundledSkillPath();
|
|
200
|
+
if (await pathType(path.join(bundledSkillPath, "SKILL.md")) !== "file") {
|
|
201
|
+
throw new Error(`Invalid bundled skill at ${bundledSkillPath}: SKILL.md is missing.`);
|
|
202
|
+
}
|
|
203
|
+
const destination = resolveSkillDestination({ agent: command.agent, local: command.local, cwd, home });
|
|
204
|
+
const states = [{
|
|
205
|
+
...destination,
|
|
206
|
+
state: await getInstallationState(bundledSkillPath, destination.path),
|
|
207
|
+
}];
|
|
208
|
+
if (command.subcommand !== "status" && !command.force) {
|
|
209
|
+
const conflicts = states.filter(({ state }) => state === "modified");
|
|
210
|
+
if (conflicts.length > 0) {
|
|
211
|
+
const paths = conflicts.map(({ path: destinationPath }) => destinationPath).join(", ");
|
|
212
|
+
throw new Error(`Refusing to ${command.subcommand} modified skill files at ${paths}. Re-run with --force to replace them.`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const destination of states) {
|
|
216
|
+
if (command.subcommand === "status") {
|
|
217
|
+
output.log(`${destination.displayName}: ${stateLabel(destination.state)} at ${destination.path}`);
|
|
218
|
+
}
|
|
219
|
+
else if (command.subcommand === "install") {
|
|
220
|
+
if (destination.state === "installed") {
|
|
221
|
+
output.log(formatInstallSuccess(destination, { alreadyInstalled: true, color }));
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
if (destination.state !== "not-installed")
|
|
225
|
+
await rm(destination.path, { recursive: true, force: true });
|
|
226
|
+
await mkdir(path.dirname(destination.path), { recursive: true });
|
|
227
|
+
await cp(bundledSkillPath, destination.path, { recursive: true });
|
|
228
|
+
output.log(formatInstallSuccess(destination, { alreadyInstalled: false, color }));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
else if (destination.state === "not-installed") {
|
|
232
|
+
output.log(`${destination.displayName}: not installed at ${destination.path}`);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
await rm(destination.path, { recursive: true, force: true });
|
|
236
|
+
output.log(`${destination.displayName}: uninstalled from ${destination.path}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return states.map(({ agent, path: destinationPath, state }) => ({ agent, path: destinationPath, state }));
|
|
240
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Help command handler
|
|
3
3
|
*/
|
|
4
|
+
import { CUA_MODELS } from '../core/cua-models.js';
|
|
4
5
|
/**
|
|
5
6
|
* Handle /help command
|
|
6
7
|
* @param {string} args - Command arguments
|
|
@@ -32,7 +33,7 @@ export async function handleHelp(args, session, context) {
|
|
|
32
33
|
addOutput({ type: 'info', text: ' --session-mode <mode> Browser session mode for web runs: persistent or fresh' });
|
|
33
34
|
addOutput({ type: 'info', text: ' --config <file> Headless execution JSON config for prompt parity' });
|
|
34
35
|
addOutput({ type: 'info', text: ' --llm-provider <mode> Headless AI provider: loadmill or openai' });
|
|
35
|
-
addOutput({ type: 'info', text:
|
|
36
|
+
addOutput({ type: 'info', text: ` --cua-model <model> Headless CUA model override: ${CUA_MODELS.join(', ')}` });
|
|
36
37
|
addOutput({ type: 'info', text: ' --aim-assist <mode> Headless Aim Assist mode: off, smart, or always' });
|
|
37
38
|
addOutput({ type: 'info', text: ' --context <file> Optional app context file injected into execution runs' });
|
|
38
39
|
addOutput({ type: 'info', text: ' --no-context Disable app context for headless execution' });
|
|
@@ -1,32 +1,50 @@
|
|
|
1
1
|
export const DEFAULT_OPENAI_CUA_MODEL = "gpt-5.4";
|
|
2
2
|
export const DEFAULT_LOADMILL_CUA_MODEL = "loadmill-smart";
|
|
3
3
|
export const DEFAULT_CUA_MODEL = DEFAULT_LOADMILL_CUA_MODEL;
|
|
4
|
-
export const OPENAI_CUA_MODELS = ["gpt-5.4", "gpt-5.4-mini"];
|
|
5
|
-
export const LOADMILL_CUA_MODELS = [DEFAULT_LOADMILL_CUA_MODEL];
|
|
6
|
-
export const CUA_MODELS = ["gpt-5.4", "loadmill-smart", "gpt-5.4-mini"];
|
|
7
4
|
export const CUA_MODEL_OPTIONS = Object.freeze([
|
|
8
5
|
Object.freeze({
|
|
9
6
|
id: "loadmill-smart",
|
|
10
7
|
providerMode: "loadmill",
|
|
11
8
|
providerLabel: "Loadmill",
|
|
12
|
-
name: "Loadmill Smart",
|
|
13
|
-
label: "Loadmill Smart (recommended)",
|
|
9
|
+
name: "Loadmill - Smart",
|
|
10
|
+
label: "Loadmill - Smart (recommended)",
|
|
14
11
|
}),
|
|
15
12
|
Object.freeze({
|
|
16
|
-
id: "
|
|
13
|
+
id: "loadmill-beacon",
|
|
14
|
+
providerMode: "loadmill",
|
|
15
|
+
providerLabel: "Loadmill",
|
|
16
|
+
name: "Loadmill - Beacon",
|
|
17
|
+
label: "Loadmill - Beacon",
|
|
18
|
+
}),
|
|
19
|
+
Object.freeze({
|
|
20
|
+
id: "gpt-5.6-terra",
|
|
17
21
|
providerMode: "openai",
|
|
18
22
|
providerLabel: "OpenAI",
|
|
19
|
-
name: "GPT-5.
|
|
20
|
-
label: "OpenAI GPT-5.
|
|
23
|
+
name: "GPT-5.6 Terra",
|
|
24
|
+
label: "OpenAI GPT-5.6 Terra",
|
|
21
25
|
}),
|
|
22
26
|
Object.freeze({
|
|
23
|
-
id: "gpt-5.
|
|
27
|
+
id: "gpt-5.6-luna",
|
|
24
28
|
providerMode: "openai",
|
|
25
29
|
providerLabel: "OpenAI",
|
|
26
|
-
name: "GPT-5.
|
|
27
|
-
label: "OpenAI GPT-5.
|
|
30
|
+
name: "GPT-5.6 Luna",
|
|
31
|
+
label: "OpenAI GPT-5.6 Luna",
|
|
32
|
+
}),
|
|
33
|
+
Object.freeze({
|
|
34
|
+
id: "gpt-5.4",
|
|
35
|
+
providerMode: "openai",
|
|
36
|
+
providerLabel: "OpenAI",
|
|
37
|
+
name: "GPT-5.4",
|
|
38
|
+
label: "OpenAI GPT-5.4",
|
|
28
39
|
}),
|
|
29
40
|
]);
|
|
41
|
+
export const CUA_MODELS = Object.freeze(CUA_MODEL_OPTIONS.map((option) => option.id));
|
|
42
|
+
export const OPENAI_CUA_MODELS = Object.freeze(CUA_MODEL_OPTIONS
|
|
43
|
+
.filter((option) => option.providerMode === "openai")
|
|
44
|
+
.map((option) => option.id));
|
|
45
|
+
export const LOADMILL_CUA_MODELS = Object.freeze(CUA_MODEL_OPTIONS
|
|
46
|
+
.filter((option) => option.providerMode === "loadmill")
|
|
47
|
+
.map((option) => option.id));
|
|
30
48
|
const VALID_CUA_MODELS = new Set(CUA_MODELS);
|
|
31
49
|
const OPENAI_CUA_MODEL_SET = new Set(OPENAI_CUA_MODELS);
|
|
32
50
|
const LOADMILL_CUA_MODEL_SET = new Set(LOADMILL_CUA_MODELS);
|
|
@@ -2,16 +2,22 @@ const TOKENS_PER_MILLION = 1_000_000;
|
|
|
2
2
|
const OPENAI_GPT_5_4_PRICING = Object.freeze({
|
|
3
3
|
inputPerMillionUsd: 2.5,
|
|
4
4
|
});
|
|
5
|
-
const
|
|
6
|
-
inputPerMillionUsd:
|
|
5
|
+
const OPENAI_GPT_5_6_TERRA_PRICING = Object.freeze({
|
|
6
|
+
inputPerMillionUsd: 2.5,
|
|
7
|
+
});
|
|
8
|
+
const OPENAI_GPT_5_6_LUNA_PRICING = Object.freeze({
|
|
9
|
+
inputPerMillionUsd: 1,
|
|
7
10
|
});
|
|
8
11
|
const MODEL_PRICING = Object.freeze({
|
|
12
|
+
"gpt-5.6-terra": Object.freeze({
|
|
13
|
+
...OPENAI_GPT_5_6_TERRA_PRICING,
|
|
14
|
+
}),
|
|
15
|
+
"gpt-5.6-luna": Object.freeze({
|
|
16
|
+
...OPENAI_GPT_5_6_LUNA_PRICING,
|
|
17
|
+
}),
|
|
9
18
|
"gpt-5.4": Object.freeze({
|
|
10
19
|
...OPENAI_GPT_5_4_PRICING,
|
|
11
20
|
}),
|
|
12
|
-
"gpt-5.4-mini": Object.freeze({
|
|
13
|
-
...OPENAI_GPT_5_4_MINI_PRICING,
|
|
14
|
-
}),
|
|
15
21
|
// Loadmill Smart currently routes to GPT-5.4 behind the Loadmill LLM gateway.
|
|
16
22
|
// Keep this explicit so pricing can be updated here if the backing model changes.
|
|
17
23
|
"loadmill-smart": Object.freeze({
|
|
@@ -32,8 +38,11 @@ export function normalizeDroidCuaCostModel(model) {
|
|
|
32
38
|
if (typeof model !== "string") {
|
|
33
39
|
return null;
|
|
34
40
|
}
|
|
35
|
-
if (model === "gpt-5.
|
|
36
|
-
return "gpt-5.
|
|
41
|
+
if (model === "gpt-5.6-terra" || model.startsWith("gpt-5.6-terra-")) {
|
|
42
|
+
return "gpt-5.6-terra";
|
|
43
|
+
}
|
|
44
|
+
if (model === "gpt-5.6-luna" || model.startsWith("gpt-5.6-luna-")) {
|
|
45
|
+
return "gpt-5.6-luna";
|
|
37
46
|
}
|
|
38
47
|
if (model === "gpt-5.4" || model.startsWith("gpt-5.4-")) {
|
|
39
48
|
return "gpt-5.4";
|