@4yi-dev/cli 0.1.12 → 0.1.14
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 +7 -0
- package/bin/4yi.mjs +32 -1
- package/package.json +1 -1
- package/src/codex-migrate.mjs +256 -0
- package/src/connect.mjs +67 -8
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ Both packages install the `4yi` command. The dev package points at `https://xcla
|
|
|
26
26
|
4yi code
|
|
27
27
|
4yi connect claude
|
|
28
28
|
4yi connect codex
|
|
29
|
+
4yi migrate codex
|
|
29
30
|
4yi status all
|
|
30
31
|
4yi restore all
|
|
31
32
|
```
|
|
@@ -38,6 +39,12 @@ On macOS, `4yi connect claude` also configures an installed Claude Desktop throu
|
|
|
38
39
|
|
|
39
40
|
`--scope project` changes only the project's Claude Code settings and never changes the global Claude Desktop profile.
|
|
40
41
|
|
|
42
|
+
Codex CLI and Codex App share the same Codex home on native Windows (`%USERPROFILE%\\.codex`). After `4yi connect codex`, finish active App tasks, fully quit and reopen the App, and start a new chat so its embedded Codex process loads the new provider. Microsoft Store/AppX installations may not expose a conventional executable path, but that detection does not gate the shared configuration update.
|
|
43
|
+
|
|
44
|
+
Existing Codex tasks keep the provider they were created with. Run `4yi migrate codex`, choose a recent task by number, and 4YI creates a new task with the same history and project using your Plan's default model. The original task is not modified. Scripts can use `--thread <id>` and optionally `--model <id>`.
|
|
45
|
+
|
|
46
|
+
The Codex provider uses the official command-backed authentication configuration. Its helper reads the current token from `~/.4yi/config.json`; `config.toml` and the model catalog do not contain the bearer token.
|
|
47
|
+
|
|
41
48
|
For local development:
|
|
42
49
|
|
|
43
50
|
```bash
|
package/bin/4yi.mjs
CHANGED
|
@@ -2,16 +2,20 @@
|
|
|
2
2
|
import { login, loadSession, clearSession } from "../src/auth.mjs";
|
|
3
3
|
import { runCode } from "../src/opencode.mjs";
|
|
4
4
|
import { connect, connectionStatus, prepareConnectionTools, restoreConnection } from "../src/connect.mjs";
|
|
5
|
+
import { migrateCodexTask } from "../src/codex-migrate.mjs";
|
|
5
6
|
|
|
6
7
|
const command = process.argv[2] || "help";
|
|
7
8
|
|
|
8
9
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
9
|
-
console.log("Usage: 4yi <login|whoami|logout|code|connect|status|restore>");
|
|
10
|
+
console.log("Usage: 4yi <login|whoami|logout|code|connect|migrate|status|restore>");
|
|
10
11
|
console.log(" 4yi code launch OpenCode; switch models live with Tab / /models");
|
|
11
12
|
console.log(" 4yi code --model X pin model X as the default for future sessions");
|
|
12
13
|
console.log(" 4yi connect <claude|codex|all> connect existing coding tools to 4YI");
|
|
13
14
|
console.log(" --yes install a missing CLI without prompting");
|
|
14
15
|
console.log(" Claude on macOS also configures installed Claude Desktop 3P Gateway mode");
|
|
16
|
+
console.log(" 4yi migrate codex continue an existing Codex task through 4YI");
|
|
17
|
+
console.log(" --thread ID select a specific task without prompting");
|
|
18
|
+
console.log(" --model X choose a model (defaults to your Plan model)");
|
|
15
19
|
console.log(" 4yi status [claude|codex|all] inspect the current connection");
|
|
16
20
|
console.log(" 4yi restore <claude|codex|all> restore the latest protected config");
|
|
17
21
|
process.exit(0);
|
|
@@ -39,6 +43,29 @@ function parseConnectArgs(args) {
|
|
|
39
43
|
return { target, scope, platformUrl, claudeBaseUrl, codexBaseUrl, skipCheck, autoInstall };
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
function parseMigrateArgs(args) {
|
|
47
|
+
const target = args[0];
|
|
48
|
+
if (target !== "codex") throw new Error("Use `4yi migrate codex`.");
|
|
49
|
+
let threadId;
|
|
50
|
+
let model;
|
|
51
|
+
for (let i = 1; i < args.length; i += 1) {
|
|
52
|
+
const arg = args[i];
|
|
53
|
+
if (arg === "--thread") {
|
|
54
|
+
threadId = args[++i];
|
|
55
|
+
if (!threadId || threadId.startsWith("-")) throw new Error("--thread requires a task id.");
|
|
56
|
+
}
|
|
57
|
+
else if (arg.startsWith("--thread=")) threadId = arg.slice("--thread=".length);
|
|
58
|
+
else if (arg === "--model" || arg === "-m") {
|
|
59
|
+
model = args[++i];
|
|
60
|
+
if (!model || model.startsWith("-")) throw new Error("--model requires a model id.");
|
|
61
|
+
}
|
|
62
|
+
else if (arg.startsWith("--model=")) model = arg.slice("--model=".length);
|
|
63
|
+
else throw new Error(`Unknown option: ${arg}`);
|
|
64
|
+
}
|
|
65
|
+
if (threadId === "" || model === "") throw new Error("--thread and --model require a value.");
|
|
66
|
+
return { threadId, model };
|
|
67
|
+
}
|
|
68
|
+
|
|
42
69
|
/** Split out `--model <id>` / `--model=<id>` / `-m <id>`; the rest pass through to OpenCode. */
|
|
43
70
|
function parseCodeArgs(args) {
|
|
44
71
|
let preferredModel = null;
|
|
@@ -87,6 +114,10 @@ try {
|
|
|
87
114
|
session = loadSession();
|
|
88
115
|
}
|
|
89
116
|
await connect({ session, ...connectArgs, skipToolCheck: true });
|
|
117
|
+
} else if (command === "migrate") {
|
|
118
|
+
const migrateArgs = parseMigrateArgs(process.argv.slice(3));
|
|
119
|
+
const session = loadSession();
|
|
120
|
+
await migrateCodexTask({ session, ...migrateArgs });
|
|
90
121
|
} else if (command === "status") {
|
|
91
122
|
const { target, scope } = parseConnectArgs(process.argv.slice(3));
|
|
92
123
|
connectionStatus({ target, scope });
|
package/package.json
CHANGED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { requestJson } from "./http.mjs";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_LIMIT = 10;
|
|
8
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
9
|
+
|
|
10
|
+
function executableName(command, platform = process.platform) {
|
|
11
|
+
return platform === "win32" ? `${command}.cmd` : command;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function appServerLaunch({ command, args, env, platform }) {
|
|
15
|
+
if (platform !== "win32") return { command, args, shell: false };
|
|
16
|
+
// Node 24 warns when `shell: true` receives an argv array (DEP0190). Invoke
|
|
17
|
+
// cmd.exe explicitly with constant, non-user-controlled arguments instead.
|
|
18
|
+
const shell = env.ComSpec || env.COMSPEC || "cmd.exe";
|
|
19
|
+
return { command: shell, args: ["/d", "/s", "/c", `${command} ${args.join(" ")}`], shell: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function oneLine(value, fallback) {
|
|
23
|
+
const line = String(value || "").split(/\r?\n/, 1)[0].replace(/\s+/g, " ").trim();
|
|
24
|
+
if (!line) return fallback;
|
|
25
|
+
return line.length > 64 ? `${line.slice(0, 61)}...` : line;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function relativeTime(timestamp, now = Date.now()) {
|
|
29
|
+
const elapsed = Math.max(0, Math.floor(now / 1000) - Number(timestamp || 0));
|
|
30
|
+
if (elapsed < 60) return "刚刚";
|
|
31
|
+
if (elapsed < 3600) return `${Math.floor(elapsed / 60)} 分钟前`;
|
|
32
|
+
if (elapsed < 86400) return `${Math.floor(elapsed / 3600)} 小时前`;
|
|
33
|
+
if (elapsed < 86400 * 30) return `${Math.floor(elapsed / 86400)} 天前`;
|
|
34
|
+
return new Date(Number(timestamp) * 1000).toLocaleDateString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function describeCodexThread(thread, { now = Date.now() } = {}) {
|
|
38
|
+
const title = oneLine(thread.name || thread.preview, "未命名任务");
|
|
39
|
+
const project = path.basename(String(thread.cwd || "")) || String(thread.cwd || "未知项目");
|
|
40
|
+
const updatedAt = thread.recencyAt || thread.updatedAt || thread.createdAt;
|
|
41
|
+
return { title, project, age: relativeTime(updatedAt, now) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function selectCodexThread(threads, {
|
|
45
|
+
input = process.stdin,
|
|
46
|
+
output = process.stdout,
|
|
47
|
+
now = Date.now(),
|
|
48
|
+
} = {}) {
|
|
49
|
+
if (!input?.isTTY || !output?.isTTY) {
|
|
50
|
+
throw new Error("Interactive task selection requires a terminal. Use --thread <id> in scripts.");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
output.write("选择要迁移到 4YI 的 Codex 任务:\n\n");
|
|
54
|
+
threads.forEach((thread, index) => {
|
|
55
|
+
const item = describeCodexThread(thread, { now });
|
|
56
|
+
output.write(`${index + 1}. ${item.title}\n ${item.project} · ${item.age}\n`);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const readline = createInterface({ input, output });
|
|
60
|
+
try {
|
|
61
|
+
while (true) {
|
|
62
|
+
const answer = await readline.question("\n输入序号(默认 1,q 取消):");
|
|
63
|
+
const value = answer.trim().toLowerCase();
|
|
64
|
+
if (value === "q" || value === "quit") return null;
|
|
65
|
+
const index = value === "" ? 0 : Number(value) - 1;
|
|
66
|
+
if (Number.isInteger(index) && index >= 0 && index < threads.length) return threads[index];
|
|
67
|
+
output.write(`请输入 1-${threads.length} 之间的序号。\n`);
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
readline.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class CodexAppServerClient {
|
|
75
|
+
constructor({
|
|
76
|
+
command = executableName("codex"),
|
|
77
|
+
args = ["app-server", "--stdio"],
|
|
78
|
+
env = process.env,
|
|
79
|
+
platform = process.platform,
|
|
80
|
+
spawn = nodeSpawn,
|
|
81
|
+
timeoutMs = REQUEST_TIMEOUT_MS,
|
|
82
|
+
} = {}) {
|
|
83
|
+
this.nextId = 1;
|
|
84
|
+
this.pending = new Map();
|
|
85
|
+
this.stderr = "";
|
|
86
|
+
this.timeoutMs = timeoutMs;
|
|
87
|
+
const launch = appServerLaunch({ command, args, env, platform });
|
|
88
|
+
this.child = spawn(launch.command, launch.args, {
|
|
89
|
+
env,
|
|
90
|
+
shell: launch.shell,
|
|
91
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
let stdoutBuffer = "";
|
|
95
|
+
this.child.stdout.setEncoding("utf8");
|
|
96
|
+
this.child.stdout.on("data", (chunk) => {
|
|
97
|
+
stdoutBuffer += chunk;
|
|
98
|
+
const lines = stdoutBuffer.split(/\r?\n/);
|
|
99
|
+
stdoutBuffer = lines.pop() || "";
|
|
100
|
+
for (const line of lines) this.#handleLine(line);
|
|
101
|
+
});
|
|
102
|
+
this.child.stderr.setEncoding("utf8");
|
|
103
|
+
this.child.stderr.on("data", (chunk) => {
|
|
104
|
+
this.stderr = `${this.stderr}${chunk}`.slice(-4000);
|
|
105
|
+
});
|
|
106
|
+
this.child.on("error", (error) => this.#rejectAll(error));
|
|
107
|
+
this.child.on("exit", (code, signal) => {
|
|
108
|
+
if (this.pending.size === 0) return;
|
|
109
|
+
const detail = this.stderr.trim();
|
|
110
|
+
const exitReason = signal || (code ?? "unknown");
|
|
111
|
+
this.#rejectAll(new Error(`Codex app-server stopped (${exitReason}).${detail ? ` ${detail}` : ""}`));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#handleLine(line) {
|
|
116
|
+
if (!line.trim()) return;
|
|
117
|
+
let message;
|
|
118
|
+
try { message = JSON.parse(line); } catch { return; }
|
|
119
|
+
if (message.id === undefined || message.id === null) return;
|
|
120
|
+
const pending = this.pending.get(message.id);
|
|
121
|
+
if (!pending) return;
|
|
122
|
+
this.pending.delete(message.id);
|
|
123
|
+
clearTimeout(pending.timer);
|
|
124
|
+
if (message.error) {
|
|
125
|
+
const text = message.error.message || JSON.stringify(message.error);
|
|
126
|
+
pending.reject(new Error(text));
|
|
127
|
+
} else {
|
|
128
|
+
pending.resolve(message.result);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#rejectAll(error) {
|
|
133
|
+
for (const pending of this.pending.values()) {
|
|
134
|
+
clearTimeout(pending.timer);
|
|
135
|
+
pending.reject(error);
|
|
136
|
+
}
|
|
137
|
+
this.pending.clear();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
request(method, params = {}) {
|
|
141
|
+
const id = this.nextId++;
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const timer = setTimeout(() => {
|
|
144
|
+
this.pending.delete(id);
|
|
145
|
+
reject(new Error(`Codex app-server timed out while calling ${method}.`));
|
|
146
|
+
}, this.timeoutMs);
|
|
147
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
148
|
+
this.child.stdin.write(`${JSON.stringify({ id, method, params })}\n`, (error) => {
|
|
149
|
+
if (!error) return;
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
this.pending.delete(id);
|
|
152
|
+
reject(error);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
notify(method, params = {}) {
|
|
158
|
+
this.child.stdin.write(`${JSON.stringify({ method, params })}\n`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async initialize() {
|
|
162
|
+
const result = await this.request("initialize", {
|
|
163
|
+
clientInfo: { name: "4yi-cli", version: "0.1.0" },
|
|
164
|
+
capabilities: { experimentalApi: true },
|
|
165
|
+
});
|
|
166
|
+
this.notify("initialized", {});
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async close() {
|
|
171
|
+
this.child.stdin.end();
|
|
172
|
+
if (this.child.exitCode !== null) return;
|
|
173
|
+
await new Promise((resolve) => {
|
|
174
|
+
const timer = setTimeout(() => {
|
|
175
|
+
this.child.kill();
|
|
176
|
+
resolve();
|
|
177
|
+
}, 1000);
|
|
178
|
+
this.child.once("exit", () => {
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
resolve();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function loadDefaultCodexModel(session) {
|
|
187
|
+
const response = await requestJson(session.baseUrl, "/api/cli/models?runtime=codex", { token: session.token });
|
|
188
|
+
const ids = (response?.models || []).map((model) => model?.id).filter(Boolean);
|
|
189
|
+
if (ids.length === 0) throw new Error("Your active Plan has no Codex-compatible Responses models.");
|
|
190
|
+
return ids.includes(response.default_model) ? response.default_model : ids[0];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function migrateCodexTask({
|
|
194
|
+
session,
|
|
195
|
+
threadId,
|
|
196
|
+
model,
|
|
197
|
+
limit = DEFAULT_LIMIT,
|
|
198
|
+
input = process.stdin,
|
|
199
|
+
output = process.stdout,
|
|
200
|
+
stdout = console.log,
|
|
201
|
+
client,
|
|
202
|
+
clientOptions = {},
|
|
203
|
+
selectThread = selectCodexThread,
|
|
204
|
+
} = {}) {
|
|
205
|
+
if (!session?.token) throw new Error("Not signed in. Run `4yi login`.");
|
|
206
|
+
const selectedModel = model || await loadDefaultCodexModel(session);
|
|
207
|
+
const appServer = client || new CodexAppServerClient(clientOptions);
|
|
208
|
+
try {
|
|
209
|
+
await appServer.initialize();
|
|
210
|
+
let selected;
|
|
211
|
+
if (threadId) {
|
|
212
|
+
selected = { id: threadId };
|
|
213
|
+
} else {
|
|
214
|
+
const response = await appServer.request("thread/list", {
|
|
215
|
+
limit: Math.max(limit * 3, 30),
|
|
216
|
+
sortKey: "updated_at",
|
|
217
|
+
sortDirection: "desc",
|
|
218
|
+
useStateDbOnly: true,
|
|
219
|
+
});
|
|
220
|
+
const candidates = (response?.data || [])
|
|
221
|
+
.filter((thread) => thread?.id && thread.modelProvider !== "4yi" && !thread.ephemeral)
|
|
222
|
+
.slice(0, limit);
|
|
223
|
+
if (candidates.length === 0) {
|
|
224
|
+
throw new Error("没有找到可迁移的旧 Codex 任务。新任务已经会默认使用 4YI。");
|
|
225
|
+
}
|
|
226
|
+
selected = await selectThread(candidates, { input, output });
|
|
227
|
+
if (!selected) {
|
|
228
|
+
stdout("已取消,没有修改任何任务。");
|
|
229
|
+
return { cancelled: true };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
stdout(`正在迁移到 ${selectedModel}…`);
|
|
234
|
+
const result = await appServer.request("thread/fork", {
|
|
235
|
+
threadId: selected.id,
|
|
236
|
+
model: selectedModel,
|
|
237
|
+
modelProvider: "4yi",
|
|
238
|
+
excludeTurns: true,
|
|
239
|
+
});
|
|
240
|
+
if (!result?.thread?.id) throw new Error("Codex did not return the migrated task id.");
|
|
241
|
+
if (result.modelProvider !== "4yi" && result.thread.modelProvider !== "4yi") {
|
|
242
|
+
throw new Error("Codex created the task but did not apply the 4YI provider.");
|
|
243
|
+
}
|
|
244
|
+
stdout("✓ 已创建新的 4YI 任务,原任务未修改。");
|
|
245
|
+
stdout(`新任务 ID: ${result.thread.id}`);
|
|
246
|
+
return { cancelled: false, sourceThreadId: selected.id, threadId: result.thread.id, model: selectedModel };
|
|
247
|
+
} finally {
|
|
248
|
+
if (!client) await appServer.close();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export const __testing = {
|
|
253
|
+
appServerLaunch,
|
|
254
|
+
loadDefaultCodexModel,
|
|
255
|
+
relativeTime,
|
|
256
|
+
};
|
package/src/connect.mjs
CHANGED
|
@@ -65,6 +65,7 @@ export function connectionPaths({
|
|
|
65
65
|
claudeProject: path.join(cwd, ".claude", "settings.local.json"),
|
|
66
66
|
codexConfig: path.join(codexDir, "config.toml"),
|
|
67
67
|
codexCatalog: path.join(codexDir, "model-catalogs", "4yi.json"),
|
|
68
|
+
codexCredentialHelper: path.join(home, ".4yi", "helpers", "codex-credential.mjs"),
|
|
68
69
|
};
|
|
69
70
|
if (platform === "darwin") {
|
|
70
71
|
const desktopDir = path.join(home, "Library", "Application Support", "Claude-3p");
|
|
@@ -154,17 +155,37 @@ async function ensureToolCli(target, {
|
|
|
154
155
|
function desktopAppCandidates(target, { home = os.homedir(), platform = process.platform, env = process.env } = {}) {
|
|
155
156
|
const appName = target === "claude" ? "Claude" : "Codex";
|
|
156
157
|
if (platform === "darwin") {
|
|
157
|
-
|
|
158
|
+
const candidates = [
|
|
158
159
|
path.join("/Applications", `${appName}.app`),
|
|
159
160
|
path.join(home, "Applications", `${appName}.app`),
|
|
160
161
|
];
|
|
162
|
+
if (target === "codex") {
|
|
163
|
+
candidates.push(
|
|
164
|
+
path.join("/Applications", "ChatGPT.app"),
|
|
165
|
+
path.join(home, "Applications", "ChatGPT.app"),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return candidates;
|
|
161
169
|
}
|
|
162
170
|
if (platform === "win32") {
|
|
163
|
-
const localAppData = env.LOCALAPPDATA || path.join(home, "AppData", "Local");
|
|
164
|
-
|
|
165
|
-
path.join(localAppData, "Programs", appName, `${appName}.exe`),
|
|
166
|
-
path.join(localAppData, appName, `${appName}.exe`),
|
|
171
|
+
const localAppData = env.LOCALAPPDATA || path.win32.join(home, "AppData", "Local");
|
|
172
|
+
const candidates = [
|
|
173
|
+
path.win32.join(localAppData, "Programs", appName, `${appName}.exe`),
|
|
174
|
+
path.win32.join(localAppData, appName, `${appName}.exe`),
|
|
167
175
|
];
|
|
176
|
+
if (target === "codex") {
|
|
177
|
+
// Microsoft Store/AppX builds keep their mutable runtime under this
|
|
178
|
+
// directory and commonly expose only an execution alias in WindowsApps.
|
|
179
|
+
// Checking both covers the Store build without traversing the protected
|
|
180
|
+
// C:\Program Files\WindowsApps package directory.
|
|
181
|
+
candidates.push(
|
|
182
|
+
path.win32.join(localAppData, "OpenAI", "Codex"),
|
|
183
|
+
path.win32.join(localAppData, "OpenAI", "ChatGPT"),
|
|
184
|
+
path.win32.join(localAppData, "Microsoft", "WindowsApps", "Codex.exe"),
|
|
185
|
+
path.win32.join(localAppData, "Microsoft", "WindowsApps", "ChatGPT.exe"),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return candidates;
|
|
168
189
|
}
|
|
169
190
|
return [];
|
|
170
191
|
}
|
|
@@ -179,6 +200,14 @@ function reportDesktopApp(target, {
|
|
|
179
200
|
const meta = TOOL_METADATA[target];
|
|
180
201
|
const candidates = desktopAppCandidates(target, { home, platform, env });
|
|
181
202
|
const detected = candidates.some((candidate) => exists(candidate));
|
|
203
|
+
if (target === "codex" && platform === "win32") {
|
|
204
|
+
if (detected) {
|
|
205
|
+
stdout("Codex App detected. 4YI updated the shared Windows Codex home. Finish active tasks, fully quit and reopen the App, then start a new chat to load the new provider.");
|
|
206
|
+
} else {
|
|
207
|
+
stdout("4YI updated the shared Windows Codex home. A Microsoft Store/AppX install may not expose a standard executable path; finish active tasks, fully quit and reopen any running Codex App, then start a new chat.");
|
|
208
|
+
}
|
|
209
|
+
return detected;
|
|
210
|
+
}
|
|
182
211
|
if (detected) {
|
|
183
212
|
stdout(`${meta.appLabel} detected. Quit and reopen it to use the new connection.`);
|
|
184
213
|
} else if (candidates.length > 0) {
|
|
@@ -268,6 +297,25 @@ printf '%s' "$token"
|
|
|
268
297
|
`;
|
|
269
298
|
}
|
|
270
299
|
|
|
300
|
+
function codexCredentialHelper() {
|
|
301
|
+
return `#!/usr/bin/env node
|
|
302
|
+
import fs from "node:fs";
|
|
303
|
+
import os from "node:os";
|
|
304
|
+
import path from "node:path";
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const configFile = path.join(os.homedir(), ".4yi", "config.json");
|
|
308
|
+
const config = JSON.parse(fs.readFileSync(configFile, "utf8"));
|
|
309
|
+
const token = typeof config.token === "string" ? config.token.trim() : "";
|
|
310
|
+
if (!token) throw new Error("4YI session is missing a token.");
|
|
311
|
+
process.stdout.write(token);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
process.stderr.write((error.message || "4YI session is invalid.") + " Run: 4yi login\\n");
|
|
314
|
+
process.exit(1);
|
|
315
|
+
}
|
|
316
|
+
`;
|
|
317
|
+
}
|
|
318
|
+
|
|
271
319
|
function configureClaudeDesktop({
|
|
272
320
|
home,
|
|
273
321
|
claudeBaseUrl,
|
|
@@ -494,7 +542,8 @@ async function checkCodex(session, codexBaseUrl, model) {
|
|
|
494
542
|
}
|
|
495
543
|
|
|
496
544
|
async function connectCodex({ session, home, codexHome, codexBaseUrl, stdout, skipCheck = false, tooling = {} }) {
|
|
497
|
-
const
|
|
545
|
+
const platform = tooling.platform || process.platform;
|
|
546
|
+
const paths = connectionPaths({ home, codexHome, platform });
|
|
498
547
|
const { models, defaultModel } = await loadCodexModels(session);
|
|
499
548
|
if (!skipCheck) await checkCodex(session, codexBaseUrl, defaultModel);
|
|
500
549
|
ensureDir(path.dirname(paths.codexConfig));
|
|
@@ -502,17 +551,26 @@ async function connectCodex({ session, home, codexHome, codexBaseUrl, stdout, sk
|
|
|
502
551
|
const catalog = buildCodexCatalog(models, template);
|
|
503
552
|
atomicWrite(paths.codexCatalog, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
504
553
|
|
|
505
|
-
const
|
|
554
|
+
const backupGroup = timestamp();
|
|
555
|
+
const backup = backupFile("codex", paths.codexConfig, home, backupGroup);
|
|
556
|
+
const helperBackup = backupFile("codex", paths.codexCredentialHelper, home, backupGroup);
|
|
506
557
|
let existing = fs.existsSync(paths.codexConfig) ? fs.readFileSync(paths.codexConfig, "utf8") : "";
|
|
507
558
|
existing = removeManagedBlock(existing, CODEX_ROOT_START, CODEX_ROOT_END);
|
|
508
559
|
existing = removeManagedBlock(existing, CODEX_PROVIDER_START, CODEX_PROVIDER_END);
|
|
509
560
|
existing = removeCodexRootAssignments(existing);
|
|
510
561
|
const root = `${CODEX_ROOT_START}\nmodel = ${JSON.stringify(defaultModel)}\nmodel_provider = "4yi"\nmodel_catalog_json = ${JSON.stringify(paths.codexCatalog)}\n${CODEX_ROOT_END}`;
|
|
511
|
-
const
|
|
562
|
+
const nodeExecutable = tooling.nodeExecutable || process.execPath;
|
|
563
|
+
const provider = `${CODEX_PROVIDER_START}\n[model_providers."4yi"]\nname = "4YI Gateway"\nbase_url = ${JSON.stringify(codexBaseUrl.replace(/\/+$/, ""))}\nwire_api = "responses"\n\n[model_providers."4yi".auth]\ncommand = ${JSON.stringify(nodeExecutable)}\nargs = [${JSON.stringify(paths.codexCredentialHelper)}]\ntimeout_ms = 5000\n${CODEX_PROVIDER_END}`;
|
|
564
|
+
// Codex loads the provider when its process starts. Make the command-backed
|
|
565
|
+
// credential durable before activating the provider in config.toml, and
|
|
566
|
+
// never copy the bearer token into Codex-owned files.
|
|
567
|
+
atomicWrite(paths.codexCredentialHelper, codexCredentialHelper(), 0o700);
|
|
512
568
|
atomicWrite(paths.codexConfig, `${root}\n\n${existing ? `${existing}\n\n` : ""}${provider}\n`);
|
|
513
569
|
stdout(`Connected Codex: ${paths.codexConfig}`);
|
|
570
|
+
stdout(`Codex credential helper: ${paths.codexCredentialHelper}`);
|
|
514
571
|
stdout(`Available Codex models: ${models.map((model) => model.id).join(", ")}`);
|
|
515
572
|
stdout(`Backup: ${backup}`);
|
|
573
|
+
stdout(`Backup: ${helperBackup}`);
|
|
516
574
|
}
|
|
517
575
|
|
|
518
576
|
function resolveUrls(session, options) {
|
|
@@ -641,6 +699,7 @@ export const __testing = {
|
|
|
641
699
|
reportDesktopApp,
|
|
642
700
|
configureClaudeDesktop,
|
|
643
701
|
claudeDesktopCredentialHelper,
|
|
702
|
+
codexCredentialHelper,
|
|
644
703
|
readClaudeDesktopStatus,
|
|
645
704
|
removeManagedBlock,
|
|
646
705
|
removeCodexRootAssignments,
|