@frontendxlab/opencode-computer-use 0.1.3 → 0.1.5

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.
@@ -5,8 +5,8 @@ import { randomUUID } from "node:crypto";
5
5
  import { homedir } from "node:os";
6
6
  import { delimiter, dirname, join, resolve } from "node:path";
7
7
  import { spawnSync } from "node:child_process";
8
- import { fileURLToPath } from "node:url";
9
- import { applyEdits, modify, parse, parseTree } from "jsonc-parser";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
+ import { applyEdits, modify, parse } from "jsonc-parser";
10
10
 
11
11
  const PACKAGE_NAME = "@frontendxlab/opencode-computer-use";
12
12
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -36,14 +36,9 @@ For OpenCode2 V2, add this to opencode.json or opencode.jsonc:
36
36
  ]
37
37
  }
38
38
 
39
- For global OpenCode2 V2 setup, run:
40
-
41
- opencode2 plugin add @frontendxlab/opencode-computer-use
42
-
43
- After changing configuration, restart OpenCode:
44
-
45
- opencode service restart
46
- opencode2 service restart
39
+ For global setup, rerun with --global and the appropriate --v1 or --v2 flag.
40
+ For a local checkout, also pass --source /absolute/path/to/opencode-computer-use.
41
+ After changing configuration, close and reopen OpenCode.
47
42
  `.trim();
48
43
 
49
44
  function print(text, stream = "stdout") {
@@ -76,6 +71,12 @@ function parseArguments(argv) {
76
71
  result.flavor = value === "1" ? "v1" : "v2";
77
72
  } else if (argument === "--dry-run") {
78
73
  result.dryRun = true;
74
+ } else if (argument === "--source") {
75
+ const value = argv[++index];
76
+ if (!value || value.startsWith("--")) {
77
+ throw new Error("--source requires a local directory, file path, or file URL");
78
+ }
79
+ result.source = value;
79
80
  } else if (argument === "--help" || argument === "-h") {
80
81
  result.help = true;
81
82
  } else if (argument === "--auto") {
@@ -98,18 +99,19 @@ Options:
98
99
  --v1 Configure the OpenCode 1 plugin format
99
100
  --v2 Configure the OpenCode2 V2 plugin format
100
101
  --opencode-version 1|2 Select the OpenCode generation explicitly
102
+ --source PATH_OR_URL Load a local checkout directory or entrypoint file
101
103
  --dry-run Show the action without changing files
102
104
  --help, -h Show this help
103
105
  `;
104
106
  }
105
107
 
