@4yi/cli 0.1.8 → 0.1.10
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 +6 -0
- package/bin/4yi.mjs +43 -1
- package/package.json +1 -1
- package/src/config.mjs +2 -0
- package/src/connect.mjs +479 -0
- package/src/opencode.mjs +4 -14
package/README.md
CHANGED
|
@@ -24,10 +24,16 @@ Both packages install the `4yi` command. The dev package points at `https://xcla
|
|
|
24
24
|
4yi login
|
|
25
25
|
4yi whoami
|
|
26
26
|
4yi code
|
|
27
|
+
4yi connect claude
|
|
28
|
+
4yi connect codex
|
|
29
|
+
4yi status all
|
|
30
|
+
4yi restore all
|
|
27
31
|
```
|
|
28
32
|
|
|
29
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.
|
|
30
34
|
|
|
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.
|
|
36
|
+
|
|
31
37
|
For local development:
|
|
32
38
|
|
|
33
39
|
```bash
|
package/bin/4yi.mjs
CHANGED
|
@@ -1,16 +1,43 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { login, loadSession, clearSession } from "../src/auth.mjs";
|
|
3
3
|
import { runCode } from "../src/opencode.mjs";
|
|
4
|
+
import { connect, connectionStatus, prepareConnectionTools, restoreConnection } from "../src/connect.mjs";
|
|
4
5
|
|
|
5
6
|
const command = process.argv[2] || "help";
|
|
6
7
|
|
|
7
8
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
8
|
-
console.log("Usage: 4yi <login|whoami|logout|code
|
|
9
|
+
console.log("Usage: 4yi <login|whoami|logout|code|connect|status|restore>");
|
|
9
10
|
console.log(" 4yi code launch OpenCode; switch models live with Tab / /models");
|
|
10
11
|
console.log(" 4yi code --model X pin model X as the default for future sessions");
|
|
12
|
+
console.log(" 4yi connect <claude|codex|all> connect existing coding tools to 4YI");
|
|
13
|
+
console.log(" --yes install a missing CLI without prompting");
|
|
14
|
+
console.log(" 4yi status [claude|codex|all] inspect the current connection");
|
|
15
|
+
console.log(" 4yi restore <claude|codex|all> restore the latest protected config");
|
|
11
16
|
process.exit(0);
|
|
12
17
|
}
|
|
13
18
|
|
|
19
|
+
function parseConnectArgs(args) {
|
|
20
|
+
let target = "all";
|
|
21
|
+
let scope = "user";
|
|
22
|
+
let platformUrl;
|
|
23
|
+
let claudeBaseUrl;
|
|
24
|
+
let codexBaseUrl;
|
|
25
|
+
let skipCheck = false;
|
|
26
|
+
let autoInstall = false;
|
|
27
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
28
|
+
const arg = args[i];
|
|
29
|
+
if (!arg.startsWith("-") && i === 0) target = arg;
|
|
30
|
+
else if (arg === "--scope") scope = args[++i];
|
|
31
|
+
else if (arg === "--platform-url") platformUrl = args[++i];
|
|
32
|
+
else if (arg === "--claude-base-url") claudeBaseUrl = args[++i];
|
|
33
|
+
else if (arg === "--codex-base-url") codexBaseUrl = args[++i];
|
|
34
|
+
else if (arg === "--skip-codex-responses-check") skipCheck = true;
|
|
35
|
+
else if (arg === "--yes" || arg === "-y") autoInstall = true;
|
|
36
|
+
else throw new Error(`Unknown option: ${arg}`);
|
|
37
|
+
}
|
|
38
|
+
return { target, scope, platformUrl, claudeBaseUrl, codexBaseUrl, skipCheck, autoInstall };
|
|
39
|
+
}
|
|
40
|
+
|
|
14
41
|
/** Split out `--model <id>` / `--model=<id>` / `-m <id>`; the rest pass through to OpenCode. */
|
|
15
42
|
function parseCodeArgs(args) {
|
|
16
43
|
let preferredModel = null;
|
|
@@ -50,6 +77,21 @@ try {
|
|
|
50
77
|
const { preferredModel, passthrough } = parseCodeArgs(process.argv.slice(3));
|
|
51
78
|
const code = await runCode({ session, argv: passthrough, preferredModel });
|
|
52
79
|
process.exit(Number(code || 0));
|
|
80
|
+
} else if (command === "connect") {
|
|
81
|
+
const connectArgs = parseConnectArgs(process.argv.slice(3));
|
|
82
|
+
await prepareConnectionTools(connectArgs);
|
|
83
|
+
let session = loadSession();
|
|
84
|
+
if (!session.token) {
|
|
85
|
+
await login();
|
|
86
|
+
session = loadSession();
|
|
87
|
+
}
|
|
88
|
+
await connect({ session, ...connectArgs, skipToolCheck: true });
|
|
89
|
+
} else if (command === "status") {
|
|
90
|
+
const { target, scope } = parseConnectArgs(process.argv.slice(3));
|
|
91
|
+
connectionStatus({ target, scope });
|
|
92
|
+
} else if (command === "restore") {
|
|
93
|
+
const { target } = parseConnectArgs(process.argv.slice(3));
|
|
94
|
+
restoreConnection({ target });
|
|
53
95
|
} else {
|
|
54
96
|
console.error(`Unknown command: ${command}`);
|
|
55
97
|
process.exit(1);
|
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -21,10 +21,12 @@ export function normalizeBaseUrl(value) {
|
|
|
21
21
|
|
|
22
22
|
export function pathsForHome(home = os.homedir()) {
|
|
23
23
|
const homeDir = path.join(home, ".4yi");
|
|
24
|
+
const backupsDir = path.join(homeDir, "backups");
|
|
24
25
|
const opencodeDir = path.join(homeDir, "vendor", "opencode");
|
|
25
26
|
const opencodeConfigDir = path.join(homeDir, "opencode");
|
|
26
27
|
return {
|
|
27
28
|
homeDir,
|
|
29
|
+
backupsDir,
|
|
28
30
|
configFile: path.join(homeDir, "config.json"),
|
|
29
31
|
opencodeDir,
|
|
30
32
|
opencodePackageFile: path.join(opencodeDir, "package.json"),
|
package/src/connect.mjs
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { ensureDir, pathsForHome } from "./config.mjs";
|
|
7
|
+
import { requestJson } from "./http.mjs";
|
|
8
|
+
|
|
9
|
+
const TARGETS = new Set(["claude", "codex", "all"]);
|
|
10
|
+
const CLAUDE_MANAGED_ENV_KEYS = [
|
|
11
|
+
"ANTHROPIC_BASE_URL",
|
|
12
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
13
|
+
"ANTHROPIC_MODEL",
|
|
14
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
15
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
16
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
17
|
+
"CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY",
|
|
18
|
+
];
|
|
19
|
+
const CODEX_ROOT_START = "# >>> 4yi-cli codex-root";
|
|
20
|
+
const CODEX_ROOT_END = "# <<< 4yi-cli codex-root";
|
|
21
|
+
const CODEX_PROVIDER_START = "# >>> 4yi-cli codex-provider";
|
|
22
|
+
const CODEX_PROVIDER_END = "# <<< 4yi-cli codex-provider";
|
|
23
|
+
const TOOL_METADATA = {
|
|
24
|
+
claude: {
|
|
25
|
+
label: "Claude Code",
|
|
26
|
+
command: "claude",
|
|
27
|
+
npmPackage: "@anthropic-ai/claude-code",
|
|
28
|
+
appLabel: "Claude App",
|
|
29
|
+
},
|
|
30
|
+
codex: {
|
|
31
|
+
label: "Codex",
|
|
32
|
+
command: "codex",
|
|
33
|
+
npmPackage: "@openai/codex",
|
|
34
|
+
appLabel: "Codex App",
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function normalizeTarget(target = "all") {
|
|
39
|
+
const value = String(target || "all").toLowerCase();
|
|
40
|
+
if (!TARGETS.has(value)) throw new Error(`Unknown target: ${target}. Use claude, codex, or all.`);
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function prepareConnectionTools({ target = "all", autoInstall = false, stdout = console.log, tooling = {} } = {}) {
|
|
45
|
+
const normalized = normalizeTarget(target);
|
|
46
|
+
if (normalized === "claude" || normalized === "all") {
|
|
47
|
+
await ensureToolCli("claude", { ...tooling, autoInstall, stdout });
|
|
48
|
+
}
|
|
49
|
+
if (normalized === "codex" || normalized === "all") {
|
|
50
|
+
await ensureToolCli("codex", { ...tooling, autoInstall, stdout });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function connectionPaths({ home = os.homedir(), cwd = process.cwd(), codexHome = process.env.CODEX_HOME } = {}) {
|
|
55
|
+
const codexDir = codexHome || path.join(home, ".codex");
|
|
56
|
+
return {
|
|
57
|
+
claudeUser: path.join(home, ".claude", "settings.json"),
|
|
58
|
+
claudeProject: path.join(cwd, ".claude", "settings.local.json"),
|
|
59
|
+
codexConfig: path.join(codexDir, "config.toml"),
|
|
60
|
+
codexCatalog: path.join(codexDir, "model-catalogs", "4yi.json"),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function atomicWrite(file, value) {
|
|
65
|
+
ensureDir(path.dirname(file));
|
|
66
|
+
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
67
|
+
fs.writeFileSync(temp, value, { mode: 0o600 });
|
|
68
|
+
fs.renameSync(temp, file);
|
|
69
|
+
try { fs.chmodSync(file, 0o600); } catch { /* Windows may ignore POSIX modes. */ }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function timestamp() {
|
|
73
|
+
return new Date().toISOString().replace(/[-:.TZ]/g, "");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function executableName(command, platform = process.platform) {
|
|
77
|
+
return platform === "win32" ? `${command}.cmd` : command;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function commandAvailable(command, { platform = process.platform, spawn = spawnSync } = {}) {
|
|
81
|
+
const result = spawn(executableName(command, platform), ["--version"], {
|
|
82
|
+
encoding: "utf8",
|
|
83
|
+
shell: platform === "win32",
|
|
84
|
+
stdio: "ignore",
|
|
85
|
+
});
|
|
86
|
+
return !result.error && result.status === 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function promptToInstall(meta, { input = process.stdin, output = process.stdout } = {}) {
|
|
90
|
+
if (!input?.isTTY || !output?.isTTY) return false;
|
|
91
|
+
const readline = createInterface({ input, output });
|
|
92
|
+
try {
|
|
93
|
+
const answer = await readline.question(`${meta.label} CLI is not installed. Install ${meta.npmPackage} globally with npm? [Y/n] `);
|
|
94
|
+
return !["n", "no"].includes(answer.trim().toLowerCase());
|
|
95
|
+
} finally {
|
|
96
|
+
readline.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function ensureToolCli(target, {
|
|
101
|
+
autoInstall = false,
|
|
102
|
+
confirmInstall = promptToInstall,
|
|
103
|
+
input = process.stdin,
|
|
104
|
+
output = process.stdout,
|
|
105
|
+
platform = process.platform,
|
|
106
|
+
spawn = spawnSync,
|
|
107
|
+
stdout = console.log,
|
|
108
|
+
} = {}) {
|
|
109
|
+
const meta = TOOL_METADATA[target];
|
|
110
|
+
if (commandAvailable(meta.command, { platform, spawn })) return { installed: false };
|
|
111
|
+
|
|
112
|
+
const approved = autoInstall || await confirmInstall(meta, { input, output });
|
|
113
|
+
if (!approved) {
|
|
114
|
+
throw new Error(`${meta.label} CLI is required. Install it with \`npm install -g ${meta.npmPackage}\`, then run this command again.`);
|
|
115
|
+
}
|
|
116
|
+
if (!commandAvailable("npm", { platform, spawn })) {
|
|
117
|
+
throw new Error(`npm is required to install ${meta.label} CLI automatically. Install Node.js/npm, then run this command again.`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
stdout(`Installing ${meta.label} CLI (${meta.npmPackage})...`);
|
|
121
|
+
const result = spawn(executableName("npm", platform), ["install", "-g", meta.npmPackage], {
|
|
122
|
+
shell: platform === "win32",
|
|
123
|
+
stdio: "inherit",
|
|
124
|
+
});
|
|
125
|
+
if (result.error || result.status !== 0) {
|
|
126
|
+
throw new Error(`Could not install ${meta.label} CLI automatically. Run \`npm install -g ${meta.npmPackage}\` and try again.`);
|
|
127
|
+
}
|
|
128
|
+
if (!commandAvailable(meta.command, { platform, spawn })) {
|
|
129
|
+
throw new Error(`${meta.label} CLI was installed, but \`${meta.command}\` is not available in this terminal. Open a new terminal and run this command again.`);
|
|
130
|
+
}
|
|
131
|
+
stdout(`${meta.label} CLI installed.`);
|
|
132
|
+
return { installed: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function desktopAppCandidates(target, { home = os.homedir(), platform = process.platform, env = process.env } = {}) {
|
|
136
|
+
const appName = target === "claude" ? "Claude" : "Codex";
|
|
137
|
+
if (platform === "darwin") {
|
|
138
|
+
return [
|
|
139
|
+
path.join("/Applications", `${appName}.app`),
|
|
140
|
+
path.join(home, "Applications", `${appName}.app`),
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
if (platform === "win32") {
|
|
144
|
+
const localAppData = env.LOCALAPPDATA || path.join(home, "AppData", "Local");
|
|
145
|
+
return [
|
|
146
|
+
path.join(localAppData, "Programs", appName, `${appName}.exe`),
|
|
147
|
+
path.join(localAppData, appName, `${appName}.exe`),
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function reportDesktopApp(target, {
|
|
154
|
+
home = os.homedir(),
|
|
155
|
+
platform = process.platform,
|
|
156
|
+
env = process.env,
|
|
157
|
+
exists = fs.existsSync,
|
|
158
|
+
stdout = console.log,
|
|
159
|
+
} = {}) {
|
|
160
|
+
const meta = TOOL_METADATA[target];
|
|
161
|
+
const candidates = desktopAppCandidates(target, { home, platform, env });
|
|
162
|
+
const detected = candidates.some((candidate) => exists(candidate));
|
|
163
|
+
if (detected) {
|
|
164
|
+
stdout(`${meta.appLabel} detected. Quit and reopen it to use the new connection.`);
|
|
165
|
+
} else if (candidates.length > 0) {
|
|
166
|
+
stdout(`${meta.appLabel} was not detected in a standard install location. 4YI does not install desktop apps automatically.`);
|
|
167
|
+
} else {
|
|
168
|
+
stdout(`${meta.appLabel} detection is not available on this platform. 4YI does not install desktop apps automatically.`);
|
|
169
|
+
}
|
|
170
|
+
return detected;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function backupFile(target, file, home, groupId = timestamp()) {
|
|
174
|
+
const dir = path.join(pathsForHome(home).backupsDir, target);
|
|
175
|
+
ensureDir(dir);
|
|
176
|
+
const record = {
|
|
177
|
+
version: 1,
|
|
178
|
+
target,
|
|
179
|
+
group_id: groupId,
|
|
180
|
+
source: file,
|
|
181
|
+
existed: fs.existsSync(file),
|
|
182
|
+
content: fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null,
|
|
183
|
+
created_at: new Date().toISOString(),
|
|
184
|
+
};
|
|
185
|
+
const backup = path.join(dir, `${groupId}-${Math.random().toString(16).slice(2, 10)}.json`);
|
|
186
|
+
atomicWrite(backup, `${JSON.stringify(record, null, 2)}\n`);
|
|
187
|
+
return backup;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function latestBackups(target, home) {
|
|
191
|
+
const dir = path.join(pathsForHome(home).backupsDir, target);
|
|
192
|
+
if (!fs.existsSync(dir)) return [];
|
|
193
|
+
const entries = fs.readdirSync(dir)
|
|
194
|
+
.filter((entry) => entry.endsWith(".json"))
|
|
195
|
+
.sort()
|
|
196
|
+
.reverse();
|
|
197
|
+
if (!entries[0]) return [];
|
|
198
|
+
const latest = path.join(dir, entries[0]);
|
|
199
|
+
const latestRecord = JSON.parse(fs.readFileSync(latest, "utf8"));
|
|
200
|
+
const groupId = latestRecord.group_id;
|
|
201
|
+
if (!groupId) return [latest];
|
|
202
|
+
return entries
|
|
203
|
+
.map((entry) => path.join(dir, entry))
|
|
204
|
+
.filter((file) => JSON.parse(fs.readFileSync(file, "utf8")).group_id === groupId);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function mask(value) {
|
|
208
|
+
const text = String(value || "");
|
|
209
|
+
if (!text) return "";
|
|
210
|
+
return text.length > 12 ? `${text.slice(0, 6)}...${text.slice(-4)}` : `${text.slice(0, 2)}...${text.slice(-2)}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function chooseModel(models, preferred, keyword) {
|
|
214
|
+
if (preferred && models.includes(preferred)) return preferred;
|
|
215
|
+
const match = models.find((model) => model.toLowerCase().includes(keyword));
|
|
216
|
+
return match || models[0];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function loadClaudeModels(session, claudeBaseUrl) {
|
|
220
|
+
const base = claudeBaseUrl.replace(/\/+$/, "");
|
|
221
|
+
const response = await requestJson(base, "/v1/models", { token: session.token });
|
|
222
|
+
const models = [...new Set((response?.data || []).map((row) => row?.id).filter(Boolean))];
|
|
223
|
+
if (models.length === 0) throw new Error("Your active Plan has no Claude models available for Coding Tools.");
|
|
224
|
+
return models;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function connectClaude({ session, home, cwd, scope, claudeBaseUrl, stdout }) {
|
|
228
|
+
const paths = connectionPaths({ home, cwd });
|
|
229
|
+
const files = scope === "project" ? [paths.claudeProject] : scope === "both" ? [paths.claudeUser, paths.claudeProject] : [paths.claudeUser];
|
|
230
|
+
const models = await loadClaudeModels(session, claudeBaseUrl);
|
|
231
|
+
const defaultModel = chooseModel(models, "claude-sonnet-4-6", "sonnet");
|
|
232
|
+
const opusModel = chooseModel(models, "", "opus");
|
|
233
|
+
const sonnetModel = chooseModel(models, defaultModel, "sonnet");
|
|
234
|
+
const haikuModel = chooseModel(models, "claude-haiku-4-5-20251001", "haiku");
|
|
235
|
+
|
|
236
|
+
const backupGroup = timestamp();
|
|
237
|
+
for (const file of files) {
|
|
238
|
+
const backup = backupFile("claude", file, home, backupGroup);
|
|
239
|
+
let data = {};
|
|
240
|
+
if (fs.existsSync(file) && fs.statSync(file).size > 0) {
|
|
241
|
+
data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
242
|
+
if (!data || Array.isArray(data) || typeof data !== "object") throw new Error(`${file} must contain a JSON object.`);
|
|
243
|
+
}
|
|
244
|
+
const env = data.env && !Array.isArray(data.env) && typeof data.env === "object" ? data.env : {};
|
|
245
|
+
Object.assign(env, {
|
|
246
|
+
ANTHROPIC_BASE_URL: claudeBaseUrl.replace(/\/+$/, ""),
|
|
247
|
+
ANTHROPIC_AUTH_TOKEN: session.token,
|
|
248
|
+
ANTHROPIC_MODEL: defaultModel,
|
|
249
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel,
|
|
250
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel,
|
|
251
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel,
|
|
252
|
+
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
|
|
253
|
+
});
|
|
254
|
+
delete env.ANTHROPIC_SMALL_FAST_MODEL;
|
|
255
|
+
data.env = env;
|
|
256
|
+
data.model = defaultModel;
|
|
257
|
+
data.availableModels = models;
|
|
258
|
+
atomicWrite(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
259
|
+
stdout(`Connected Claude Code: ${file}`);
|
|
260
|
+
stdout(`Backup: ${backup}`);
|
|
261
|
+
}
|
|
262
|
+
stdout(`Available Claude models: ${models.join(", ")}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function removeManagedBlock(text, start, end) {
|
|
266
|
+
const pattern = new RegExp(`^${escapeRegExp(start)}\\n[\\s\\S]*?^${escapeRegExp(end)}\\n?`, "gm");
|
|
267
|
+
return text.replace(pattern, "");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function removeCodexRootAssignments(text) {
|
|
271
|
+
const firstTable = text.search(/^\s*\[/m);
|
|
272
|
+
const root = firstTable === -1 ? text : text.slice(0, firstTable);
|
|
273
|
+
const tables = firstTable === -1 ? "" : text.slice(firstTable);
|
|
274
|
+
const cleanedRoot = root
|
|
275
|
+
.split("\n")
|
|
276
|
+
.filter((line) => !/^\s*(model|model_provider|model_catalog_json)\s*=/.test(line))
|
|
277
|
+
.join("\n")
|
|
278
|
+
.trim();
|
|
279
|
+
return `${cleanedRoot}${cleanedRoot && tables ? "\n\n" : ""}${tables}`.trim();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function escapeRegExp(value) {
|
|
283
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function parseBundledCatalog({ platform = process.platform, spawn = spawnSync } = {}) {
|
|
287
|
+
const command = executableName("codex", platform);
|
|
288
|
+
const result = spawn(command, ["debug", "models", "--bundled"], { encoding: "utf8", shell: platform === "win32" });
|
|
289
|
+
if (result.status !== 0 || !result.stdout) throw new Error("Codex CLI is required to build its 4YI model catalog. Install Codex, then run this command again.");
|
|
290
|
+
const catalog = JSON.parse(result.stdout);
|
|
291
|
+
const template = catalog.models?.find((model) => model.slug === "gpt-5.5") || catalog.models?.[0];
|
|
292
|
+
if (!template) throw new Error("Codex bundled model catalog is empty.");
|
|
293
|
+
return template;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function loadCodexModels(session) {
|
|
297
|
+
const response = await requestJson(session.baseUrl, "/api/cli/models?runtime=codex", { token: session.token });
|
|
298
|
+
const models = (response?.models || []).filter((row) => row?.id);
|
|
299
|
+
if (models.length === 0) throw new Error("Your active Plan has no Codex-compatible Responses models.");
|
|
300
|
+
const ids = models.map((model) => model.id);
|
|
301
|
+
const defaultModel = ids.includes(response.default_model) ? response.default_model : ids[0];
|
|
302
|
+
return { models, defaultModel };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function buildCodexCatalog(models, template) {
|
|
306
|
+
return {
|
|
307
|
+
models: models.map((planModel) => ({
|
|
308
|
+
...structuredClone(template),
|
|
309
|
+
slug: planModel.id,
|
|
310
|
+
display_name: planModel.display_name || planModel.id,
|
|
311
|
+
description: `${planModel.display_name || planModel.id} via 4YI Gateway with OpenAI Responses tool support.`,
|
|
312
|
+
supported_in_api: true,
|
|
313
|
+
use_responses_lite: false,
|
|
314
|
+
tool_mode: undefined,
|
|
315
|
+
})).map((model) => {
|
|
316
|
+
delete model.tool_mode;
|
|
317
|
+
return model;
|
|
318
|
+
}),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function checkCodex(session, codexBaseUrl, model) {
|
|
323
|
+
const response = await fetch(`${codexBaseUrl.replace(/\/+$/, "")}/responses`, {
|
|
324
|
+
method: "POST",
|
|
325
|
+
headers: {
|
|
326
|
+
"content-type": "application/json",
|
|
327
|
+
authorization: `Bearer ${session.token}`,
|
|
328
|
+
// The Responses ingress only accepts the native Codex request shape.
|
|
329
|
+
// Keep this synthetic compatibility check aligned with the real client
|
|
330
|
+
// so it exercises the same authorization and routing path.
|
|
331
|
+
originator: "codex_cli_rs",
|
|
332
|
+
},
|
|
333
|
+
body: JSON.stringify({
|
|
334
|
+
model,
|
|
335
|
+
instructions: "Reply with pong.",
|
|
336
|
+
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "ping" }] }],
|
|
337
|
+
tools: [],
|
|
338
|
+
tool_choice: "auto",
|
|
339
|
+
parallel_tool_calls: true,
|
|
340
|
+
store: false,
|
|
341
|
+
// Safe Responses ingress deliberately requires streaming requests. A
|
|
342
|
+
// JSON/non-streaming probe is rejected before routing, even though the
|
|
343
|
+
// same credential and model work in Codex itself.
|
|
344
|
+
stream: true,
|
|
345
|
+
include: [],
|
|
346
|
+
client_metadata: {
|
|
347
|
+
session_id: "4yi-cli-preflight",
|
|
348
|
+
thread_id: "4yi-cli-preflight",
|
|
349
|
+
},
|
|
350
|
+
}),
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
if (!response.ok) {
|
|
354
|
+
const text = await response.text();
|
|
355
|
+
let body;
|
|
356
|
+
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
|
|
357
|
+
const message = body?.error?.message || body?.error || text || `HTTP ${response.status}`;
|
|
358
|
+
const error = new Error(typeof message === "string" ? message : JSON.stringify(message));
|
|
359
|
+
error.status = response.status;
|
|
360
|
+
error.body = body;
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// A successful native probe is an SSE stream. Compatibility is established
|
|
365
|
+
// once the response starts; do not consume a model response just for setup.
|
|
366
|
+
if (response.body) await response.body.cancel();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function connectCodex({ session, home, codexHome, codexBaseUrl, stdout, skipCheck = false, tooling = {} }) {
|
|
370
|
+
const paths = connectionPaths({ home, codexHome });
|
|
371
|
+
const { models, defaultModel } = await loadCodexModels(session);
|
|
372
|
+
if (!skipCheck) await checkCodex(session, codexBaseUrl, defaultModel);
|
|
373
|
+
ensureDir(path.dirname(paths.codexConfig));
|
|
374
|
+
const template = parseBundledCatalog(tooling);
|
|
375
|
+
const catalog = buildCodexCatalog(models, template);
|
|
376
|
+
atomicWrite(paths.codexCatalog, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
377
|
+
|
|
378
|
+
const backup = backupFile("codex", paths.codexConfig, home);
|
|
379
|
+
let existing = fs.existsSync(paths.codexConfig) ? fs.readFileSync(paths.codexConfig, "utf8") : "";
|
|
380
|
+
existing = removeManagedBlock(existing, CODEX_ROOT_START, CODEX_ROOT_END);
|
|
381
|
+
existing = removeManagedBlock(existing, CODEX_PROVIDER_START, CODEX_PROVIDER_END);
|
|
382
|
+
existing = removeCodexRootAssignments(existing);
|
|
383
|
+
const root = `${CODEX_ROOT_START}\nmodel = ${JSON.stringify(defaultModel)}\nmodel_provider = "4yi"\nmodel_catalog_json = ${JSON.stringify(paths.codexCatalog)}\n${CODEX_ROOT_END}`;
|
|
384
|
+
const provider = `${CODEX_PROVIDER_START}\n[model_providers."4yi"]\nname = "4YI Gateway"\nbase_url = ${JSON.stringify(codexBaseUrl.replace(/\/+$/, ""))}\nwire_api = "responses"\nexperimental_bearer_token = ${JSON.stringify(session.token)}\n${CODEX_PROVIDER_END}`;
|
|
385
|
+
atomicWrite(paths.codexConfig, `${root}\n\n${existing ? `${existing}\n\n` : ""}${provider}\n`);
|
|
386
|
+
stdout(`Connected Codex: ${paths.codexConfig}`);
|
|
387
|
+
stdout(`Available Codex models: ${models.map((model) => model.id).join(", ")}`);
|
|
388
|
+
stdout(`Backup: ${backup}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function resolveUrls(session, options) {
|
|
392
|
+
const platformUrl = String(options.platformUrl || session.baseUrl).replace(/\/+$/, "");
|
|
393
|
+
return {
|
|
394
|
+
claudeBaseUrl: String(options.claudeBaseUrl || `${platformUrl}/api/anthropic`).replace(/\/+$/, ""),
|
|
395
|
+
codexBaseUrl: String(options.codexBaseUrl || `${platformUrl}/api/v1`).replace(/\/+$/, ""),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function connect({ target = "all", session, home = os.homedir(), cwd = process.cwd(), codexHome = process.env.CODEX_HOME, scope = "user", stdout = console.log, ...options } = {}) {
|
|
400
|
+
const normalized = normalizeTarget(target);
|
|
401
|
+
if (!session?.token) throw new Error("Not signed in. Run `4yi login`.");
|
|
402
|
+
if (!new Set(["user", "project", "both"]).has(scope)) throw new Error("Claude scope must be user, project, or both.");
|
|
403
|
+
const urls = resolveUrls(session, options);
|
|
404
|
+
const tooling = options.tooling || {};
|
|
405
|
+
if (!options.skipToolCheck) {
|
|
406
|
+
await prepareConnectionTools({ target: normalized, autoInstall: options.autoInstall, stdout, tooling });
|
|
407
|
+
}
|
|
408
|
+
if (normalized === "claude" || normalized === "all") {
|
|
409
|
+
await connectClaude({ session, home, cwd, scope, claudeBaseUrl: urls.claudeBaseUrl, stdout });
|
|
410
|
+
reportDesktopApp("claude", { ...tooling, home, stdout });
|
|
411
|
+
}
|
|
412
|
+
if (normalized === "codex" || normalized === "all") {
|
|
413
|
+
await connectCodex({ session, home, codexHome, codexBaseUrl: urls.codexBaseUrl, stdout, skipCheck: options.skipCheck, tooling });
|
|
414
|
+
reportDesktopApp("codex", { ...tooling, home, stdout });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function readClaudeStatus(file) {
|
|
419
|
+
if (!fs.existsSync(file)) return { file, connected: false };
|
|
420
|
+
try {
|
|
421
|
+
const data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
422
|
+
const env = data?.env || {};
|
|
423
|
+
return { file, connected: Boolean(env.ANTHROPIC_BASE_URL && env.ANTHROPIC_AUTH_TOKEN), baseUrl: env.ANTHROPIC_BASE_URL, model: env.ANTHROPIC_MODEL, token: mask(env.ANTHROPIC_AUTH_TOKEN) };
|
|
424
|
+
} catch (error) {
|
|
425
|
+
return { file, connected: false, error: error.message };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function connectionStatus({ target = "all", home = os.homedir(), cwd = process.cwd(), codexHome = process.env.CODEX_HOME, scope = "user", stdout = console.log } = {}) {
|
|
430
|
+
const normalized = normalizeTarget(target);
|
|
431
|
+
const paths = connectionPaths({ home, cwd, codexHome });
|
|
432
|
+
if (normalized === "claude" || normalized === "all") {
|
|
433
|
+
const files = scope === "project" ? [paths.claudeProject] : scope === "both" ? [paths.claudeUser, paths.claudeProject] : [paths.claudeUser];
|
|
434
|
+
for (const file of files) stdout(`Claude Code: ${JSON.stringify(readClaudeStatus(file))}`);
|
|
435
|
+
}
|
|
436
|
+
if (normalized === "codex" || normalized === "all") {
|
|
437
|
+
const text = fs.existsSync(paths.codexConfig) ? fs.readFileSync(paths.codexConfig, "utf8") : "";
|
|
438
|
+
stdout(`Codex: ${JSON.stringify({ file: paths.codexConfig, connected: text.includes(CODEX_PROVIDER_START), catalog: paths.codexCatalog })}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function restoreOne(target, home, stdout, { required = true } = {}) {
|
|
443
|
+
const backups = latestBackups(target, home);
|
|
444
|
+
if (backups.length === 0) {
|
|
445
|
+
if (required) throw new Error(`No ${target} backup found.`);
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
for (const backup of backups) {
|
|
449
|
+
const record = JSON.parse(fs.readFileSync(backup, "utf8"));
|
|
450
|
+
if (record.existed) atomicWrite(record.source, record.content || "");
|
|
451
|
+
else if (fs.existsSync(record.source)) fs.unlinkSync(record.source);
|
|
452
|
+
stdout(`Restored ${target}: ${record.source}`);
|
|
453
|
+
stdout(`From: ${backup}`);
|
|
454
|
+
}
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function restoreConnection({ target = "all", home = os.homedir(), stdout = console.log } = {}) {
|
|
459
|
+
const normalized = normalizeTarget(target);
|
|
460
|
+
if (normalized === "claude") return restoreOne("claude", home, stdout);
|
|
461
|
+
if (normalized === "codex") return restoreOne("codex", home, stdout);
|
|
462
|
+
const restoredClaude = restoreOne("claude", home, stdout, { required: false });
|
|
463
|
+
const restoredCodex = restoreOne("codex", home, stdout, { required: false });
|
|
464
|
+
if (!restoredClaude && !restoredCodex) throw new Error("No Claude or Codex backup found.");
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export const __testing = {
|
|
469
|
+
buildCodexCatalog,
|
|
470
|
+
checkCodex,
|
|
471
|
+
commandAvailable,
|
|
472
|
+
desktopAppCandidates,
|
|
473
|
+
ensureToolCli,
|
|
474
|
+
executableName,
|
|
475
|
+
reportDesktopApp,
|
|
476
|
+
removeManagedBlock,
|
|
477
|
+
removeCodexRootAssignments,
|
|
478
|
+
CLAUDE_MANAGED_ENV_KEYS,
|
|
479
|
+
};
|
package/src/opencode.mjs
CHANGED
|
@@ -10,7 +10,6 @@ const DEFAULT_CONTEXT_LIMIT = 200000;
|
|
|
10
10
|
const DEFAULT_OUTPUT_LIMIT = 8192;
|
|
11
11
|
|
|
12
12
|
function isClaudeModel(model) {
|
|
13
|
-
if (model?.family === "claude") return true;
|
|
14
13
|
const id = model?.id || "";
|
|
15
14
|
const name = model?.display_name || "";
|
|
16
15
|
return /claude/i.test(id) || /claude/i.test(name);
|
|
@@ -24,23 +23,14 @@ function modelOutputLimit(model) {
|
|
|
24
23
|
return model.output_limit || model.max_output_tokens || model.max_tokens || DEFAULT_OUTPUT_LIMIT;
|
|
25
24
|
}
|
|
26
25
|
|
|
27
|
-
function modelPickerName(model) {
|
|
28
|
-
const displayName = model.display_name || model.id;
|
|
29
|
-
if (model.family === "claude") {
|
|
30
|
-
return `Claude · ${displayName.replace(/^(?:anthropic[ ._-]*)?claude[ ._-]*/i, "")}`;
|
|
31
|
-
}
|
|
32
|
-
if (model.family === "codex") return `Codex · ${displayName}`;
|
|
33
|
-
return displayName;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
26
|
export function buildOpenCodeConfig({ modelConfig, preferredModel = null, tokenEnv = "FOURYI_CLI_TOKEN", orgEnv = "FOURYI_ORG_ID" }) {
|
|
37
|
-
// Register
|
|
38
|
-
// (`Tab` / `/models`) can move between
|
|
27
|
+
// Register EVERY model the server returned so OpenCode's native switcher
|
|
28
|
+
// (`Tab` / `/models`) can move between them. Claude stays the default.
|
|
39
29
|
const list = modelConfig.models || [];
|
|
40
30
|
const models = {};
|
|
41
31
|
for (const model of list) {
|
|
42
32
|
const entry = {
|
|
43
|
-
name:
|
|
33
|
+
name: model.display_name || model.id,
|
|
44
34
|
limit: {
|
|
45
35
|
context: modelContextLimit(model),
|
|
46
36
|
output: modelOutputLimit(model),
|
|
@@ -121,7 +111,7 @@ export function ensureOpenCodeRuntime({ home = os.homedir(), stdout = console.lo
|
|
|
121
111
|
|
|
122
112
|
export async function runCode({ session, home = os.homedir(), argv = [], stdout = console.log, preferredModel = null } = {}) {
|
|
123
113
|
if (!session?.token) throw new Error("Not signed in. Run `4yi login`.");
|
|
124
|
-
const modelConfig = await requestJson(session.baseUrl, "/api/cli/models
|
|
114
|
+
const modelConfig = await requestJson(session.baseUrl, "/api/cli/models", { token: session.token });
|
|
125
115
|
const list = modelConfig.models || [];
|
|
126
116
|
if (list.length === 0) throw new Error("No chat models available for this organization.");
|
|
127
117
|
|