base_parts_ai 1.0.57 → 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_*`。账号名优先用 `auth.json` 中非空 `OPENAI_API_KEY` 的脱敏短名,否则用 JWT email;不会自动创建空的 `~/.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
- .description('通过目录改名切换 Codex 账号')
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
@@ -2,8 +2,9 @@
2
2
 
3
3
  /**
4
4
  * ucx 命令:通过重命名 ~/.codex 与 ~/.codex_<账号名> 切换 Codex 账号
5
- * 账号名优先用非空 OPENAI_API_KEY 的脱敏短名,否则用 JWT email
5
+ * 账号名由配置名、脱敏 Key、JWT email 组合,缺段去掉;配置名来自 config.toml 的 model_provider
6
6
  * 不创建空的 ~/.codex,避免把空目录当成当前账号
7
+ * 选中当前 ~/.codex 进入二级菜单,只在该目录内用 juser_* 切换子账户,不碰 ~/.codex_*
7
8
  */
8
9
 
9
10
  var fs = require('fs');
@@ -11,6 +12,7 @@ var os = require('os');
11
12
  var path = require('path');
12
13
  var childProcess = require('child_process');
13
14
  var inquirer = require('inquirer');
15
+ var TOML = require('@ltd/j-toml');
14
16
  var utils = require('./ai_utils');
15
17
 
16
18
  /** 无法解析账号名时的展示名 */
@@ -19,6 +21,27 @@ var UNKNOWN_EMAIL_LABEL = '未知邮箱';
19
21
  /** 列表末尾固定项:移除当前账号,以便登录下一个账号 */
20
22
  var REMOVE_CURRENT_ACCOUNT = '__remove_current_codex__';
21
23
 
24
+ /** 二级菜单:移除当前子账户(只停放 4 个配置文件) */
25
+ var REMOVE_CURRENT_SUB = '__remove_current_sub__';
26
+
27
+ /** 二级菜单:返回一级账号列表 */
28
+ var BACK_TO_PARENT = '__back_to_parent__';
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
+
39
+ /** 子账户目录前缀,只出现在当前 ~/.codex 内 */
40
+ var SUB_DIR_PREFIX = 'juser_';
41
+
42
+ /** 子账户只交换这 4 个文件,其它文件留在 ~/.codex */
43
+ var SUB_FILE_NAMES = ['config.toml', 'auth.json', '.env', 'models.json'];
44
+
22
45
  /** ~/.codex/.env 自定义代理示例内容 */