106
108
  function executableCandidates(command, env = process.env) {
107
- const pathValue = env.PATH || "";
109
+ const pathKey = Object.keys(env).find((key) => key.toUpperCase() === "PATH");
110
+ const pathValue = env.PATH || (pathKey && env[pathKey]) || "";
108
111
  const directories = pathValue.split(delimiter).filter(Boolean);
109
112
  if (process.platform === "win32") {
110
113
  const extensions = (env.PATHEXT || ".EXE;.CMD;.BAT").split(";").filter(Boolean);
111
114
  return directories.flatMap((directory) => [
112
- join(directory, command),
113
115
  ...extensions.map((extension) => join(directory, `${command}${extension.toLowerCase()}`)),
114
116
  ...extensions.map((extension) => join(directory, `${command}${extension}`)),
115
117
  ]);
@@ -131,10 +133,13 @@ function resolveCommand(command, env = process.env) {
131
133
 
132
134
  function probeCommand(command, env = process.env) {
133
135
  const executable = resolveCommand(command, env);
134
- const result = spawnSync(executable, ["--version"], {
136
+ const batchFile = process.platform === "win32" && /\.(?:cmd|bat)$/iu.test(executable);
137
+ // npm's Windows shims need cmd.exe; a quoted fixed command keeps paths with
138
+ // spaces intact. The only argument is the fixed --version probe.
139
+ const result = spawnSync(batchFile ? `"${executable}" --version` : executable, batchFile ? [] : ["--version"], {
135
140
  encoding: "utf8",
136
141
  env,
137
- shell: false,
142
+ shell: batchFile,
138
143
  windowsHide: true,
139
144
  timeout: 10_000,
140
145
  });
@@ -170,19 +175,32 @@ function packageKey(entry) {
170
175
  return undefined;
171
176
  }
172
177
 
173
- function hasPackage(entries) {
174
- return Array.isArray(entries) && entries.some((entry) => packageKey(entry) === PACKAGE_NAME);
178
+ function isPackageReference(value) {
179
+ return value === PACKAGE_NAME || (typeof value === "string" && value.startsWith(`${PACKAGE_NAME}@`) && value.length > PACKAGE_NAME.length + 1);
180
+ }
181
+
182
+ function hasPackage(entries, specifier = PACKAGE_NAME) {
183
+ return Array.isArray(entries) && entries.some((entry) => {
184
+ const key = packageKey(entry);
185
+ return key === specifier || (specifier === PACKAGE_NAME && isPackageReference(key));
186
+ });
187
+ }
188
+
189
+ function parseConfiguration(text) {
190
+ const errors = [];
191
+ const data = parse(text, errors, { allowTrailingComma: true, allowEmptyContent: true });
192
+ if (errors.length > 0) throw new Error("configuration is not valid JSON or JSONC");
193
+ if (!text.trim()) return {};
194
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
195
+ throw new Error("configuration is not a JSON or JSONC object");
196
+ }
197
+ return data;
175
198
  }
176
199
 
177
200
  function configFlavor(text) {
178
- const root = parseTree(text);
179
- if (!root || root.type !== "object") return null;
180
- const names = (root.children || [])
181
- .filter((child) => child.type === "property")
182
- .map((property) => property.children?.[0]?.value)
183
- .filter((name) => name === "plugin" || name === "plugins");
184
- const hasV1 = names.includes("plugin");
185
- const hasV2 = names.includes("plugins");
201
+ const data = parseConfiguration(text);
202
+ const hasV1 = Object.hasOwn(data, "plugin");
203
+ const hasV2 = Object.hasOwn(data, "plugins");
186
204
  if (hasV1 && hasV2) return "ambiguous";
187
205
  if (hasV1) return "v1";
188
206
  if (hasV2) return "v2";
@@ -244,40 +262,52 @@ function defaultConfigFile(root, scope, env) {
244
262
  return join(configDir, "opencode.json");
245
263
  }
246
264
 
247
- function addEntry(entries, flavor) {
248
- if (hasPackage(entries)) return { entries, changed: false };
265
+ function addEntry(entries, flavor, specifier) {
266
+ if (hasPackage(entries, specifier)) return { entries, changed: false };
267
+ if (specifier !== PACKAGE_NAME) {
268
+ const index = entries.findIndex((entry) => isPackageReference(packageKey(entry)));
269
+ if (index !== -1) {
270
+ const current = entries[index];
271
+ const replacement = Array.isArray(current)
272
+ ? [specifier, ...current.slice(1)]
273
+ : typeof current === "object" ? { ...current, package: specifier } : specifier;
274
+ const updated = [...entries];
275
+ updated[index] = replacement;
276
+ return { entries: updated, changed: true };
277
+ }
278
+ }
249
279
  const entry = flavor === "v1"
250
- ? [PACKAGE_NAME, { ...DEFAULT_V1_OPTIONS }]
251
- : PACKAGE_NAME;
280
+ ? [specifier, { ...DEFAULT_V1_OPTIONS }]
281
+ : specifier;
252
282
  return { entries: [...entries, entry], changed: true };
253
283
  }
254
284
 
255
- function newConfig(flavor) {
285
+ function newConfig(flavor, specifier) {
256
286
  const key = flavor === "v1" ? "plugin" : "plugins";
257
287
  const entry = flavor === "v1"
258
- ? [[PACKAGE_NAME, { ...DEFAULT_V1_OPTIONS }]]
259
- : [PACKAGE_NAME];
288
+ ? [[specifier, { ...DEFAULT_V1_OPTIONS }]]
289
+ : [specifier];
260
290
  return {
261
291
  $schema: "https://opencode.ai/config.json",
262
292
  [key]: entry,
263
293
  };
264
294
  }
265
295
 
266
- export function updateConfigText(text, flavor) {
267
- const parsed = parse(text);
268
- if (text.trim() && (parsed === undefined || parsed === null || typeof parsed !== "object" || Array.isArray(parsed))) {
269
- throw new Error("configuration is not a JSON or JSONC object");
270
- }
271
- const data = parsed && typeof parsed === "object" ? parsed : {};
296
+ export function updateConfigText(text, flavor, specifier = PACKAGE_NAME) {
297
+ const data = parseConfiguration(text);
272
298
  if (data.plugin !== undefined && data.plugins !== undefined) {
273
299
  throw new Error("configuration contains both plugin and plugins fields");
274
300
  }
275
301
  const key = flavor === "v1" ? "plugin" : "plugins";
302
+ const otherKey = flavor === "v1" ? "plugins" : "plugin";
303
+ if (Object.hasOwn(data, otherKey)) {
304
+ throw new Error(`selected ${flavor} conflicts with the existing ${otherKey} configuration`);
305
+ }
276
306
  const current = data[key];
277
307
  if (current !== undefined && !Array.isArray(current)) {
278
308
  throw new Error(`configuration field "${key}" is not an array`);
279
309
  }
280
- const result = addEntry(current || [], flavor);
310
+ const result = addEntry(current || [], flavor, specifier);
281
311
  if (!result.changed) return { text, changed: false };
282
312
  const edits = modify(text, [key], result.entries, {
283
313
  formattingOptions: {
@@ -307,20 +337,21 @@ function writeConfig(file, content, dryRun) {
307
337
 
308
338
  function validateConfigFiles(root, scope, env) {
309
339
  for (const file of existingConfigFiles(root, scope, env)) {
310
- const text = readFileSync(file, "utf8");
311
- const data = parse(text);
312
- if (text.trim() && (data === undefined || data === null || typeof data !== "object" || Array.isArray(data))) {
313
- throw new Error(`configuration is not valid JSON or JSONC: ${file}`);
340
+ if (!existsSync(file)) continue;
341
+ try {
342
+ parseConfiguration(readFileSync(file, "utf8"));
343
+ } catch (error) {
344
+ throw new Error(`${error.message}: ${file}`);
314
345
  }
315
346
  }
316
347
  }
317
348
 
318
- function packageAlreadyConfigured(root, scope, env) {
349
+ function packageAlreadyConfigured(root, scope, env, specifier) {
319
350
  for (const file of existingConfigFiles(root, scope, env)) {
320
351
  try {
321
- const data = parse(readFileSync(file, "utf8"));
352
+ const data = parseConfiguration(readFileSync(file, "utf8"));
322
353
  const key = data?.plugins !== undefined ? "plugins" : data?.plugin !== undefined ? "plugin" : undefined;
323
- if (key && hasPackage(data[key])) return file;
354
+ if (key && hasPackage(data[key], specifier)) return file;
324
355
  } catch {
325
356
  continue;
326
357
  }
@@ -328,31 +359,7 @@ function packageAlreadyConfigured(root, scope, env) {
328
359
  return undefined;
329
360
  }
330
361
 
331
- function redactMessage(value) {
332
- return String(value)
333
- .replace(/\b(token|secret|password|api[_-]?key|authorization)\s*[:=]\s*(?:bearer|basic)?[^\n\r,;]*/giu, "$1=[redacted]")
334
- .slice(0, 500);
335
- }
336
-
337
- function runPluginCli(flavor, scope, root, env) {
338
- const command = flavor === "v1" ? "opencode" : "opencode2";
339
- const args = ["plugin", "add", PACKAGE_NAME];
340
- if (flavor === "v1" && scope === "global") args.push("--global");
341
- const result = spawnSync(resolveCommand(command, env), args, {
342
- cwd: root,
343
- encoding: "utf8",
344
- env,
345
- shell: process.platform === "win32",
346
- windowsHide: true,
347
- timeout: 120_000,
348
- });
349
- if (result.error || result.status !== 0) {
350
- const detail = redactMessage(`${result.stdout || ""}${result.stderr || ""}`.trim());
351
- throw new Error(`${command} plugin add failed${detail ? `: ${detail}` : ""}`);
352
- }
353
- }
354
-
355
- function configForSetup(root, scope, flavor, env, dryRun) {
362
+ function configForSetup(root, scope, flavor, env, dryRun, specifier) {
356
363
  const files = existingConfigFiles(root, scope, env);
357
364
  if (files.length > 0) {
358
365
  const flavors = files.map((file) => {
@@ -369,14 +376,19 @@ function configForSetup(root, scope, flavor, env, dryRun) {
369
376
  throw new Error("multiple conflicting OpenCode configuration files were found");
370
377
  }
371
378
  }
372
- const file = files[0] || defaultConfigFile(root, scope, env);
379
+ const configured = packageAlreadyConfigured(root, scope, env, specifier);
380
+ if (configured) return { file: configured, changed: false };
381
+ // A checkout source replaces an npm registration in its original config,
382
+ // even when another JSON/JSONC config is also present.
383
+ const file = (specifier !== PACKAGE_NAME && packageAlreadyConfigured(root, scope, env, PACKAGE_NAME))
384
+ || files[0] || defaultConfigFile(root, scope, env);
373
385
  if (!existsSync(file)) {
374
- const content = JSON.stringify(newConfig(flavor), null, 2) + "\n";
386
+ const content = JSON.stringify(newConfig(flavor, specifier), null, 2) + "\n";
375
387
  writeConfig(file, content, dryRun);
376
388
  return { file, changed: true };
377
389
  }
378
- const result = updateConfigText(readFileSync(file, "utf8"), flavor);
379
- writeConfig(file, result.text, dryRun);
390
+ const result = updateConfigText(readFileSync(file, "utf8"), flavor, specifier);
391
+ if (result.changed) writeConfig(file, result.text, dryRun);
380
392
  return { file, changed: result.changed };
381
393
  }
382
394
 
@@ -394,8 +406,13 @@ function chooseFlavor(requested, detection, config) {
394
406
  if (config === "v1" || config === "v2") return config;
395
407
  throw new Error("both OpenCode 1 and OpenCode2 are installed, and the configuration is ambiguous");
396
408
  }
397
- if (detection.v1.available) return "v1";
398
- if (detection.v2.available) return "v2";
409
+ if (detection.v1.available || detection.v2.available) {
410
+ const detected = detection.v1.available ? "v1" : "v2";
411
+ if (config && config !== detected) {
412
+ throw new Error(`detected ${detected} conflicts with the existing ${config} configuration`);
413
+ }
414
+ return detected;
415
+ }
399
416
  throw new Error("neither opencode nor opencode2 was found");
400
417
  }
401
418
 
@@ -405,6 +422,17 @@ function chooseRoot(scope, env) {
405
422
  return resolve(initial);
406
423
  }
407
424
 
425
+ function sourceSpecifier(source, root) {
426
+ if (source === undefined) return PACKAGE_NAME;
427
+ if (typeof source !== "string" || !source.trim()) throw new Error("--source requires a local path or file URL");
428
+ let path = source.startsWith("file:") ? fileURLToPath(source) : resolve(root, source);
429
+ if (existsSync(path) && statSync(path).isDirectory()) path = join(path, "index.js");
430
+ if (!existsSync(path) || !statSync(path).isFile()) {
431
+ throw new Error(`local plugin entrypoint does not exist: ${path}`);
432
+ }
433
+ return pathToFileURL(realpathSync(path)).href;
434
+ }
435
+
408
436
  export async function setup(options = {}, env = process.env, io = print) {
409
437
  const requestedScope = options.scope || "auto";
410
438
  const requestedFlavor = options.flavor || "auto";
@@ -413,31 +441,20 @@ export async function setup(options = {}, env = process.env, io = print) {
413
441
  if (env.OPENCODE_CONFIG_CONTENT) {
414
442
  throw new Error("OPENCODE_CONFIG_CONTENT is immutable; add the plugin to that configuration manually");
415
443
  }
444
+ validateConfigFiles(root, scope, env);
445
+ const specifier = sourceSpecifier(options.source, root);
416
446
  const detection = detectOpenCode(env);
417
447
  const config = detectConfigFlavor(root, scope, env);
418
448
  const flavor = chooseFlavor(requestedFlavor, detection, config);
419
449
  const dryRun = Boolean(options.dryRun);
420
- let result;
421
- if (scope === "global" && !dryRun) {
422
- validateConfigFiles(root, scope, env);
423
- const existing = packageAlreadyConfigured(root, scope, env);
424
- if (existing) {
425
- result = { file: existing, changed: false };
426
- } else {
427
- runPluginCli(flavor, scope, root, env);
428
- const configured = packageAlreadyConfigured(root, scope, env);
429
- if (!configured) throw new Error(`${flavor.toUpperCase()} plugin command did not register the package`);
430
- result = { file: configured, changed: true };
431
- }
432
- } else {
433
- result = configForSetup(root, scope, flavor, env, dryRun);
434
- }
450
+ const result = configForSetup(root, scope, flavor, env, dryRun, specifier);
435
451
  const action = options.dryRun
436
452
  ? (result.changed ? "would configure" : "already configured")
437
453
  : (result.changed ? "configured" : "already configured");
438
454
  io(`OpenCode Computer Use setup ${action} for ${flavor.toUpperCase()} (${scope}).`);
439
- if (result.changed) io(`Configuration: ${result.file}`);
440
- return { ...result, scope, flavor, detection };
455
+ io(`Configuration: ${result.file}`);
456
+ io(`Plugin: ${specifier}`);
457
+ return { ...result, scope, flavor, detection, specifier };
441
458
  }
442
459
 
443
460
  export function printHelp() {
@@ -477,9 +494,10 @@ export async function main(argv = process.argv.slice(2), env = process.env, io =
477
494
  return setup(options, env, io)
478
495
  .then(() => 0)
479
496
  .catch((error) => {
480
- io(`[opencode-computer-use] Automatic setup was not changed: ${error instanceof Error ? error.message : String(error)}`, "stderr");
497
+ const automatic = env.npm_lifecycle_event === "postinstall";
498
+ io(`[opencode-computer-use] ${automatic ? "Automatic setup was not changed" : "Setup failed"}: ${error instanceof Error ? error.message : String(error)}`, "stderr");
481
499
  io(FALLBACK_INSTRUCTIONS, "stderr");
482
- return 0;
500
+ return automatic ? 0 : 1;
483
501
  });
484
502
  }
485
503
 
@@ -0,0 +1,102 @@
1
+ # Native Desktop Validation
2
+
3
+ Use this checklist before marking a native operating-system target as live-certified. The headless adapter test suite does not replace these checks.
4
+
5
+ ## Target matrix
6
+
7
+ - macOS arm64
8
+ - macOS x64
9
+ - Linux arm64
10
+ - Linux x64
11
+ - Windows arm64
12
+ - Windows x64
13
+
14
+ Run only on a signed-in interactive desktop session. Do not use banking, password-manager, administrator, or other high-value applications for validation.
15
+
16
+ ## 1. Runtime and permissions
17
+
18
+ ```sh
19
+ npm exec --package=@frontendxlab/opencode-computer-use -- opencode-computer-use-permissions --strict
20
+ npm exec --package=open-computer-use@0.3.5 -- open-computer-use list-apps
21
+ ```
22
+
23
+ Confirm that the native runtime starts successfully and that a disposable test application appears in the app list. Resolve Accessibility, Screen Recording, AT-SPI, UI Automation, capture, or input prerequisites before continuing.
24
+
25
+ ## 2. Read-only session
26
+
27
+ Start OpenCode with `safetyMode: "read-only"` and verify:
28
+
29
+ - `cua_repl` connects.
30
+ - `cua.listApps()` returns the disposable test application.
31
+ - `cua.getApp(...)` binds it.
32
+ - `getAXState()` returns bounded state.
33
+ - `getScreenshot()` returns an image when the host supports capture.
34
+ - click, type, key, scroll, drag, value, and secondary actions are rejected by the adapter before native execution.
35
+
36
+ ## 3. Controlled mutation
37
+
38
+ Use a disposable editor or test application and restore `safetyMode: "full"`.
39
+
40
+ Verify semantic click, text input, keyboard input, scrolling/drag, and a fresh state read. Coordinate/global-pointer fallback must stay disabled unless explicitly enabled. Do not automatically replay a mutating action after a timeout or transport failure; observe first.
41
+
42
+ ## 4. Lifecycle and recovery
43
+
44
+ Verify `js_reset`, CPU-loop timeout recovery, normal shutdown, native-request interruption recovery, and that no orphan native process remains.
45
+
46
+ ## 5. Platform-specific checks
47
+
48
+ ### macOS
49
+
50
+ - Accessibility permission is granted only to the expected executable.
51
+ - Screen Recording permission is sufficient for screenshots.
52
+ - Intel and Apple Silicon use the expected packaged executable.
53
+
54
+ ### Linux
55
+
56
+ - Record desktop environment and session type (X11 or Wayland).
57
+ - Verify AT-SPI exposes actionable elements for the chosen test application.
58
+ - Record the screenshot/input backend used by the native runtime.
59
+ - Treat compositor-blocked synthetic input as unsupported rather than success.
60
+
61
+ ### Windows
62
+
63
+ - Run in the interactive user desktop, not a service/session-0 context.
64
+ - Verify UI Automation state and keyboard input.
65
+ - Do not cross a UAC secure desktop.
66
+ - Confirm process cleanup terminates the native process tree.
67
+
68
+ ## 6. Certification record
69
+
70
+ For each target record the OS/version, architecture, Node/OpenCode/native-runtime versions, desktop/session type, permission result, app listing, AX state, screenshot, semantic input, lifecycle/recovery result, and known limitations.
71
+
72
+ Only then change public documentation from packaged/supported target to live-certified for that target.
73
+
74
+ ## Recorded validation — 2026-10-30, 0.1.5 release candidate
75
+
76
+ Host: Linux x64, Ubuntu/GNOME Wayland, GTK 3.24. Runtime versions: Node
77
+ 26.13.0, OpenCode 1.19.3, `open-computer-use` 0.3.5. The OpenCode integration
78
+ also ran from an independently installed npm tarball with isolated settings.
79
+
80
+ | Check | Observed result |
81
+ | --- | --- |
82
+ | Adapter regression suite | 70 passed, zero failed on this Linux host. |
83
+ | Syntax/lint and whitespace checks | Passed. |
84
+ | Real OpenCode registration | `cua_repl` connected with options preserved; both fake-backend and native startup modes passed. |
85
+ | Packed installation | Clean tarball install, CLI help, and actual OpenCode fake-backend integration passed. |
86
+ | Native app inventory | Eight running apps returned before opening the disposable fixture. |
87
+ | Persistent JavaScript and reset | A variable survived a second call; `js_reset` succeeded. |
88
+ | Disposable GTK accessibility tree | The test window exposed its counter, text entry, and named button. |
89
+ | Element-index click | Counter changed from 0 to 1, confirmed by fresh state and the fixture's event log. |
90
+ | `setValue` | Text field changed to the test string, confirmed by fresh state and the fixture's event log. |
91
+ | Screenshot | Unavailable for the GTK window in this Wayland session; the adapter returned an explicit error. |
92
+ | Coordinate input, keyboard input, drag, focus recovery | Not validated in this run. |
93
+ | macOS/Windows live desktops | Not available for this run; native behavior remains unverified. |
94
+ | Real OpenCode2 installation | Not exercised; adapter contract coverage only. |
95
+
96
+ The fixture was a disposable local GTK window with no external action or data.
97
+ Mutating checks allowed access only to that fixture, and global-pointer
98
+ fallback remained off. ATK diagnostic warnings occurred during native tree
99
+ inspection; the checked semantic actions still completed. This record supports
100
+ the specific checks above, not universal Linux desktop certification or
101
+ screenshot-based automation. Remote CI jobs must be checked on the release
102
+ commit; their configuration alone is not evidence of a passed run.