@4yi/cli 0.1.10 → 0.2.0-beta.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 4YI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,42 +1,67 @@
1
- # 4YI CLI
1
+ # @4yi/cli
2
2
 
3
- 4YI CLI signs in through the 4YI web app and launches OpenCode with 4YI model routing.
3
+ The 4YI CLI signs in through the 4YI web app and securely connects supported local coding tools to 4YI model routing.
4
4
 
5
5
  ## Install
6
6
 
7
- Temporary dev environment:
8
-
9
7
  ```bash
10
8
  npm install -g @4yi/cli
11
9
  ```
12
10
 
13
- Production environment:
14
-
15
- ```bash
16
- npm install -g @4yi/cli
17
- ```
11
+ Invited beta testers can opt in with `npm install -g @4yi/cli@beta`. The beta channel never replaces the normal `latest` installation automatically.
18
12
 
19
- Both packages install the `4yi` command. The dev package points at `https://xclaw-dev.bieases.com`; the production package points at `https://app.4yi.ai`.
13
+ Node.js 20 or newer is required.
20
14
 
21
- ## Commands
15
+ ## Quick start
22
16
 
23
17
  ```bash
24
18
  4yi login
25
19
  4yi whoami
26
- 4yi code
27
- 4yi connect claude
20
+ 4yi connect codex --dry-run
28
21
  4yi connect codex
29
- 4yi status all
30
- 4yi restore all
22
+ 4yi status codex
23
+ ```
24
+
25
+ Supported commands:
26
+
27
+ ```text
28
+ 4yi login
29
+ 4yi whoami
30
+ 4yi logout
31
+ 4yi code [--model MODEL]
32
+ 4yi connect <claude|codex|all> [--scope user|project|both] [--dry-run]
33
+ 4yi status [claude|codex|all]
34
+ 4yi restore <claude|codex|all>
35
+ 4yi doctor [--json]
31
36
  ```
32
37
 
33
- `4yi code` installs OpenCode under `~/.4yi/vendor/opencode`, writes an OpenCode config under `~/.4yi/opencode/opencode.json`, and launches OpenCode with a 4YI provider.
38
+ `4yi code` installs an isolated, version-pinned OpenCode runtime under `~/.4yi/vendor/opencode`, generates a 4YI-only configuration, and launches it without modifying the user's normal OpenCode state.
34
39
 
35
- `4yi connect claude` and `4yi connect codex` first check for the corresponding local CLI. If it is missing, 4YI asks before installing the official npm package. Pass `--yes` to approve that CLI installation non-interactively. Desktop apps are detected and reported, but are never installed automatically.
40
+ Connection commands create protected backups before changing Claude Code or Codex configuration. Use `--dry-run` to inspect target files without changing them and `4yi restore all` to restore the latest protected backup set.
36
41
 