23
46
  var CODEX_ENV_DEMO = [
24
47
  'HTTP_PROXY=http://127.0.0.1:11811',
@@ -51,7 +74,7 @@ function isCodexAccountDirName(name) {
51
74
 
52
75
  /**
53
76
  * 将账号名中 Windows 非法文件名字符替换为下划线,保留 @.+-
54
- * @param {string} accountName 邮箱或脱敏 Key 短名
77
+ * @param {string} accountName 组合展示名(配置名_Key_Email)
55
78
  * @returns {string}
56
79
  */
57
80
  function sanitizeEmailForDir(accountName) {
@@ -123,20 +146,69 @@ function getOpenAiApiKey(auth) {
123
146
  }
124
147
 
125
148
  /**
126
- * 读取账号目录的展示/停放名:非空 OPENAI_API_KEY 优先用脱敏短名,否则用 JWT email
127
- * @param {string} authJsonPath auth.json 路径
149
+ * 读取账号目录 config.toml 顶层 model_provider 作为配置名
150
+ * 文件缺失、解析失败、字段为空或非字符串时返回空串,避免阻断账号列表
151
+ * @param {string} configTomlPath config.toml 路径
128
152
  * @returns {string}
129
153
  */
130
- function readAccountName(authJsonPath) {
131
- var auth = utils.readJsonFile(authJsonPath, null);
132
- if (!auth) {
154
+ function readModelProvider(configTomlPath) {
155
+ var text;
156
+ try {
157
+ text = fs.readFileSync(configTomlPath, 'utf8');
158
+ } catch (e) {
133
159
  return '';
134
160
  }
135
- var apiKey = getOpenAiApiKey(auth);
136
- if (apiKey) {
137
- return utils.maskKey(apiKey);
161
+ if (!text || !String(text).trim()) {
162
+ return '';
163
+ }
164
+ var config;
165
+ try {
166
+ config = TOML.parse(text);
167
+ } catch (e) {
168
+ return '';
138
169
  }
139
- return extractEmailFromTokens(auth.tokens);
170
+ if (!config || typeof config.model_provider !== 'string') {
171
+ return '';
172
+ }
173
+ return config.model_provider.trim();
174
+ }
175
+
176
+ /**
177
+ * 按 配置名_Key_Email 顺序拼接展示名,空段去掉
178
+ * @param {string} configName config.toml 的 model_provider
179
+ * @param {string} maskedKey 脱敏后的 OPENAI_API_KEY
180
+ * @param {string} email JWT 中的邮箱
181
+ * @returns {string}
182
+ */
183
+ function composeAccountName(configName, maskedKey, email) {
184
+ var parts = [];
185
+ [configName, maskedKey, email].forEach(function (part) {
186
+ if (part) {
187
+ parts.push(String(part));
188
+ }
189
+ });
190
+ return parts.join('_');
191
+ }
192
+
193
+ /**
194
+ * 读取账号目录的展示/停放名:配置名、脱敏 Key、JWT email 全部读出后组合
195
+ * @param {string} authJsonPath auth.json 路径,同级目录读取 config.toml
196
+ * @returns {string}
197
+ */
198
+ function readAccountName(authJsonPath) {
199
+ var dirPath = path.dirname(authJsonPath);
200
+ var configName = readModelProvider(path.join(dirPath, 'config.toml'));
201
+ var auth = utils.readJsonFile(authJsonPath, null);
202
+ var maskedKey = '';
203
+ var email = '';
204
+ if (auth) {
205
+ var apiKey = getOpenAiApiKey(auth);
206
+ if (apiKey) {
207
+ maskedKey = utils.maskKey(apiKey);
208
+ }
209
+ email = extractEmailFromTokens(auth.tokens);
210
+ }
211
+ return composeAccountName(configName, maskedKey, email);
140
212
  }
141
213
 
142
214
  /**
@@ -225,7 +297,7 @@ function buildAccountChoices(records) {
225
297
  var suffix = ' (~/' + record.dirName + ')';
226
298
  var name = record.displayEmail + suffix;
227
299
  if (record.isCurrent) {
228
- name += ' 当前';
300
+ name += ' 当前(点击配置子账号)';
229
301
  }
230
302
  return { name: name, value: record.dirPath };
231
303
  });
@@ -281,7 +353,7 @@ async function maybeCreateCodexEnv(homeDir) {
281
353
  }
282
354
 
283
355
  /**
284
- * 移除当前账号:有登录信息则停放改名;三无目录与切换时相同,询问删除或手工命名
356
+ * 移除当前账号:有登录信息则停放改名(不删除);三无目录与切换时相同,询问删除或手工命名
285
357
  * @param {string} homeDir 用户主目录
286
358
  * @param {object|null} currentRecord 当前 ~/.codex 记录
287
359
  * @param {object} [options] 可选配置,execImpl 用于注入杀进程实现
@@ -292,7 +364,7 @@ async function removeCurrentAccount(homeDir, currentRecord, options) {
292
364
  var confirmAnswer = await inquirer.prompt([{
293
365
  type: 'confirm',
294
366
  name: 'confirmed',
295
- message: '确认移除当前账号,以便登录新账号吗?',
367
+ message: '确认把当前账号从 ~/.codex 停放出来,以便登录新账号吗?(不会删除,会改名为 ~/.codex_xxx)',
296
368
  default: false,
297
369
  }]);
298
370
  if (!confirmAnswer.confirmed) {
@@ -344,10 +416,106 @@ function killCodexProcesses(execImpl) {
344
416
  });
345
417
  }
346
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
+
347
515
  /**
348
516
  * 为当前账号分配不冲突的停放目录名;同名已存在时追加 _2、_3,不覆盖
349
517
  * @param {string} homeDir 用户主目录
350
- * @param {string} accountName 邮箱或脱敏 Key 短名
518
+ * @param {string} accountName 组合展示名(配置名_Key_Email)
351
519
  * @returns {string} 可用的目录名,例如 .codex_a 或 .codex_a_2
352
520
  */
353
521
  function allocateParkedDirName(homeDir, accountName) {
@@ -436,7 +604,7 @@ function assertCanParkCurrent(homeDir, currentRecord) {
436
604
 
437
605
  /**
438
606
  * 执行账号目录切换:停放当前 .codex,再把选中目录改名为 .codex
439
- * 当前为三无目录时先询问删除或手工命名,名称不写回 auth.json
607
+ * 当前为三无目录(无配置名、无 Key、无登录信息)时先询问删除或手工命名,名称不写回 auth.json
440
608
  * @param {string} homeDir 用户主目录
441
609
  * @param {object} selected 选中的账号记录
442
610
  * @param {Array<object>} records 全部账号记录
@@ -492,6 +660,489 @@ async function switchCodexAccount(homeDir, selected, records, options) {
492
660
  return { skipped: false, email: selected.displayEmail };
493
661
  }
494
662
 
663
+ /**
664
+ * 判断目录名是否为当前空间内的子账户目录(仅 juser_*)
665
+ * @param {string} name 目录名
666
+ * @returns {boolean}
667
+ */
668
+ function isSubAccountDirName(name) {
669
+ return String(name || '').indexOf(SUB_DIR_PREFIX) === 0;
670
+ }
671
+
672
+ /**
673
+ * 解析后的路径必须落在当前 ~/.codex 内,防止子账户操作逃到一级账号目录
674
+ * @param {string} codexDir 当前 ~/.codex
675
+ * @param {string} targetPath 待检查路径
676
+ * @returns {string} 解析后的绝对路径
677
+ */
678
+ function resolvePathInsideCodexDir(codexDir, targetPath) {
679
+ var root = path.resolve(codexDir);
680
+ var resolved = path.resolve(targetPath);
681
+ var prefix = root.endsWith(path.sep) ? root : root + path.sep;
682
+ if (resolved !== root && resolved.indexOf(prefix) !== 0) {
683
+ throw new Error('子账户路径必须位于当前 ~/.codex 内');
684
+ }
685
+ return resolved;
686
+ }
687
+
688
+ /**
689
+ * 扫描当前 ~/.codex 下一层 juser_* 目录,不递归、不看 ~/.codex_*
690
+ * @param {string} codexDir 当前 ~/.codex
691
+ * @returns {Array<string>} 目录名列表
692
+ */
693
+ function listSubAccountDirs(codexDir) {
694
+ var entries;
695
+ try {
696
+ entries = fs.readdirSync(codexDir, { withFileTypes: true });
697
+ } catch (e) {
698
+ return [];
699
+ }
700
+ var names = [];
701
+ entries.forEach(function (ent) {
702
+ if (!ent.isDirectory()) {
703
+ return;
704
+ }
705
+ if (isSubAccountDirName(ent.name)) {
706
+ names.push(ent.name);
707
+ }
708
+ });
709
+ return names;
710
+ }
711
+
712
+ /**
713
+ * 列出目录中实际存在的子账户配置文件
714
+ * @param {string} dirPath 目录路径
715
+ * @returns {Array<string>}
716
+ */
717
+ function listPresentSubFiles(dirPath) {
718
+ var present = [];
719
+ SUB_FILE_NAMES.forEach(function (fileName) {
720
+ var filePath = path.join(dirPath, fileName);
721
+ try {
722
+ if (fs.statSync(filePath).isFile()) {
723
+ present.push(fileName);
724
+ }
725
+ } catch (e) {
726
+ // 文件不存在则跳过,保证缺文件时不会把旧文件留下来
727
+ }
728
+ });
729
+ return present;
730
+ }
731
+
732
+ /**
733
+ * 把源目录中存在的 4 个配置文件移动到目标目录;目标已有同名文件则先删
734
+ * 源和目标都必须在同一个 ~/.codex 内
735
+ * @param {string} codexDir 当前 ~/.codex
736
+ * @param {string} srcDir 源目录
737
+ * @param {string} destDir 目标目录
738
+ * @returns {void}
739
+ */
740
+ function moveSubFiles(codexDir, srcDir, destDir) {
741
+ srcDir = resolvePathInsideCodexDir(codexDir, srcDir);
742
+ destDir = resolvePathInsideCodexDir(codexDir, destDir);
743
+ if (!fs.existsSync(destDir)) {
744
+ fs.mkdirSync(destDir, { recursive: true });
745
+ }
746
+ listPresentSubFiles(srcDir).forEach(function (fileName) {
747
+ var destPath = path.join(destDir, fileName);
748
+ resolvePathInsideCodexDir(codexDir, destPath);
749
+ if (fs.existsSync(destPath)) {
750
+ fs.rmSync(destPath, { force: true });
751
+ }
752
+ fs.renameSync(path.join(srcDir, fileName), destPath);
753
+ });
754
+ }
755
+
756
+ /**
757
+ * 删除目录中存在的 4 个配置文件,不删目录本身
758
+ * @param {string} dirPath 目录路径
759
+ * @returns {void}
760
+ */
761
+ function removePresentSubFiles(dirPath) {
762
+ listPresentSubFiles(dirPath).forEach(function (fileName) {
763
+ fs.rmSync(path.join(dirPath, fileName), { force: true });
764
+ });
765
+ }
766
+
767
+ /**
768
+ * 子账户目录搬空后删除;还有其它文件则保留,避免把无关内容拷到 ~/.codex 根
769
+ * @param {string} dirPath 目录路径
770
+ * @returns {void}
771
+ */
772
+ function removeDirIfEmpty(dirPath) {
773
+ var entries;
774
+ try {
775
+ entries = fs.readdirSync(dirPath);
776
+ } catch (e) {
777
+ return;
778
+ }
779
+ if (entries.length === 0) {
780
+ fs.rmdirSync(dirPath);
781
+ }
782
+ }
783
+
784
+ /**
785
+ * 为当前子账户分配不冲突的 juser_* 目录名,只在 ~/.codex 内查找
786
+ * @param {string} codexDir 当前 ~/.codex
787
+ * @param {string} accountName 组合展示名
788
+ * @returns {string} 目录名,例如 juser_a 或 juser_a_2
789
+ */
790
+ function allocateParkedSubDirName(codexDir, accountName) {
791
+ var base = SUB_DIR_PREFIX + sanitizeEmailForDir(accountName);
792
+ var name = base;
793
+ var index = 2;
794
+ while (fs.existsSync(path.join(codexDir, name))) {
795
+ name = base + '_' + index;
796
+ index += 1;
797
+ }
798
+ return name;
799
+ }
800
+
801
+ /**
802
+ * 把当前 ~/.codex 或某个 juser_* 转成子账户记录
803
+ * @param {string} codexDir 当前 ~/.codex
804
+ * @param {string} dirName 当前为 .codex,停放为 juser_*
805
+ * @param {boolean} isCurrent 是否为根上正在生效的 4 个文件
806
+ * @returns {object}
807
+ */
808
+ function buildSubAccountRecord(codexDir, dirName, isCurrent) {
809
+ var dirPath = isCurrent ? codexDir : path.join(codexDir, dirName);
810
+ var accountName = readAccountName(path.join(dirPath, 'auth.json'));
811
+ var parkedName = accountName ? (SUB_DIR_PREFIX + sanitizeEmailForDir(accountName)) : '';
812
+ return {
813
+ dirName: dirName,
814
+ dirPath: dirPath,
815
+ accountName: accountName,
816
+ displayEmail: accountName || UNKNOWN_EMAIL_LABEL,
817
+ parkedName: parkedName,
818
+ isCurrent: !!isCurrent,
819
+ };
820
+ }
821
+
822
+ /**
823
+ * 读取当前空间子账户记录:根上 4 个文件为当前,juser_* 为停放
824
+ * @param {string} codexDir 当前 ~/.codex
825
+ * @returns {Array<object>}
826
+ */
827
+ function buildSubAccountRecords(codexDir) {
828
+ var records = [buildSubAccountRecord(codexDir, '.codex', true)];
829
+ listSubAccountDirs(codexDir).forEach(function (dirName) {
830
+ records.push(buildSubAccountRecord(codexDir, dirName, false));
831
+ });
832
+ records.sort(function (a, b) {
833
+ if (a.isCurrent !== b.isCurrent) {
834
+ return a.isCurrent ? -1 : 1;
835
+ }
836
+ var emailCmp = String(a.displayEmail).localeCompare(String(b.displayEmail));
837
+ if (emailCmp !== 0) {
838
+ return emailCmp;
839
+ }
840
+ return String(a.dirName).localeCompare(String(b.dirName));
841
+ });
842
+ return records;
843
+ }
844
+
845
+ /**
846
+ * 生成二级菜单子账户列表项
847
+ * @param {Array<object>} records 子账户记录
848
+ * @returns {Array<{name: string, value: string}>}
849
+ */
850
+ function buildSubAccountChoices(records) {
851
+ return records.map(function (record) {
852
+ var suffix = record.isCurrent
853
+ ? ' (~/.codex)'
854
+ : ' (~/.codex/' + record.dirName + ')';
855
+ var name = record.displayEmail + suffix;
856
+ if (record.isCurrent) {
857
+ name += ' 当前';
858
+ }
859
+ return { name: name, value: record.dirPath };
860
+ });
861
+ }
862
+
863
+ /**
864
+ * 二级完整选项:子账户列表 + 移除当前子账户 + 返回上级
865
+ * @param {Array<object>} records 子账户记录
866
+ * @returns {Array<{name: string, value: string}>}
867
+ */
868
+ function buildUcxSubChoices(records) {
869
+ var choices = buildSubAccountChoices(records);
870
+ choices.push({
871
+ name: '*移除当前子账户*',
872
+ value: REMOVE_CURRENT_SUB,
873
+ });
874
+ choices.push({
875
+ name: '*返回上级*',
876
+ value: BACK_TO_PARENT,
877
+ });
878
+ return choices;
879
+ }
880
+
881
+ /**
882
+ * 三无当前子账户:询问是否删除这 4 个配置文件,否则手工命名停放到 juser_*
883
+ * @returns {Promise<{ deleteCurrent: boolean, parkName?: string }>}
884
+ */
885
+ async function promptUnnamedSubAction() {
886
+ var deleteAnswer = await inquirer.prompt([{
887
+ type: 'confirm',
888
+ name: 'confirmed',
889
+ message: '当前没有可识别的子账户配置,是否删除这 4 个配置文件?',
890
+ default: false,
891
+ }]);
892
+ if (deleteAnswer.confirmed) {
893
+ return { deleteCurrent: true };
894
+ }
895
+ var nameAnswer = await inquirer.prompt([{
896
+ type: 'input',
897
+ name: 'accountName',
898
+ message: '请输入用于停放当前子账户的名称(不会写回 auth.json)',
899
+ validate: validateParkName,
900
+ }]);
901
+ return { deleteCurrent: false, parkName: String(nameAnswer.accountName || '').trim() };
902
+ }
903
+
904
+ /**
905
+ * 按三无子账户的选择删除 4 个文件或停放到 ~/.codex/juser_*,不删 ~/.codex
906
+ * @param {string} codexDir 当前 ~/.codex
907
+ * @param {{ deleteCurrent: boolean, parkName?: string }} action 用户选择
908
+ * @returns {string|null} 停放后的路径,删除时为 null
909
+ */
910
+ function applyUnnamedSubAction(codexDir, action) {
911
+ if (action.deleteCurrent) {
912
+ removePresentSubFiles(codexDir);
913
+ console.log('已删除当前子账户的 4 个配置文件。');
914
+ return null;
915
+ }
916
+ var parkedName = allocateParkedSubDirName(codexDir, action.parkName);
917
+ var parkedPath = path.join(codexDir, parkedName);
918
+ moveSubFiles(codexDir, codexDir, parkedPath);
919
+ console.log('已将当前子账户停放到 ' + parkedPath);
920
+ return parkedPath;
921
+ }
922
+
923
+ /**
924
+ * 在当前 ~/.codex 内切换子账户:只 move 4 个文件,不碰 ~/.codex_*
925
+ * @param {string} codexDir 当前 ~/.codex
926
+ * @param {object} selected 选中的子账户记录
927
+ * @param {Array<object>} records 全部子账户记录
928
+ * @param {object} [options] 可选配置,execImpl 用于注入杀进程实现
929
+ * @returns {Promise<{ skipped: boolean, email?: string }>}
930
+ */
931
+ async function switchSubAccount(codexDir, selected, records, options) {
932
+ options = options || {};
933
+ if (!selected) {
934
+ throw new Error('未找到要切换的子账户');
935
+ }
936
+ if (selected.isCurrent) {
937
+ console.log('当前已是 ' + selected.displayEmail + ',无需切换');
938
+ return { skipped: true };
939
+ }
940
+
941
+ var currentRecord = null;
942
+ records.forEach(function (record) {
943
+ if (record.isCurrent) {
944
+ currentRecord = record;
945
+ }
946
+ });
947
+
948
+ var presentFiles = listPresentSubFiles(codexDir);
949
+ var unnamedAction = null;
950
+ if (presentFiles.length > 0 && currentRecord && !currentRecord.accountName) {
951
+ unnamedAction = await promptUnnamedSubAction();
952
+ }
953
+
954
+ killCodexProcesses(options.execImpl);
955
+
956
+ var parkedPath = null;
957
+ if (presentFiles.length > 0) {
958
+ if (unnamedAction) {
959
+ parkedPath = applyUnnamedSubAction(codexDir, unnamedAction);
960
+ } else {
961
+ var parkedName = allocateParkedSubDirName(codexDir, currentRecord.accountName);
962
+ parkedPath = path.join(codexDir, parkedName);
963
+ moveSubFiles(codexDir, codexDir, parkedPath);
964
+ }
965
+ }
966
+
967
+ try {
968
+ moveSubFiles(codexDir, selected.dirPath, codexDir);
969
+ removeDirIfEmpty(selected.dirPath);
970
+ } catch (e) {
971
+ if (parkedPath) {
972
+ console.error('已将原子账户停放到 ' + parkedPath + ',但启用新子账户失败,请手动恢复');
973
+ }
974
+ throw e;
975
+ }
976
+
977
+ console.log('已切换到 ' + selected.displayEmail + ',请重新启动 Codex');
978
+ return { skipped: false, email: selected.displayEmail };
979
+ }
980
+
981
+ /**
982
+ * 移除当前子账户:只把 4 个文件停放到 ~/.codex/juser_*,不删 ~/.codex,不改其它账号目录
983
+ * @param {string} codexDir 当前 ~/.codex
984
+ * @param {object|null} currentRecord 当前子账户记录
985
+ * @param {object} [options] 可选配置
986
+ * @returns {Promise<{ cancelled: boolean }>}
987
+ */
988
+ async function removeCurrentSubAccount(codexDir, currentRecord, options) {
989
+ options = options || {};
990
+ var confirmAnswer = await inquirer.prompt([{
991
+ type: 'confirm',
992
+ name: 'confirmed',
993
+ message: '确认把当前子账户停放到 ~/.codex/juser_* 吗?',
994
+ default: false,
995
+ }]);
996
+ if (!confirmAnswer.confirmed) {
997
+ console.log('已取消移除当前子账户。');
998
+ return { cancelled: true };
999
+ }
1000
+
1001
+ var presentFiles = listPresentSubFiles(codexDir);
1002
+ if (presentFiles.length <= 0) {
1003
+ console.log('当前没有可移除的子账户配置。');
1004
+ await maybeCreateCodexEnv(path.dirname(codexDir));
1005
+ return { cancelled: false };
1006
+ }
1007
+
1008
+ var unnamedAction = null;
1009
+ if (!(currentRecord && currentRecord.accountName)) {
1010
+ unnamedAction = await promptUnnamedSubAction();
1011
+ }
1012
+
1013
+ killCodexProcesses(options.execImpl);
1014
+ if (unnamedAction) {
1015
+ applyUnnamedSubAction(codexDir, unnamedAction);
1016
+ } else {
1017
+ var parkedName = allocateParkedSubDirName(codexDir, currentRecord.accountName);
1018
+ var parkedPath = path.join(codexDir, parkedName);
1019
+ moveSubFiles(codexDir, codexDir, parkedPath);
1020
+ console.log('已移除当前子账户,已停放到 ' + parkedPath);
1021
+ }
1022
+
1023
+ await maybeCreateCodexEnv(path.dirname(codexDir));
1024
+ return { cancelled: false };
1025
+ }
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
+
1108
+ /**
1109
+ * 当前空间的子账户二级菜单;所有改动限制在 codexDir 内
1110
+ * @param {string} codexDir 当前 ~/.codex
1111
+ * @param {object} [options] 可选配置
1112
+ * @returns {Promise<{ back: boolean, exitCode?: number }>}
1113
+ */
1114
+ async function runSubAccountMenu(codexDir, options) {
1115
+ options = options || {};
1116
+ var records = buildSubAccountRecords(codexDir);
1117
+ var current = null;
1118
+ records.forEach(function (record) {
1119
+ if (record.isCurrent) {
1120
+ current = record;
1121
+ }
1122
+ });
1123
+ var answer = await inquirer.prompt([{
1124
+ type: 'list',
1125
+ name: 'dirPath',
1126
+ message: '请选择要切换的子账户',
1127
+ choices: buildUcxSubChoices(records),
1128
+ default: current ? current.dirPath : undefined,
1129
+ }]);
1130
+
1131
+ if (answer.dirPath === BACK_TO_PARENT) {
1132
+ return { back: true };
1133
+ }
1134
+ if (answer.dirPath === REMOVE_CURRENT_SUB) {
1135
+ await removeCurrentSubAccount(codexDir, current || null, options);
1136
+ return { back: false };
1137
+ }
1138
+
1139
+ var selected = findRecordByDirPath(records, answer.dirPath);
1140
+ await switchSubAccount(codexDir, selected, records, options);
1141
+ // 切换完成(含“已是当前子账户”的跳过场景)后进入三级菜单,可选择还原会话/直接进入 codex/退出
1142
+ var exitCode = await runPostSwitchMenu(path.dirname(codexDir), options);
1143
+ return { back: false, exitCode: exitCode };
1144
+ }
1145
+
495
1146
  /**
496
1147
  * 按路径从记录中查找账号
497
1148
  * @param {Array<object>} records 账号记录
@@ -510,40 +1161,62 @@ function findRecordByDirPath(records, dirPath) {
510
1161
 
511
1162
  /**
512
1163
  * 执行 ucx 交互切换
1164
+ * 选中当前 ~/.codex 进入子账户二级菜单;返回上级时重新弹出一级列表
513
1165
  * @param {object} cmd commander 选项(当前无业务字段)
514
1166
  * @param {object} buildCfg 含 homeDir 的配置
515
- * @returns {Promise<void>}
1167
+ * @param {object} [options] 可选配置,spawnImpl/execImpl 用于单测注入
1168
+ * @returns {Promise<number|undefined>} 启动过 codex 时返回其退出码
516
1169
  */
517
- async function runUcx(cmd, buildCfg) {
1170
+ async function runUcx(cmd, buildCfg, options) {
1171
+ options = options || {};
518
1172
  var homeDir = getHomeDir(buildCfg);
519
- var records = buildAccountRecords(homeDir);
520
- var current = findRecordByDirPath(records, path.join(homeDir, '.codex'));
521
- var answer = await inquirer.prompt([{
522
- type: 'list',
523
- name: 'dirPath',
524
- message: '请选择要切换的 Codex 账号',
525
- choices: buildUcxChoices(records),
526
- default: current ? current.dirPath : undefined,
527
- }]);
1173
+ for (;;) {
1174
+ var records = buildAccountRecords(homeDir);
1175
+ var current = findRecordByDirPath(records, path.join(homeDir, '.codex'));
1176
+ var answer = await inquirer.prompt([{
1177
+ type: 'list',
1178
+ name: 'dirPath',
1179
+ message: '请选择要切换的 Codex 账号',
1180
+ choices: buildUcxChoices(records),
1181
+ default: current ? current.dirPath : undefined,
1182
+ }]);
528
1183
 
529
- if (answer.dirPath === REMOVE_CURRENT_ACCOUNT) {
530
- await removeCurrentAccount(homeDir, current || null, {});
1184
+ if (answer.dirPath === REMOVE_CURRENT_ACCOUNT) {
1185
+ await removeCurrentAccount(homeDir, current || null, options);
1186
+ return;
1187
+ }
1188
+
1189
+ var selected = findRecordByDirPath(records, answer.dirPath);
1190
+ if (selected && selected.isCurrent) {
1191
+ var subResult = await runSubAccountMenu(selected.dirPath, options);
1192
+ if (subResult && subResult.back) {
1193
+ continue;
1194
+ }
1195
+ return subResult ? subResult.exitCode : undefined;
1196
+ }
1197
+ await switchCodexAccount(homeDir, selected, records, options);
531
1198
  return;
532
1199
  }
533
-
534
- var selected = findRecordByDirPath(records, answer.dirPath);
535
- await switchCodexAccount(homeDir, selected, records, {});
536
1200
  }
537
1201
 
538
1202
  runUcx._private = {
539
1203
  UNKNOWN_EMAIL_LABEL: UNKNOWN_EMAIL_LABEL,
540
1204
  REMOVE_CURRENT_ACCOUNT: REMOVE_CURRENT_ACCOUNT,
1205
+ REMOVE_CURRENT_SUB: REMOVE_CURRENT_SUB,
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,
1210
+ SUB_DIR_PREFIX: SUB_DIR_PREFIX,
1211
+ SUB_FILE_NAMES: SUB_FILE_NAMES,
541
1212
  CODEX_ENV_DEMO: CODEX_ENV_DEMO,
542
1213
  getHomeDir: getHomeDir,
543
1214
  sanitizeEmailForDir: sanitizeEmailForDir,
544
1215
  decodeJwtPayload: decodeJwtPayload,
545
1216
  extractEmailFromTokens: extractEmailFromTokens,
546
1217
  getOpenAiApiKey: getOpenAiApiKey,
1218
+ readModelProvider: readModelProvider,
1219
+ composeAccountName: composeAccountName,
547
1220
  readAccountName: readAccountName,
548
1221
  readAccountEmail: readAccountEmail,
549
1222
  listCodexAccountDirs: listCodexAccountDirs,
@@ -557,6 +1230,26 @@ runUcx._private = {
557
1230
  assertCanParkCurrent: assertCanParkCurrent,
558
1231
  switchCodexAccount: switchCodexAccount,
559
1232
  removeCurrentAccount: removeCurrentAccount,
1233
+ isSubAccountDirName: isSubAccountDirName,
1234
+ resolvePathInsideCodexDir: resolvePathInsideCodexDir,
1235
+ listSubAccountDirs: listSubAccountDirs,
1236
+ listPresentSubFiles: listPresentSubFiles,
1237
+ moveSubFiles: moveSubFiles,
1238
+ removePresentSubFiles: removePresentSubFiles,
1239
+ allocateParkedSubDirName: allocateParkedSubDirName,
1240
+ buildSubAccountRecords: buildSubAccountRecords,
1241
+ buildSubAccountChoices: buildSubAccountChoices,
1242
+ buildUcxSubChoices: buildUcxSubChoices,
1243
+ switchSubAccount: switchSubAccount,
1244
+ removeCurrentSubAccount: removeCurrentSubAccount,
1245
+ runSubAccountMenu: runSubAccountMenu,
1246
+ parseCodexEnvContent: parseCodexEnvContent,
1247
+ buildCodexChildEnv: buildCodexChildEnv,
1248
+ codexCommandExists: codexCommandExists,
1249
+ readLastSessionRecord: readLastSessionRecord,
1250
+ buildPostSwitchChoices: buildPostSwitchChoices,
1251
+ launchCodex: launchCodex,
1252
+ runPostSwitchMenu: runPostSwitchMenu,
560
1253
  };
561
1254
 
562
1255
  module.exports = runUcx;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "base_parts_ai",
3
- "version": "1.0.57",
3
+ "version": "1.0.59",
4
4
  "description": "jaskle base_parts_ai",
5
5
  "main": "./main.js",
6
6
  "registry": true,
@@ -19,7 +19,6 @@
19
19
  "base_parts_ai": "./main.js",
20
20
  "jcc": "./bin/jcc.js",
21
21
  "jcx": "./bin/jcx.js",
22
- "weds": "./bin/weds.js",
23
22
  "ucx": "./bin/ucx.js",
24
23
  "jgrok": "./bin/jgrok.js"
25
24
  },