base_parts_ai 1.0.58 → 1.0.59

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
@@ -38,7 +38,7 @@ ucx
38
38
  jgrok
39
39
  ```
40
40
 
41
- `ucx` 会扫描用户主目录下一层的 `~/.codex` 与 `~/.codex_*`。账号名由 `config.toml` 顶层 `model_provider`、`auth.json` 中非空 `OPENAI_API_KEY` 的脱敏短名、JWT email 组合成 `配置名_Key_Email`,缺段去掉;不会自动创建空的 `~/.codex`。选中当前 `~/.codex` 会进入二级菜单,只在该目录内用 `juser_*` 切换 `config.toml`、`auth.json`、`.env`、`models.json`,不改其它 `~/.codex_*` 账号目录。
41
+ `ucx` 会扫描用户主目录下一层的 `~/.codex` 与 `~/.codex_*`。账号名由 `config.toml` 顶层 `model_provider`、`auth.json` 中非空 `OPENAI_API_KEY` 的脱敏短名、JWT email 组合成 `配置名_Key_Email`,缺段去掉;不会自动创建空的 `~/.codex`。选中当前 `~/.codex` 会进入二级菜单,只在该目录内用 `juser_*` 切换 `config.toml`、`auth.json`、`.env`、`models.json`,不改其它 `~/.codex_*` 账号目录。二级选中子账户后(含选中当前无需切换)会进入下一级选择:还原最后一次会话并进入 codex(读 `~/.codex/session_index.jsonl` 取最后一次会话 id 后执行 `codex fork <会话id> --yolo`)、直接进入 codex(`codex --yolo`)、退出 ucx;启动 codex 时会等待其执行完成后一起退出并透传其退出码,`~/.codex/.env` 存在时会注入子进程环境。
42
42
 
43
43
  `jgrok` 若 `~/.grok/.env` 不存在则写入代理示例并打印绝对路径,请编辑后再运行。已存在则把该文件注入环境变量:本机没有 `grok` 时通过腾讯云 npm 源安装 `@xai-official/grok`(安装不注入环境变量),然后启动 `grok`,用户参数原样透传。
44
44
 
package/bin/ucx.js CHANGED
@@ -29,45 +29,51 @@ function loadLib(libName) {
29
29
 
30
30
  /**
31
31
  * 拉取远程配置(含自动升级)后执行账号切换
32
- * @returns {Promise<void>}
32
+ * @returns {Promise<number|undefined>} 子账户三级菜单启动过 codex 时返回其退出码
33
33
  */
34
34
  async function runSwitch() {
35
35
  var buildCfg = getBuildCfg();
36
36
  var utils = loadLib('ai_utils');
37
37
  await utils.fetchConfig(buildCfg);
38
38
  await utils.showNotice();
39
- await loadLib('ucx')({}, buildCfg);
39
+ return loadLib('ucx')({}, buildCfg);
40
40
  }
41
41
 
42
42
  /**
43
43
  * 运行 ucx CLI,可供命令行直接执行或被 base_parts_ai require 调用
44
44
  * @param {Array<string>} args 用户参数,不包含 node 和脚本路径
45
- * @returns {Promise<void>}
45
+ * @returns {Promise<number|undefined>} 透传 codex 退出码
46
46
  */
47
47
  async function runCli(args) {
48
48
  var cliArgs = (args || []).slice();
49
49
  var program = new Command();
50
+ var exitCode;
50
51
 
51
52
  program
52
53
  .name('ucx')
53
54
  .description('通过目录改名切换 Codex 账号,选中当前目录可切换其子账户')
54
55
  .version(packageJson.version)
55
56
  .action(async function () {
56
- await runSwitch();
57
+ exitCode = await runSwitch();
57
58
  });
58
59
 
59
60
  // 无参数时直接进入切换,避免 commander 空参数不触发根 action
60
61
  if (cliArgs.length <= 0) {
61
- await runSwitch();
62
- return;
62
+ return runSwitch();
63
63
  }
64
64
 
65
65
  // 使用 from:user 避免读取或修改全局 process.argv,便于被主入口 require 调用
66
66
  await program.parseAsync(cliArgs, { from: 'user' });
67
+ return exitCode;
67
68
  }
68
69
 
69
70
  if (require.main === module) {
70
- runCli(process.argv.slice(2)).catch(function (err) {
71
+ runCli(process.argv.slice(2)).then(function (code) {
72
+ // 启动过 codex 时与其共用退出码,便于脚本感知 codex 结果
73
+ if (typeof code === 'number') {
74
+ process.exit(code);
75
+ }
76
+ }).catch(function (err) {
71
77
  console.error('ucx 执行失败:', err.message);
72
78
  process.exit(1);
73
79
  });
package/lib/ucx.js CHANGED
@@ -27,6 +27,15 @@ var REMOVE_CURRENT_SUB = '__remove_current_sub__';
27
27
  /** 二级菜单:返回一级账号列表 */
28
28
  var BACK_TO_PARENT = '__back_to_parent__';
29
29
 
30
+ /** 三级菜单:还原最后一次会话并进入 codex */
31
+ var POST_SWITCH_RESUME = '__post_switch_resume__';
32
+
33
+ /** 三级菜单:直接进入 codex */
34
+ var POST_SWITCH_DIRECT = '__post_switch_direct__';
35
+
36
+ /** 三级菜单:退出 ucx,不执行任何命令 */
37
+ var POST_SWITCH_EXIT = '__post_switch_exit__';
38
+
30
39
  /** 子账户目录前缀,只出现在当前 ~/.codex 内 */
31
40
  var SUB_DIR_PREFIX = 'juser_';
32
41
 
@@ -407,6 +416,102 @@ function killCodexProcesses(execImpl) {
407
416
  });
408
417
  }
409
418
 
419
+ /**
420
+ * 解析 .env 文本为键值对象:跳过注释与空行,去掉成对引号
421
+ * @param {string} content .env 文件内容
422
+ * @returns {object}
423
+ */
424
+ function parseCodexEnvContent(content) {
425
+ var result = {};
426
+ String(content || '').split(/\r?\n/).forEach(function (line) {
427
+ var trimmed = line.trim();
428
+ if (!trimmed || trimmed.indexOf('#') === 0) {
429
+ return;
430
+ }
431
+ var eqIndex = trimmed.indexOf('=');
432
+ if (eqIndex <= 0) {
433
+ return;
434
+ }
435
+ var key = trimmed.slice(0, eqIndex).trim();
436
+ var value = trimmed.slice(eqIndex + 1).trim();
437
+ if (value.length >= 2 && ((value[0] === '"' && value[value.length - 1] === '"') || (value[0] === '\'' && value[value.length - 1] === '\''))) {
438
+ value = value.slice(1, -1);
439
+ }
440
+ result[key] = value;
441
+ });
442
+ return result;
443
+ }
444
+
445
+ /**
446
+ * 构建 codex 子进程环境:在当前进程环境之上合并 ~/.codex/.env,确保子账户切换后代理与 Key 立即生效
447
+ * @param {string} homeDir 用户主目录
448
+ * @returns {object}
449
+ */
450
+ function buildCodexChildEnv(homeDir) {
451
+ var envPath = path.join(homeDir, '.codex', '.env');
452
+ var childEnv = Object.assign({}, process.env);
453
+ if (!fs.existsSync(envPath)) {
454
+ return childEnv;
455
+ }
456
+ try {
457
+ var parsed = parseCodexEnvContent(fs.readFileSync(envPath, 'utf8'));
458
+ return Object.assign(childEnv, parsed);
459
+ } catch (e) {
460
+ // .env 读取失败时退回当前进程环境,不阻断启动
461
+ return childEnv;
462
+ }
463
+ }
464
+
465
+ /**
466
+ * 检测本机是否存在 codex 命令;测试环境或注入 exec 时避免触碰宿主机
467
+ * @param {Function} [execImpl] 可注入的 execSync,便于单测
468
+ * @returns {boolean}
469
+ */
470
+ function codexCommandExists(execImpl) {
471
+ var exec = execImpl || childProcess.execSync;
472
+ var cmd = process.platform === 'win32' ? 'where codex' : 'which codex';
473
+ try {
474
+ exec(cmd, { stdio: 'ignore' });
475
+ return true;
476
+ } catch (e) {
477
+ return false;
478
+ }
479
+ }
480
+
481
+ /**
482
+ * 读取 ~/.codex/session_index.jsonl 的最后一次会话记录:倒序跳过坏行,取第一条含非空 id 的记录
483
+ * @param {string} homeDir 用户主目录
484
+ * @returns {{ id: string, threadName: string }|null} 无有效记录时返回 null
485
+ */
486
+ function readLastSessionRecord(homeDir) {
487
+ var indexPath = path.join(homeDir, '.codex', 'session_index.jsonl');
488
+ var content = '';
489
+ try {
490
+ content = fs.readFileSync(indexPath, 'utf8');
491
+ } catch (e) {
492
+ return null;
493
+ }
494
+ var lines = content.split(/\r?\n/);
495
+ for (var i = lines.length - 1; i >= 0; i -= 1) {
496
+ var line = lines[i].trim();
497
+ if (!line) {
498
+ continue;
499
+ }
500
+ try {
501
+ var record = JSON.parse(line);
502
+ if (record && typeof record.id === 'string' && record.id.trim()) {
503
+ return {
504
+ id: record.id.trim(),
505
+ threadName: typeof record.thread_name === 'string' ? record.thread_name : '',
506
+ };
507
+ }
508
+ } catch (e) {
509
+ // 半行写入等坏行跳过,继续向前找
510
+ }
511
+ }
512
+ return null;
513
+ }
514
+
410
515
  /**
411
516
  * 为当前账号分配不冲突的停放目录名;同名已存在时追加 _2、_3,不覆盖
412
517
  * @param {string} homeDir 用户主目录
@@ -919,11 +1024,92 @@ async function removeCurrentSubAccount(codexDir, currentRecord, options) {
919
1024
  return { cancelled: false };
920
1025
  }
921
1026
 
1027
+ /**
1028
+ * 三级菜单选项:还原最后一次会话 / 直接进入 / 退出,固定顺序
1029
+ * @returns {Array<object>}
1030
+ */
1031
+ function buildPostSwitchChoices() {
1032
+ return [
1033
+ { name: '还原最后一次会话并进入 codex', value: POST_SWITCH_RESUME },
1034
+ { name: '直接进入 codex', value: POST_SWITCH_DIRECT },
1035
+ { name: '退出 ucx', value: POST_SWITCH_EXIT },
1036
+ ];
1037
+ }
1038
+
1039
+ /**
1040
+ * 启动 codex 并等待其执行完成,返回子进程退出码;codex 缺失时提示并返回 null
1041
+ * @param {Array<string>} args 传给 codex 的参数
1042
+ * @param {string} homeDir 用户主目录
1043
+ * @param {object} [options] 可选配置,spawnImpl/execImpl 用于单测注入
1044
+ * @returns {number|null}
1045
+ */
1046
+ function launchCodex(args, homeDir, options) {
1047
+ options = options || {};
1048
+ // 测试环境且未注入实现时跳过,避免真实启动 codex
1049
+ if (process.env.JCC_TEST_HOME && !options.spawnImpl) {
1050
+ console.log('测试环境跳过启动 codex。');
1051
+ return null;
1052
+ }
1053
+ if (!codexCommandExists(options.execImpl)) {
1054
+ console.error('未检测到 codex 命令,请先安装 Codex CLI 后重试。');
1055
+ return null;
1056
+ }
1057
+ var spawn = options.spawnImpl || childProcess.spawnSync;
1058
+ var result = spawn('codex', args || [], {
1059
+ stdio: 'inherit',
1060
+ shell: process.platform === 'win32',
1061
+ env: buildCodexChildEnv(homeDir),
1062
+ }) || {};
1063
+ if (result.error) {
1064
+ throw result.error;
1065
+ }
1066
+ if (result.signal) {
1067
+ return 1;
1068
+ }
1069
+ return typeof result.status === 'number' ? result.status : 0;
1070
+ }
1071
+
1072
+ /**
1073
+ * 子账号切换后的三级菜单:还原最后一次会话 / 直接进入 codex / 退出 ucx
1074
+ * 还原时读 ~/.codex/session_index.jsonl 取最后一次会话 id 后执行 codex fork <id> --yolo
1075
+ * @param {string} homeDir 用户主目录
1076
+ * @param {object} [options] 可选配置,spawnImpl/execImpl 用于单测注入
1077
+ * @returns {Promise<number|undefined>} codex 退出码;未启动 codex 时为 undefined
1078
+ */
1079
+ async function runPostSwitchMenu(homeDir, options) {
1080
+ options = options || {};
1081
+ for (;;) {
1082
+ var answer = await inquirer.prompt([{
1083
+ type: 'list',
1084
+ name: 'action',
1085
+ message: '请选择接下来的操作',
1086
+ choices: buildPostSwitchChoices(),
1087
+ }]);
1088
+
1089
+ if (answer.action === POST_SWITCH_EXIT) {
1090
+ return undefined;
1091
+ }
1092
+ if (answer.action === POST_SWITCH_DIRECT) {
1093
+ return launchCodex(['--yolo'], homeDir, options);
1094
+ }
1095
+
1096
+ // 还原最后一次会话:无会话记录时提示并重新选择,用户可改选直接进入
1097
+ var session = readLastSessionRecord(homeDir);
1098
+ if (!session) {
1099
+ console.log('未在 ~/.codex/session_index.jsonl 中找到会话记录,无法还原。');
1100
+ continue;
1101
+ }
1102
+ var display = session.threadName || session.id;
1103
+ console.log('正在还原最后一次会话:' + display);
1104
+ return launchCodex(['fork', session.id, '--yolo'], homeDir, options);
1105
+ }
1106
+ }
1107
+
922
1108
  /**
923
1109
  * 当前空间的子账户二级菜单;所有改动限制在 codexDir 内
924
1110
  * @param {string} codexDir 当前 ~/.codex
925
1111
  * @param {object} [options] 可选配置
926
- * @returns {Promise<{ back: boolean }>}
1112
+ * @returns {Promise<{ back: boolean, exitCode?: number }>}
927
1113
  */
928
1114
  async function runSubAccountMenu(codexDir, options) {
929
1115
  options = options || {};
@@ -952,7 +1138,9 @@ async function runSubAccountMenu(codexDir, options) {
952
1138
 
953
1139
  var selected = findRecordByDirPath(records, answer.dirPath);
954
1140
  await switchSubAccount(codexDir, selected, records, options);
955
- return { back: false };
1141
+ // 切换完成(含“已是当前子账户”的跳过场景)后进入三级菜单,可选择还原会话/直接进入 codex/退出
1142
+ var exitCode = await runPostSwitchMenu(path.dirname(codexDir), options);
1143
+ return { back: false, exitCode: exitCode };
956
1144
  }
957
1145
 
958
1146
  /**
@@ -976,11 +1164,13 @@ function findRecordByDirPath(records, dirPath) {
976
1164
  * 选中当前 ~/.codex 进入子账户二级菜单;返回上级时重新弹出一级列表
977
1165
  * @param {object} cmd commander 选项(当前无业务字段)
978
1166
  * @param {object} buildCfg 含 homeDir 的配置
979
- * @returns {Promise<void>}
1167
+ * @param {object} [options] 可选配置,spawnImpl/execImpl 用于单测注入
1168
+ * @returns {Promise<number|undefined>} 启动过 codex 时返回其退出码
980
1169
  */
981
- async function runUcx(cmd, buildCfg) {
1170
+ async function runUcx(cmd, buildCfg, options) {
1171
+ options = options || {};
982
1172
  var homeDir = getHomeDir(buildCfg);
983
- while (true) {
1173
+ for (;;) {
984
1174
  var records = buildAccountRecords(homeDir);
985
1175
  var current = findRecordByDirPath(records, path.join(homeDir, '.codex'));
986
1176
  var answer = await inquirer.prompt([{
@@ -992,19 +1182,19 @@ async function runUcx(cmd, buildCfg) {
992
1182
  }]);
993
1183
 
994
1184
  if (answer.dirPath === REMOVE_CURRENT_ACCOUNT) {
995
- await removeCurrentAccount(homeDir, current || null, {});
1185
+ await removeCurrentAccount(homeDir, current || null, options);
996
1186
  return;
997
1187
  }
998
1188
 
999
1189
  var selected = findRecordByDirPath(records, answer.dirPath);
1000
1190
  if (selected && selected.isCurrent) {
1001
- var subResult = await runSubAccountMenu(selected.dirPath, {});
1191
+ var subResult = await runSubAccountMenu(selected.dirPath, options);
1002
1192
  if (subResult && subResult.back) {
1003
1193
  continue;
1004
1194
  }
1005
- return;
1195
+ return subResult ? subResult.exitCode : undefined;
1006
1196
  }
1007
- await switchCodexAccount(homeDir, selected, records, {});
1197
+ await switchCodexAccount(homeDir, selected, records, options);
1008
1198
  return;
1009
1199
  }
1010
1200
  }
@@ -1014,6 +1204,9 @@ runUcx._private = {
1014
1204
  REMOVE_CURRENT_ACCOUNT: REMOVE_CURRENT_ACCOUNT,
1015
1205
  REMOVE_CURRENT_SUB: REMOVE_CURRENT_SUB,
1016
1206
  BACK_TO_PARENT: BACK_TO_PARENT,
1207
+ POST_SWITCH_RESUME: POST_SWITCH_RESUME,
1208
+ POST_SWITCH_DIRECT: POST_SWITCH_DIRECT,
1209
+ POST_SWITCH_EXIT: POST_SWITCH_EXIT,
1017
1210
  SUB_DIR_PREFIX: SUB_DIR_PREFIX,
1018
1211
  SUB_FILE_NAMES: SUB_FILE_NAMES,
1019
1212
  CODEX_ENV_DEMO: CODEX_ENV_DEMO,
@@ -1050,6 +1243,13 @@ runUcx._private = {
1050
1243
  switchSubAccount: switchSubAccount,
1051
1244
  removeCurrentSubAccount: removeCurrentSubAccount,
1052
1245
  runSubAccountMenu: runSubAccountMenu,
1246
+ parseCodexEnvContent: parseCodexEnvContent,
1247
+ buildCodexChildEnv: buildCodexChildEnv,
1248
+ codexCommandExists: codexCommandExists,
1249
+ readLastSessionRecord: readLastSessionRecord,
1250
+ buildPostSwitchChoices: buildPostSwitchChoices,
1251
+ launchCodex: launchCodex,
1252
+ runPostSwitchMenu: runPostSwitchMenu,
1053
1253
  };
1054
1254
 
1055
1255
  module.exports = runUcx;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "base_parts_ai",
3
- "version": "1.0.58",
3
+ "version": "1.0.59",
4
4
  "description": "jaskle base_parts_ai",
5
5
  "main": "./main.js",
6
6
  "registry": true,