37
- For local development:
42
+ ## Authentication and local data
43
+
44
+ `4yi login` uses browser-based device authorization. On Unix-like systems, the CLI stores its local session in `~/.4yi/config.json` with owner-only permissions. Do not share this file or attach it to a public issue.
45
+
46
+ See the repository [security policy](https://github.com/4yi-ai/4yi-ai-bridge/blob/main/SECURITY.md) before reporting a vulnerability.
47
+
48
+ ## Development
49
+
50
+ From the repository root:
51
+
52
+ ```bash
53
+ npm install
54
+ npm test
55
+ npm run smoke:cli
56
+ npm run pack:dry
57
+ ```
58
+
59
+ For local service development, set `FOURYI_BASE_URL` explicitly:
38
60
 
39
61
  ```bash
40
62
  FOURYI_BASE_URL=http://localhost:3000 node packages/cli/bin/4yi.mjs login
41
- node packages/cli/bin/4yi.mjs code
42
63
  ```
64
+
65
+ ## License
66
+
67
+ The CLI source in this package is licensed under the [MIT License](LICENSE). The hosted 4YI service remains a separate commercial service.
package/bin/4yi.mjs CHANGED
@@ -1,18 +1,22 @@
1
1
  #!/usr/bin/env node
2
+ import fs from "node:fs";
2
3
  import { login, loadSession, clearSession } from "../src/auth.mjs";
3
4
  import { runCode } from "../src/opencode.mjs";
4
- import { connect, connectionStatus, prepareConnectionTools, restoreConnection } from "../src/connect.mjs";
5
+ import { connect, connectionPlan, connectionStatus, prepareConnectionTools, restoreConnection } from "../src/connect.mjs";
6
+ import { runDoctor } from "../src/doctor.mjs";
5
7
 
6
8
  const command = process.argv[2] || "help";
7
9
 
8
10
  if (command === "help" || command === "--help" || command === "-h") {
9
- console.log("Usage: 4yi <login|whoami|logout|code|connect|status|restore>");
11
+ console.log("Usage: 4yi <login|whoami|logout|code|connect|status|restore|doctor>");
10
12
  console.log(" 4yi code launch OpenCode; switch models live with Tab / /models");
11
13
  console.log(" 4yi code --model X pin model X as the default for future sessions");
12
14
  console.log(" 4yi connect <claude|codex|all> connect existing coding tools to 4YI");
13
15
  console.log(" --yes install a missing CLI without prompting");
16
+ console.log(" --dry-run show affected files without changing them");
14
17
  console.log(" 4yi status [claude|codex|all] inspect the current connection");
15
18
  console.log(" 4yi restore <claude|codex|all> restore the latest protected config");
19
+ console.log(" 4yi doctor [--json] inspect local CLI readiness safely");
16
20
  process.exit(0);
17
21
  }
18
22
 
@@ -24,6 +28,7 @@ function parseConnectArgs(args) {
24
28
  let codexBaseUrl;
25
29
  let skipCheck = false;
26
30
  let autoInstall = false;
31
+ let dryRun = false;
27
32
  for (let i = 0; i < args.length; i += 1) {
28
33
  const arg = args[i];
29
34
  if (!arg.startsWith("-") && i === 0) target = arg;
@@ -33,9 +38,10 @@ function parseConnectArgs(args) {
33
38
  else if (arg === "--codex-base-url") codexBaseUrl = args[++i];
34
39
  else if (arg === "--skip-codex-responses-check") skipCheck = true;
35
40
  else if (arg === "--yes" || arg === "-y") autoInstall = true;
41
+ else if (arg === "--dry-run") dryRun = true;
36
42
  else throw new Error(`Unknown option: ${arg}`);
37
43
  }
38
- return { target, scope, platformUrl, claudeBaseUrl, codexBaseUrl, skipCheck, autoInstall };
44
+ return { target, scope, platformUrl, claudeBaseUrl, codexBaseUrl, skipCheck, autoInstall, dryRun };
39
45
  }
40
46
 
41
47
  /** Split out `--model <id>` / `--model=<id>` / `-m <id>`; the rest pass through to OpenCode. */
@@ -57,8 +63,8 @@ function parseCodeArgs(args) {
57
63
  }
58
64
 
59
65
  if (command === "--version" || command === "-v") {
60
- const pkg = await import("../package.json", { with: { type: "json" } });
61
- console.log(pkg.default.version);
66
+ const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
67
+ console.log(pkg.version);
62
68
  process.exit(0);
63
69
  }
64
70
 
@@ -79,6 +85,14 @@ try {
79
85
  process.exit(Number(code || 0));
80
86
  } else if (command === "connect") {
81
87
  const connectArgs = parseConnectArgs(process.argv.slice(3));
88
+ if (connectArgs.dryRun) {
89
+ const plan = connectionPlan(connectArgs);
90
+ console.log("Dry run — no files will be changed:");
91
+ for (const item of plan) {
92
+ console.log(` ${item.target}: ${item.action} ${item.file} (${item.exists ? "existing" : "new"})`);
93
+ }
94
+ process.exit(0);
95
+ }
82
96
  await prepareConnectionTools(connectArgs);
83
97
  let session = loadSession();
84
98
  if (!session.token) {
@@ -92,6 +106,12 @@ try {
92
106
  } else if (command === "restore") {
93
107
  const { target } = parseConnectArgs(process.argv.slice(3));
94
108
  restoreConnection({ target });
109
+ } else if (command === "doctor") {
110
+ const args = process.argv.slice(3);
111
+ const unknown = args.filter((arg) => arg !== "--json");
112
+ if (unknown.length > 0) throw new Error(`Unknown option: ${unknown[0]}`);
113
+ const result = runDoctor({ json: args.includes("--json") });
114
+ if (!result.ok) process.exitCode = 1;
95
115
  } else {
96
116
  console.error(`Unknown command: ${command}`);
97
117
  process.exit(1);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@4yi/cli",
3
- "version": "0.1.10",
4
- "description": "4YI command-line launcher for OAuth login and OpenCode runtime",
3
+ "version": "0.2.0-beta.1",
4
+ "description": "Securely connect Claude Code, Codex, and OpenCode to 4YI AI Bridge",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "4yi": "bin/4yi.mjs"
@@ -9,20 +9,39 @@
9
9
  "files": [
10
10
  "bin",
11
11
  "src",
12
- "README.md"
12
+ "README.md",
13
+ "LICENSE"
13
14
  ],
14
15
  "scripts": {
15
16
  "test": "node --test",
16
- "pack:dry": "npm pack --dry-run",
17
- "stage:dev": "node scripts/stage-package.mjs dev",
18
- "stage:prod": "node scripts/stage-package.mjs prod",
19
- "pack:dev": "node scripts/stage-package.mjs dev --pack",
20
- "pack:prod": "node scripts/stage-package.mjs prod --pack",
21
- "publish:dev": "node scripts/stage-package.mjs dev --publish",
22
- "publish:prod": "node scripts/stage-package.mjs prod --publish"
17
+ "smoke": "node bin/4yi.mjs --version && node bin/4yi.mjs --help",
18
+ "pack:dry": "npm pack --dry-run"
23
19
  },
24
20
  "engines": {
25
21
  "node": ">=20.0.0"
26
22
  },
27
- "license": "UNLICENSED"
23
+ "keywords": [
24
+ "4yi",
25
+ "ai",
26
+ "llm",
27
+ "cli",
28
+ "claude-code",
29
+ "codex",
30
+ "opencode",
31
+ "openai-compatible"
32
+ ],
33
+ "homepage": "https://github.com/4yi-ai/4yi-ai-bridge#readme",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/4yi-ai/4yi-ai-bridge.git",
37
+ "directory": "packages/cli"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/4yi-ai/4yi-ai-bridge/issues"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "provenance": true
45
+ },
46
+ "license": "MIT"
28
47
  }
package/src/auth.mjs CHANGED
@@ -13,16 +13,17 @@ export function saveSession(session, home = os.homedir()) {
13
13
 
14
14
  export function clearSession(home = os.homedir()) {
15
15
  const current = readConfig(home);
16
- writeConfig({ baseUrl: current.baseUrl }, home);
16
+ const { token: _token, org: _org, user: _user, ...localPreferences } = current;
17
+ writeConfig(localPreferences, home);
17
18
  }
18
19
 
19
20
  export function openBrowser(url) {
20
21
  const command = process.platform === "darwin"
21
22
  ? "open"
22
23
  : process.platform === "win32"
23
- ? "cmd"
24
+ ? "rundll32"
24
25
  : "xdg-open";
25
- const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
26
+ const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
26
27
  const child = spawn(command, args, { stdio: "ignore", detached: true });
27
28
  child.unref();
28
29
  }
package/src/config.mjs CHANGED
@@ -3,17 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
5
  export const PACKAGE_NAME = "@4yi/cli";
6
-
7
- const PACKAGE_BASE_URLS = {
8
- "@4yi-dev/cli": "https://xclaw-dev.bieases.com",
9
- "@4yi/cli": "https://app.4yi.ai",
10
- };
11
-
12
- export function defaultBaseUrlForPackage(packageName = PACKAGE_NAME) {
13
- return PACKAGE_BASE_URLS[packageName] || PACKAGE_BASE_URLS["@4yi/cli"];
14
- }
15
-
16
- export const DEFAULT_BASE_URL = defaultBaseUrlForPackage(PACKAGE_NAME);
6
+ export const DEFAULT_BASE_URL = "https://app.4yi.ai";
17
7
 
18
8
  export function normalizeBaseUrl(value) {
19
9
  return String(value || DEFAULT_BASE_URL).replace(/\/+$/, "");
@@ -48,7 +38,11 @@ export function readConfig(home = os.homedir()) {
48
38
  export function writeConfig(config, home = os.homedir()) {
49
39
  const paths = pathsForHome(home);
50
40
  ensureDir(paths.homeDir);
51
- fs.writeFileSync(paths.configFile, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
41
+ try { fs.chmodSync(paths.homeDir, 0o700); } catch { /* Windows may ignore POSIX modes. */ }
42
+ const temporary = path.join(paths.homeDir, `.config.${process.pid}.${Date.now()}.tmp`);
43
+ fs.writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
44
+ fs.renameSync(temporary, paths.configFile);
45
+ try { fs.chmodSync(paths.configFile, 0o600); } catch { /* Windows may ignore POSIX modes. */ }
52
46
  }
53
47
 
54
48
  /** The user's pinned default model id for `4yi code`, or null if unset. */
package/src/connect.mjs CHANGED
@@ -24,13 +24,13 @@ const TOOL_METADATA = {
24
24
  claude: {
25
25
  label: "Claude Code",
26
26
  command: "claude",
27
- npmPackage: "@anthropic-ai/claude-code",
27
+ npmPackage: "@anthropic-ai/claude-code@2.1.250",
28
28
  appLabel: "Claude App",
29
29
  },
30
30
  codex: {
31
31
  label: "Codex",
32
32
  command: "codex",
33
- npmPackage: "@openai/codex",
33
+ npmPackage: "@openai/codex@0.150.1",
34
34
  appLabel: "Codex App",
35
35
  },
36
36
  };
@@ -61,6 +61,28 @@ export function connectionPaths({ home = os.homedir(), cwd = process.cwd(), code
61
61
  };
62
62
  }
63
63
 
64
+ export function connectionPlan({ target = "all", home = os.homedir(), cwd = process.cwd(), codexHome = process.env.CODEX_HOME, scope = "user" } = {}) {
65
+ const normalized = normalizeTarget(target);
66
+ if (!new Set(["user", "project", "both"]).has(scope)) {
67
+ throw new Error(`Unknown scope: ${scope}. Use user, project, or both.`);
68
+ }
69
+ const paths = connectionPaths({ home, cwd, codexHome });
70
+ const plan = [];
71
+ if (normalized === "claude" || normalized === "all") {
72
+ const files = scope === "project"
73
+ ? [paths.claudeProject]
74
+ : scope === "both"
75
+ ? [paths.claudeUser, paths.claudeProject]
76
+ : [paths.claudeUser];
77
+ for (const file of files) plan.push({ target: "claude", file, exists: fs.existsSync(file), action: "backup-and-update" });
78
+ }
79
+ if (normalized === "codex" || normalized === "all") {
80
+ plan.push({ target: "codex", file: paths.codexConfig, exists: fs.existsSync(paths.codexConfig), action: "backup-and-update" });
81
+ plan.push({ target: "codex", file: paths.codexCatalog, exists: fs.existsSync(paths.codexCatalog), action: "generate" });
82
+ }
83
+ return plan;
84
+ }
85
+
64
86
  function atomicWrite(file, value) {
65
87
  ensureDir(path.dirname(file));
66
88
  const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
@@ -373,9 +395,11 @@ async function connectCodex({ session, home, codexHome, codexBaseUrl, stdout, sk
373
395
  ensureDir(path.dirname(paths.codexConfig));
374
396
  const template = parseBundledCatalog(tooling);
375
397
  const catalog = buildCodexCatalog(models, template);
398
+ const backupGroup = timestamp();
399
+ const configBackup = backupFile("codex", paths.codexConfig, home, backupGroup);
400
+ const catalogBackup = backupFile("codex", paths.codexCatalog, home, backupGroup);
376
401
  atomicWrite(paths.codexCatalog, `${JSON.stringify(catalog, null, 2)}\n`);
377
402
 
378
- const backup = backupFile("codex", paths.codexConfig, home);
379
403
  let existing = fs.existsSync(paths.codexConfig) ? fs.readFileSync(paths.codexConfig, "utf8") : "";
380
404
  existing = removeManagedBlock(existing, CODEX_ROOT_START, CODEX_ROOT_END);
381
405
  existing = removeManagedBlock(existing, CODEX_PROVIDER_START, CODEX_PROVIDER_END);
@@ -385,7 +409,7 @@ async function connectCodex({ session, home, codexHome, codexBaseUrl, stdout, sk
385
409
  atomicWrite(paths.codexConfig, `${root}\n\n${existing ? `${existing}\n\n` : ""}${provider}\n`);
386
410
  stdout(`Connected Codex: ${paths.codexConfig}`);
387
411
  stdout(`Available Codex models: ${models.map((model) => model.id).join(", ")}`);
388
- stdout(`Backup: ${backup}`);
412
+ stdout(`Backups: ${configBackup}, ${catalogBackup}`);
389
413
  }
390
414
 
391
415
  function resolveUrls(session, options) {
package/src/doctor.mjs ADDED
@@ -0,0 +1,86 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import { spawnSync } from "node:child_process";
4
+ import { DEFAULT_BASE_URL, pathsForHome, readConfig } from "./config.mjs";
5
+
6
+ function commandName(name, platform) {
7
+ return platform === "win32" ? `${name}.cmd` : name;
8
+ }
9
+
10
+ function commandAvailable(name, { platform, spawn }) {
11
+ const result = spawn(commandName(name, platform), ["--version"], {
12
+ encoding: "utf8",
13
+ shell: platform === "win32",
14
+ stdio: "ignore",
15
+ });
16
+ return !result.error && result.status === 0;
17
+ }
18
+
19
+ function check(id, status, message, required = false) {
20
+ return { id, status, message, required };
21
+ }
22
+
23
+ export function diagnose({
24
+ home = os.homedir(),
25
+ platform = process.platform,
26
+ nodeVersion = process.versions.node,
27
+ spawn = spawnSync,
28
+ } = {}) {
29
+ const results = [];
30
+ const major = Number.parseInt(String(nodeVersion).split(".")[0], 10);
31
+ results.push(check("node", major >= 20 ? "pass" : "fail", `Node.js ${nodeVersion}`, true));
32
+
33
+ const paths = pathsForHome(home);
34
+ let config = {};
35
+ try {
36
+ config = readConfig(home);
37
+ results.push(check("config-json", "pass", fs.existsSync(paths.configFile) ? "Local configuration is valid JSON" : "No local configuration yet"));
38
+ } catch {
39
+ results.push(check("config-json", "fail", "Local configuration is not valid JSON", true));
40
+ }
41
+ const baseUrl = config.baseUrl || DEFAULT_BASE_URL;
42
+ let safeBaseUrl = false;
43
+ try {
44
+ const parsed = new URL(baseUrl);
45
+ safeBaseUrl = parsed.protocol === "https:" || ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
46
+ } catch { /* Report a failed URL check below. */ }
47
+ results.push(check("base-url", safeBaseUrl ? "pass" : "fail", safeBaseUrl ? `Service URL: ${baseUrl}` : "Service URL is invalid or insecure", true));
48
+
49
+ if (fs.existsSync(paths.configFile)) {
50
+ if (platform === "win32") {
51
+ results.push(check("config-permissions", "warn", "Local session file exists; Windows ACLs were not inspected"));
52
+ } else {
53
+ const mode = fs.statSync(paths.configFile).mode & 0o777;
54
+ const permissionStatus = (mode & 0o077) === 0 ? "pass" : "fail";
55
+ const permissionMessage = permissionStatus === "pass"
56
+ ? "Local session file is owner-only"
57
+ : `Local session file permissions are too broad (${mode.toString(8)})`;
58
+ results.push(check("config-permissions", permissionStatus, permissionMessage, true));
59
+ }
60
+ } else {
61
+ results.push(check("config-permissions", "warn", "No local session file yet; run `4yi login`"));
62
+ }
63
+
64
+ results.push(check("session", config.token ? "pass" : "warn", config.token ? "Signed-in session is present" : "Not signed in"));
65
+ results.push(check("claude", commandAvailable("claude", { platform, spawn }) ? "pass" : "warn", "Claude Code CLI"));
66
+ results.push(check("codex", commandAvailable("codex", { platform, spawn }) ? "pass" : "warn", "Codex CLI"));
67
+
68
+ return {
69
+ ok: results.every((result) => !result.required || result.status === "pass"),
70
+ results,
71
+ };
72
+ }
73
+
74
+ export function runDoctor({ stdout = console.log, json = false, ...options } = {}) {
75
+ const report = diagnose(options);
76
+ if (json) {
77
+ stdout(JSON.stringify(report, null, 2));
78
+ return report;
79
+ }
80
+ stdout("4YI CLI doctor");
81
+ for (const result of report.results) {
82
+ const marker = result.status === "pass" ? "PASS" : result.status === "warn" ? "WARN" : "FAIL";
83
+ stdout(`${marker.padEnd(4)} ${result.message}`);
84
+ }
85
+ return report;
86
+ }
package/src/http.mjs CHANGED
@@ -1,17 +1,19 @@
1
1
  export async function requestJson(baseUrl, path, options = {}) {
2
2
  const url = `${baseUrl}${path}`;
3
+ const { token, timeoutMs = 30_000, signal, ...fetchOptions } = options;
3
4
  const optionHeaders = normalizeHeaders(options.headers);
4
5
  const headers = {
5
6
  "content-type": "application/json",
6
- ...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
7
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
7
8
  ...optionHeaders,
8
9
  };
9
10
  if (shouldGenerateIdempotencyKey(path, options, headers)) {
10
11
  headers["Idempotency-Key"] = `cli-${randomId()}`;
11
12
  }
12
13
  const res = await fetch(url, {
13
- ...options,
14
+ ...fetchOptions,
14
15
  headers,
16
+ signal: signal || AbortSignal.timeout(timeoutMs),
15
17
  });
16
18
  const text = await res.text();
17
19
  let body = null;
package/src/opencode.mjs CHANGED
@@ -5,11 +5,16 @@ import { spawn, spawnSync } from "node:child_process";
5
5
  import { pathsForHome, ensureDir, getPreferredModel, setPreferredModel } from "./config.mjs";
6
6
  import { requestJson } from "./http.mjs";
7
7
 
8
- const DEFAULT_OPENCODE_PACKAGE = "opencode-ai";
8
+ const OPENCODE_VERSION = "1.18.25";
9
+ const OPENAI_COMPATIBLE_VERSION = "3.0.39";
10
+ const DEFAULT_OPENCODE_PACKAGE = `opencode-ai@${OPENCODE_VERSION}`;
11
+ const OPENAI_COMPATIBLE_PACKAGE = `@ai-sdk/openai-compatible@${OPENAI_COMPATIBLE_VERSION}`;
12
+ const TRUSTED_OPENCODE_INSTALL_SCRIPT = `opencode-ai@${OPENCODE_VERSION}`;
9
13
  const DEFAULT_CONTEXT_LIMIT = 200000;
10
14
  const DEFAULT_OUTPUT_LIMIT = 8192;
11
15
 
12
16
  function isClaudeModel(model) {
17
+ if (model?.family === "claude") return true;
13
18
  const id = model?.id || "";
14
19
  const name = model?.display_name || "";
15
20
  return /claude/i.test(id) || /claude/i.test(name);
@@ -23,14 +28,23 @@ function modelOutputLimit(model) {
23
28
  return model.output_limit || model.max_output_tokens || model.max_tokens || DEFAULT_OUTPUT_LIMIT;
24
29
  }
25
30
 
31
+ function modelPickerName(model) {
32
+ const displayName = model.display_name || model.id;
33
+ if (model.family === "claude") {
34
+ return `Claude · ${displayName.replace(/^(?:anthropic[ ._-]*)?claude[ ._-]*/i, "")}`;
35
+ }
36
+ if (model.family === "codex") return `Codex · ${displayName}`;
37
+ return displayName;
38
+ }
39
+
26
40
  export function buildOpenCodeConfig({ modelConfig, preferredModel = null, tokenEnv = "FOURYI_CLI_TOKEN", orgEnv = "FOURYI_ORG_ID" }) {
27
- // Register EVERY model the server returned so OpenCode's native switcher
28
- // (`Tab` / `/models`) can move between them. Claude stays the default.
41
+ // Register every server-approved coding model so OpenCode's native switcher
42
+ // (`Tab` / `/models`) can move between Claude and Codex. Claude stays the default.
29
43
  const list = modelConfig.models || [];
30
44
  const models = {};
31
45
  for (const model of list) {
32
46
  const entry = {
33
- name: model.display_name || model.id,
47
+ name: modelPickerName(model),
34
48
  limit: {
35
49
  context: modelContextLimit(model),
36
50
  output: modelOutputLimit(model),
@@ -84,23 +98,37 @@ export function opencodeEnv({ home = os.homedir(), token, orgId = "", configFile
84
98
  };
85
99
  }
86
100
 
87
- export function ensureOpenCodeRuntime({ home = os.homedir(), stdout = console.log } = {}) {
101
+ export function ensureOpenCodeRuntime({ home = os.homedir(), stdout = console.log, spawn = spawnSync, env = process.env } = {}) {
88
102
  const paths = pathsForHome(home);
89
103
  ensureDir(paths.opencodeDir);
90
- if (!fs.existsSync(paths.opencodePackageFile)) {
91
- fs.writeFileSync(paths.opencodePackageFile, JSON.stringify({
104
+ const packageJson = fs.existsSync(paths.opencodePackageFile)
105
+ ? JSON.parse(fs.readFileSync(paths.opencodePackageFile, "utf8"))
106
+ : {
92
107
  private: true,
93
108
  type: "module",
94
109
  dependencies: {},
95
- }, null, 2));
110
+ };
111
+ const installScriptTrusted = packageJson.allowScripts?.[TRUSTED_OPENCODE_INSTALL_SCRIPT] === true;
112
+ if (!installScriptTrusted) {
113
+ packageJson.allowScripts = {
114
+ ...(packageJson.allowScripts || {}),
115
+ [TRUSTED_OPENCODE_INSTALL_SCRIPT]: true,
116
+ };
117
+ fs.writeFileSync(paths.opencodePackageFile, `${JSON.stringify(packageJson, null, 2)}\n`);
96
118
  }
97
119
 
98
120
  const bin = path.join(paths.opencodeDir, "node_modules", ".bin", process.platform === "win32" ? "opencode.cmd" : "opencode");
99
- if (fs.existsSync(bin)) return bin;
121
+ const opencodePackage = env.FOURYI_OPENCODE_PACKAGE || DEFAULT_OPENCODE_PACKAGE;
122
+ const usesDefaultRuntime = opencodePackage === DEFAULT_OPENCODE_PACKAGE;
123
+ const runtimeCurrent = fs.existsSync(bin)
124
+ && (!usesDefaultRuntime || (
125
+ packageJson.dependencies?.["opencode-ai"] === OPENCODE_VERSION
126
+ && packageJson.dependencies?.["@ai-sdk/openai-compatible"] === OPENAI_COMPATIBLE_VERSION
127
+ ));
128
+ if (runtimeCurrent) return bin;
100
129
 
101
- const opencodePackage = process.env.FOURYI_OPENCODE_PACKAGE || DEFAULT_OPENCODE_PACKAGE;
102
130
  stdout(`Installing OpenCode runtime into ${paths.opencodeDir}...`);
103
- const result = spawnSync("npm", ["install", "--prefix", paths.opencodeDir, opencodePackage, "@ai-sdk/openai-compatible"], {
131
+ const result = spawn("npm", ["install", "--prefix", paths.opencodeDir, "--save-exact", opencodePackage, OPENAI_COMPATIBLE_PACKAGE], {
104
132
  stdio: "inherit",
105
133
  });
106
134
  if (result.status !== 0) throw new Error("OpenCode runtime install failed");
@@ -111,7 +139,7 @@ export function ensureOpenCodeRuntime({ home = os.homedir(), stdout = console.lo
111
139
 
112
140
  export async function runCode({ session, home = os.homedir(), argv = [], stdout = console.log, preferredModel = null } = {}) {
113
141
  if (!session?.token) throw new Error("Not signed in. Run `4yi login`.");
114
- const modelConfig = await requestJson(session.baseUrl, "/api/cli/models", { token: session.token });
142
+ const modelConfig = await requestJson(session.baseUrl, "/api/cli/models?runtime=opencode", { token: session.token });
115
143
  const list = modelConfig.models || [];
116
144
  if (list.length === 0) throw new Error("No chat models available for this organization.");
117
145