@4yi-dev/cli 0.1.16 → 0.1.17

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 CHANGED
@@ -43,6 +43,8 @@ Codex CLI and Codex App share the same Codex home on native Windows (`%USERPROFI
43
43
 
44
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
45
 
46
+ `4yi restore codex` restores the protected OpenAI configuration, then detects tasks that still belong to the 4YI provider. In an interactive terminal, choose a recent task by number, press Enter for the newest task, enter `a` for every displayed task, or enter `s` to skip. Codex copies each selected task to a new OpenAI task without modifying the original. For scripts, use `4yi restore codex --thread <id>` or `4yi restore codex --migrate-tasks all`; optionally pass `--model <id>` to override the OpenAI default model. Fully quit and reopen Codex App after the command completes.
47
+
46
48
  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
49
 
48
50
  For local development:
package/bin/4yi.mjs CHANGED
@@ -2,7 +2,7 @@
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
+ import { migrateCodexTask, restoreCodexTasksToOpenAI } from "../src/codex-migrate.mjs";
6
6
 
7
7
  const command = process.argv[2] || "help";
8
8
 
@@ -18,6 +18,10 @@ if (command === "help" || command === "--help" || command === "-h") {
18
18
  console.log(" --model X choose a model (defaults to your Plan model)");
19
19
  console.log(" 4yi status [claude|codex|all] inspect the current connection");
20
20
  console.log(" 4yi restore <claude|codex|all> restore the latest protected config");
21
+ console.log(" Codex: interactively copy 4YI tasks to OpenAI after restoring");
22
+ console.log(" --thread ID copy one specific 4YI task to OpenAI");
23
+ console.log(" --migrate-tasks all copy every detected 4YI task to OpenAI");
24
+ console.log(" --model X use a specific OpenAI model for copied tasks");
21
25
  process.exit(0);
22
26
  }
23
27
 
@@ -66,6 +70,44 @@ function parseMigrateArgs(args) {
66
70
  return { threadId, model };
67
71
  }
68
72
 
73
+ function parseRestoreArgs(args) {
74
+ let target = "all";
75
+ let threadId;
76
+ let migrateTasks;
77
+ let model;
78
+ let targetSeen = false;
79
+ for (let i = 0; i < args.length; i += 1) {
80
+ const arg = args[i];
81
+ if (!arg.startsWith("-") && !targetSeen) {
82
+ target = arg;
83
+ targetSeen = true;
84
+ } else if (arg === "--thread") {
85
+ threadId = args[++i];
86
+ if (!threadId || threadId.startsWith("-")) throw new Error("--thread requires a task id.");
87
+ } else if (arg.startsWith("--thread=")) {
88
+ threadId = arg.slice("--thread=".length);
89
+ } else if (arg === "--migrate-tasks") {
90
+ migrateTasks = args[++i];
91
+ if (!migrateTasks || migrateTasks.startsWith("-")) throw new Error("--migrate-tasks requires `all`.");
92
+ } else if (arg.startsWith("--migrate-tasks=")) {
93
+ migrateTasks = arg.slice("--migrate-tasks=".length);
94
+ } else if (arg === "--model" || arg === "-m") {
95
+ model = args[++i];
96
+ if (!model || model.startsWith("-")) throw new Error("--model requires a model id.");
97
+ } else if (arg.startsWith("--model=")) {
98
+ model = arg.slice("--model=".length);
99
+ } else {
100
+ throw new Error(`Unknown option: ${arg}`);
101
+ }
102
+ }
103
+ if (migrateTasks && migrateTasks !== "all") throw new Error("--migrate-tasks currently supports only `all`.");
104
+ if (threadId && migrateTasks) throw new Error("Use either --thread or --migrate-tasks all, not both.");
105
+ if ((threadId || migrateTasks || model) && !new Set(["codex", "all"]).has(String(target).toLowerCase())) {
106
+ throw new Error("Codex task migration options require `4yi restore codex` or `4yi restore all`.");
107
+ }
108
+ return { target, threadId, migrateTasks, model };
109
+ }
110
+
69
111
  /** Split out `--model <id>` / `--model=<id>` / `-m <id>`; the rest pass through to OpenCode. */
70
112
  function parseCodeArgs(args) {
71
113
  let preferredModel = null;
@@ -122,8 +164,12 @@ try {
122
164
  const { target, scope } = parseConnectArgs(process.argv.slice(3));
123
165
  connectionStatus({ target, scope });
124
166
  } else if (command === "restore") {
125
- const { target } = parseConnectArgs(process.argv.slice(3));
126
- restoreConnection({ target });
167
+ const restoreArgs = parseRestoreArgs(process.argv.slice(3));
168
+ const restored = restoreConnection({ target: restoreArgs.target });
169
+ if (restored.restoredCodex) {
170
+ await restoreCodexTasksToOpenAI(restoreArgs);
171
+ console.log("Codex 已恢复为 OpenAI。请完全退出并重新打开 Codex App,然后打开复制后的任务或创建新任务。");
172
+ }
127
173
  } else {
128
174
  console.error(`Unknown command: ${command}`);
129
175
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4yi-dev/cli",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "4YI command-line launcher for OAuth login and OpenCode runtime",
5
5
  "type": "module",
6
6
  "bin": {
@@ -136,6 +136,20 @@ function mergeCandidateThreads(serverThreads, sessionThreads, limit) {
136
136
  .slice(0, limit);
137
137
  }
138
138
 
139
+ function mergeProviderThreads(serverThreads, sessionThreads, provider, limit = Number.POSITIVE_INFINITY) {
140
+ const threads = new Map();
141
+ for (const thread of sessionThreads) if (thread?.id) threads.set(thread.id, thread);
142
+ for (const thread of serverThreads) if (thread?.id) threads.set(thread.id, { ...threads.get(thread.id), ...thread });
143
+ return [...threads.values()]
144
+ .filter((thread) => thread.modelProvider === provider && !thread.ephemeral)
145
+ .sort((left, right) => {
146
+ const leftTime = left.recencyAt || left.updatedAt || left.createdAt || 0;
147
+ const rightTime = right.recencyAt || right.updatedAt || right.createdAt || 0;
148
+ return rightTime - leftTime;
149
+ })
150
+ .slice(0, limit);
151
+ }
152
+
139
153
  export function describeCodexThread(thread, { now = Date.now() } = {}) {
140
154
  const title = oneLine(thread.name || thread.preview, "未命名任务");
141
155
  const project = path.basename(String(thread.cwd || "")) || String(thread.cwd || "未知项目");
@@ -173,6 +187,35 @@ export async function selectCodexThread(threads, {
173
187
  }
174
188
  }
175
189
 
190
+ export async function selectCodexRestoreThreads(threads, {
191
+ input = process.stdin,
192
+ output = process.stdout,
193
+ now = Date.now(),
194
+ } = {}) {
195
+ if (!input?.isTTY || !output?.isTTY) return null;
196
+
197
+ output.write("\n发现仍绑定 4YI Gateway 的 Codex 任务。选择要复制到 OpenAI 的任务:\n\n");
198
+ threads.forEach((thread, index) => {
199
+ const item = describeCodexThread(thread, { now });
200
+ output.write(`${index + 1}. ${item.title}\n ${item.project} · ${item.age}\n`);
201
+ });
202
+
203
+ const readline = createInterface({ input, output });
204
+ try {
205
+ while (true) {
206
+ const answer = await readline.question("\n输入序号(默认 1,a 复制以上全部,s 跳过):");
207
+ const value = answer.trim().toLowerCase();
208
+ if (value === "s" || value === "skip" || value === "q" || value === "quit") return [];
209
+ if (value === "a" || value === "all") return threads;
210
+ const index = value === "" ? 0 : Number(value) - 1;
211
+ if (Number.isInteger(index) && index >= 0 && index < threads.length) return [threads[index]];
212
+ output.write(`请输入 1-${threads.length} 之间的序号、a 或 s。\n`);
213
+ }
214
+ } finally {
215
+ readline.close();
216
+ }
217
+ }
218
+
176
219
  export class CodexAppServerClient {
177
220
  constructor({
178
221
  command = executableName("codex"),
@@ -292,6 +335,105 @@ async function loadDefaultCodexModel(session) {
292
335
  return ids.includes(response.default_model) ? response.default_model : ids[0];
293
336
  }
294
337
 
338
+ async function loadDefaultOpenAIModel(appServer) {
339
+ const response = await appServer.request("model/list", { limit: 100, includeHidden: false });
340
+ const models = response?.data || [];
341
+ const selected = models.find((model) => model?.isDefault) || models[0];
342
+ const id = selected?.id || selected?.model;
343
+ if (!id) throw new Error("Codex did not report an available OpenAI model after restore.");
344
+ return id;
345
+ }
346
+
347
+ async function listProviderThreads(appServer, provider, limit = SESSION_SCAN_LIMIT) {
348
+ const threads = [];
349
+ let cursor;
350
+ do {
351
+ const response = await appServer.request("thread/list", {
352
+ cursor,
353
+ limit: Math.min(100, limit - threads.length),
354
+ sortKey: "recency_at",
355
+ sortDirection: "desc",
356
+ modelProviders: [provider],
357
+ useStateDbOnly: false,
358
+ });
359
+ threads.push(...(response?.data || []));
360
+ cursor = response?.nextCursor || null;
361
+ } while (cursor && threads.length < limit);
362
+ return threads;
363
+ }
364
+
365
+ export async function restoreCodexTasksToOpenAI({
366
+ threadId,
367
+ migrateTasks,
368
+ model,
369
+ limit = DEFAULT_LIMIT,
370
+ input = process.stdin,
371
+ output = process.stdout,
372
+ stdout = console.log,
373
+ client,
374
+ clientOptions = {},
375
+ selectThreads = selectCodexRestoreThreads,
376
+ scanThreads = scanCodexSessionThreads,
377
+ } = {}) {
378
+ const appServer = client || new CodexAppServerClient(clientOptions);
379
+ try {
380
+ await appServer.initialize();
381
+ let selected;
382
+ if (threadId) {
383
+ selected = [{ id: threadId }];
384
+ } else {
385
+ const serverThreads = await listProviderThreads(appServer, "4yi");
386
+ const sessionThreads = scanThreads({ codexHome: codexHomeForEnv(clientOptions.env || process.env) });
387
+ const candidates = mergeProviderThreads(serverThreads, sessionThreads, "4yi");
388
+ if (candidates.length === 0) {
389
+ stdout("未发现仍绑定 4YI Gateway 的 Codex 任务。新任务将使用 OpenAI。");
390
+ return { cancelled: false, migrated: [], failed: [] };
391
+ }
392
+ if (migrateTasks === "all") {
393
+ selected = candidates;
394
+ } else if (!input?.isTTY || !output?.isTTY) {
395
+ stdout(`发现 ${candidates.length} 个仍绑定 4YI Gateway 的任务;非交互模式未自动复制。可运行 \`4yi restore codex --migrate-tasks all\` 或使用 \`--thread <任务ID>\`。`);
396
+ return { cancelled: true, migrated: [], failed: [] };
397
+ } else {
398
+ selected = await selectThreads(candidates.slice(0, limit), { input, output });
399
+ if (!selected || selected.length === 0) {
400
+ stdout("已跳过任务复制。原 4YI 任务未修改。");
401
+ return { cancelled: true, migrated: [], failed: [] };
402
+ }
403
+ }
404
+ }
405
+
406
+ const selectedModel = model || await loadDefaultOpenAIModel(appServer);
407
+ const migrated = [];
408
+ const failed = [];
409
+ for (const thread of selected) {
410
+ const item = describeCodexThread(thread);
411
+ stdout(`正在复制到 OpenAI:${item.title}…`);
412
+ try {
413
+ const result = await appServer.request("thread/fork", {
414
+ threadId: thread.id,
415
+ model: selectedModel,
416
+ modelProvider: "openai",
417
+ excludeTurns: true,
418
+ });
419
+ if (!result?.thread?.id) throw new Error("Codex did not return the copied task id.");
420
+ if (result.modelProvider !== "openai" && result.thread.modelProvider !== "openai") {
421
+ throw new Error("Codex created the task but did not apply the OpenAI provider.");
422
+ }
423
+ migrated.push({ sourceThreadId: thread.id, threadId: result.thread.id });
424
+ stdout(`✓ OpenAI 任务 ID: ${result.thread.id}`);
425
+ } catch (error) {
426
+ failed.push({ sourceThreadId: thread.id, error: error.message });
427
+ stdout(`✗ 复制失败(${thread.id}):${error.message}`);
428
+ }
429
+ }
430
+ stdout(`任务复制完成:成功 ${migrated.length},失败 ${failed.length}。原 4YI 任务均未修改。`);
431
+ return { cancelled: false, migrated, failed, model: selectedModel };
432
+ } finally {
433
+ if (!client) await appServer.close();
434
+ }
435
+ }
436
+
295
437
  export async function migrateCodexTask({
296
438
  session,
297
439
  threadId,
@@ -355,6 +497,9 @@ export const __testing = {
355
497
  codexHomeForEnv,
356
498
  loadDefaultCodexModel,
357
499
  mergeCandidateThreads,
500
+ mergeProviderThreads,
501
+ listProviderThreads,
502
+ loadDefaultOpenAIModel,
358
503
  relativeTime,
359
504
  scanCodexSessionThreads,
360
505
  sessionThreadFromFile,
package/src/connect.mjs CHANGED
@@ -720,12 +720,18 @@ function restoreOne(target, home, stdout, { required = true } = {}) {
720
720
 
721
721
  export function restoreConnection({ target = "all", home = os.homedir(), stdout = console.log } = {}) {
722
722
  const normalized = normalizeTarget(target);
723
- if (normalized === "claude") return restoreOne("claude", home, stdout);
724
- if (normalized === "codex") return restoreOne("codex", home, stdout);
723
+ if (normalized === "claude") {
724
+ restoreOne("claude", home, stdout);
725
+ return { restoredClaude: true, restoredCodex: false };
726
+ }
727
+ if (normalized === "codex") {
728
+ restoreOne("codex", home, stdout);
729
+ return { restoredClaude: false, restoredCodex: true };
730
+ }
725
731
  const restoredClaude = restoreOne("claude", home, stdout, { required: false });
726
732
  const restoredCodex = restoreOne("codex", home, stdout, { required: false });
727
733
  if (!restoredClaude && !restoredCodex) throw new Error("No Claude or Codex backup found.");
728
- return true;
734
+ return { restoredClaude, restoredCodex };
729
735
  }
730
736
 
731
737
  export const __testing = {