@4yi/cli 0.1.11 → 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 +21 -0
- package/README.md +44 -19
- package/bin/4yi.mjs +25 -5
- package/package.json +30 -11
- package/src/auth.mjs +4 -3
- package/src/config.mjs +6 -12
- package/src/connect.mjs +28 -14
- package/src/doctor.mjs +86 -0
- package/src/http.mjs +4 -2
- package/src/opencode.mjs +26 -8
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
|
-
#
|
|
1
|
+
# @4yi/cli
|
|
2
2
|
|
|
3
|
-
4YI CLI signs in through the 4YI web app and
|
|
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
|
-
|
|
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
|
-
|
|
13
|
+
Node.js 20 or newer is required.
|
|
20
14
|
|
|
21
|
-
##
|
|
15
|
+
## Quick start
|
|
22
16
|
|
|
23
17
|
```bash
|
|
24
18
|
4yi login
|
|
25
19
|
4yi whoami
|
|
26
|
-
4yi
|
|
27
|
-
4yi connect claude
|
|
20
|
+
4yi connect codex --dry-run
|
|
28
21
|
4yi connect codex
|
|
29
|
-
4yi status
|
|
30
|
-
|
|
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`,
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
61
|
-
console.log(pkg.
|
|
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
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
17
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
? "
|
|
24
|
+
? "rundll32"
|
|
24
25
|
: "xdg-open";
|
|
25
|
-
const args = process.platform === "win32" ? ["
|
|
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.
|
|
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`;
|
|
@@ -311,16 +333,6 @@ function buildCodexCatalog(models, template) {
|
|
|
311
333
|
description: `${planModel.display_name || planModel.id} via 4YI Gateway with OpenAI Responses tool support.`,
|
|
312
334
|
supported_in_api: true,
|
|
313
335
|
use_responses_lite: false,
|
|
314
|
-
// The template is whatever `codex debug models --bundled` returns on this
|
|
315
|
-
// machine, and that is not a stable schema: 0.147 carries this field,
|
|
316
|
-
// 0.150 omits it, and connect installs the latest codex -- so it upgrades
|
|
317
|
-
// its own template source into the version that drops it. Codex.app
|
|
318
|
-
// requires it and will not start without one, so it is set here rather
|
|
319
|
-
// than inherited. The endpoint is authoritative when it answers; `true`
|
|
320
|
-
// is the fallback because every model this catalog can describe is served
|
|
321
|
-
// through the Responses ingress, which requires `parallel_tool_calls` on
|
|
322
|
-
// each request.
|
|
323
|
-
supports_parallel_tool_calls: planModel.supports_parallel_tool_calls ?? true,
|
|
324
336
|
tool_mode: undefined,
|
|
325
337
|
})).map((model) => {
|
|
326
338
|
delete model.tool_mode;
|
|
@@ -383,9 +395,11 @@ async function connectCodex({ session, home, codexHome, codexBaseUrl, stdout, sk
|
|
|
383
395
|
ensureDir(path.dirname(paths.codexConfig));
|
|
384
396
|
const template = parseBundledCatalog(tooling);
|
|
385
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);
|
|
386
401
|
atomicWrite(paths.codexCatalog, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
387
402
|
|
|
388
|
-
const backup = backupFile("codex", paths.codexConfig, home);
|
|
389
403
|
let existing = fs.existsSync(paths.codexConfig) ? fs.readFileSync(paths.codexConfig, "utf8") : "";
|
|
390
404
|
existing = removeManagedBlock(existing, CODEX_ROOT_START, CODEX_ROOT_END);
|
|
391
405
|
existing = removeManagedBlock(existing, CODEX_PROVIDER_START, CODEX_PROVIDER_END);
|
|
@@ -395,7 +409,7 @@ async function connectCodex({ session, home, codexHome, codexBaseUrl, stdout, sk
|
|
|
395
409
|
atomicWrite(paths.codexConfig, `${root}\n\n${existing ? `${existing}\n\n` : ""}${provider}\n`);
|
|
396
410
|
stdout(`Connected Codex: ${paths.codexConfig}`);
|
|
397
411
|
stdout(`Available Codex models: ${models.map((model) => model.id).join(", ")}`);
|
|
398
|
-
stdout(`
|
|
412
|
+
stdout(`Backups: ${configBackup}, ${catalogBackup}`);
|
|
399
413
|
}
|
|
400
414
|
|
|
401
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
|
-
...(
|
|
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
|
-
...
|
|
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,7 +5,11 @@ 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
|
|
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
|
|
|
@@ -94,23 +98,37 @@ export function opencodeEnv({ home = os.homedir(), token, orgId = "", configFile
|
|
|
94
98
|
};
|
|
95
99
|
}
|
|
96
100
|
|
|
97
|
-
export function ensureOpenCodeRuntime({ home = os.homedir(), stdout = console.log } = {}) {
|
|
101
|
+
export function ensureOpenCodeRuntime({ home = os.homedir(), stdout = console.log, spawn = spawnSync, env = process.env } = {}) {
|
|
98
102
|
const paths = pathsForHome(home);
|
|
99
103
|
ensureDir(paths.opencodeDir);
|
|
100
|
-
|
|
101
|
-
fs.
|
|
104
|
+
const packageJson = fs.existsSync(paths.opencodePackageFile)
|
|
105
|
+
? JSON.parse(fs.readFileSync(paths.opencodePackageFile, "utf8"))
|
|
106
|
+
: {
|
|
102
107
|
private: true,
|
|
103
108
|
type: "module",
|
|
104
109
|
dependencies: {},
|
|
105
|
-
}
|
|
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`);
|
|
106
118
|
}
|
|
107
119
|
|
|
108
120
|
const bin = path.join(paths.opencodeDir, "node_modules", ".bin", process.platform === "win32" ? "opencode.cmd" : "opencode");
|
|
109
|
-
|
|
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;
|
|
110
129
|
|
|
111
|
-
const opencodePackage = process.env.FOURYI_OPENCODE_PACKAGE || DEFAULT_OPENCODE_PACKAGE;
|
|
112
130
|
stdout(`Installing OpenCode runtime into ${paths.opencodeDir}...`);
|
|
113
|
-
const result =
|
|
131
|
+
const result = spawn("npm", ["install", "--prefix", paths.opencodeDir, "--save-exact", opencodePackage, OPENAI_COMPATIBLE_PACKAGE], {
|
|
114
132
|
stdio: "inherit",
|
|
115
133
|
});
|
|
116
134
|
if (result.status !== 0) throw new Error("OpenCode runtime install failed");
|