iivo-sub 0.1.0 → 0.1.2

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
@@ -26,8 +26,15 @@ The main menu includes:
26
26
  - Quick configuration
27
27
  - Backup configuration
28
28
  - Restore backup
29
+ - Backup chat records
30
+ - Restore chat records
31
+ - Clear cache and backups
29
32
  - Exit
30
33
 
31
34
  Backup configuration lets you choose Codex, Claude Code, Hermes, OpenClaw, or all targets. You can name the backup yourself; the default name is `<date-time>_<target>`.
32
35
 
36
+ Chat record backup lets you back up common local conversation paths for Codex, Claude Code, Hermes, and OpenClaw. Restore creates a safety backup before replacing existing chat record paths.
37
+
38
+ After you enter an API key once, it is cached in `~/.iivo-sub/cache.json`. Next time the prompt shows a masked key such as `sk-x...abcd`; press Enter to reuse it or type a new key to replace it.
39
+
33
40
  Existing config files are backed up under `~/.iivo-sub/backups/` before being replaced.
package/bin/iivo-sub.js CHANGED
@@ -5,8 +5,9 @@ import os from 'node:os'
5
5
  import path from 'node:path'
6
6
  import readline from 'node:readline'
7
7
 
8
- const VERSION = '0.1.0'
8
+ const VERSION = '0.1.2'
9
9
  const APP_DIR = path.join(os.homedir(), '.iivo-sub')
10
+ const CACHE_FILE = path.join(APP_DIR, 'cache.json')
10
11
  const SCRIPTED_INPUT = !process.stdin.isTTY ? fs.readFileSync(0, 'utf8').split(/\r?\n/) : null
11
12
  let scriptedInputIndex = 0
12
13
  const DEFAULT_HOST = 'https://sub.iivo.net'
@@ -39,6 +40,9 @@ const MAIN_MENU = [
39
40
  { label: '快速配置', value: 'quick' },
40
41
  { label: '备份配置', value: 'backup' },
41
42
  { label: '恢复备份', value: 'restore' },
43
+ { label: '备份聊天记录', value: 'backup-chats' },
44
+ { label: '还原聊天记录', value: 'restore-chats' },
45
+ { label: '清空缓存及备份', value: 'clear-cache' },
42
46
  { label: '退出', value: 'exit' }
43
47
  ]
44
48
 
@@ -50,11 +54,24 @@ const BACKUP_TARGETS = [
50
54
  { label: '全部备份', value: 'all' }
51
55
  ]
52
56
 
57
+ const CHAT_BACKUP_TARGETS = [
58
+ { label: 'Codex 聊天记录', value: 'codex' },
59
+ { label: 'Claude Code 聊天记录', value: 'claude-code' },
60
+ { label: 'Hermes 聊天记录', value: 'hermes' },
61
+ { label: 'OpenClaw 聊天记录', value: 'openclaw' },
62
+ { label: '全部聊天记录', value: 'all' }
63
+ ]
64
+
53
65
  const RESTORE_CONFIRM_OPTIONS = [
54
66
  { label: '确认恢复', value: 'yes' },
55
67
  { label: '取消', value: 'no' }
56
68
  ]
57
69
 
70
+ const CLEAR_CONFIRM_OPTIONS = [
71
+ { label: '确认清空', value: 'yes' },
72
+ { label: '取消', value: 'no' }
73
+ ]
74
+
58
75
  const colors = {
59
76
  reset: '\x1b[0m',
60
77
  dim: '\x1b[2m',
@@ -253,6 +270,10 @@ function fileExists(filePath) {
253
270
  return fs.existsSync(filePath) && fs.statSync(filePath).isFile()
254
271
  }
255
272
 
273
+ function pathExists(targetPath) {
274
+ return fs.existsSync(targetPath)
275
+ }
276
+
256
277
  function configTargets() {
257
278
  return {
258
279
  codex: [
@@ -294,6 +315,38 @@ function filesForBackupTarget(target) {
294
315
  return targets[target] ?? []
295
316
  }
296
317
 
318
+ function chatTargets() {
319
+ return {
320
+ codex: [
321
+ path.join(os.homedir(), '.codex', 'sessions'),
322
+ path.join(os.homedir(), '.codex', 'history.json')
323
+ ],
324
+ 'claude-code': [
325
+ path.join(os.homedir(), '.claude', 'projects'),
326
+ path.join(os.homedir(), '.claude', 'todos'),
327
+ path.join(os.homedir(), '.claude', 'shell-snapshots')
328
+ ],
329
+ hermes: [
330
+ path.join(os.homedir(), '.hermes', 'sessions'),
331
+ path.join(os.homedir(), '.hermes', 'chats'),
332
+ path.join(os.homedir(), '.hermes', 'history.json')
333
+ ],
334
+ openclaw: [
335
+ path.join(os.homedir(), '.openclaw', 'sessions'),
336
+ path.join(os.homedir(), '.openclaw', 'chats'),
337
+ path.join(os.homedir(), '.openclaw', 'history.json')
338
+ ]
339
+ }
340
+ }
341
+
342
+ function pathsForChatBackupTarget(target) {
343
+ const targets = chatTargets()
344
+ if (target === 'all') {
345
+ return Object.values(targets).flat()
346
+ }
347
+ return targets[target] ?? []
348
+ }
349
+
297
350
  function uniqueFiles(files) {
298
351
  return [...new Set(files)]
299
352
  }
@@ -302,6 +355,10 @@ function backupTargetLabel(value) {
302
355
  return BACKUP_TARGETS.find((item) => item.value === value)?.label ?? value
303
356
  }
304
357
 
358
+ function chatBackupTargetLabel(value) {
359
+ return CHAT_BACKUP_TARGETS.find((item) => item.value === value)?.label ?? value
360
+ }
361
+
305
362
  function timestampForName() {
306
363
  const now = new Date()
307
364
  const pad = (value) => String(value).padStart(2, '0')
@@ -342,6 +399,19 @@ function copyConfigToBackup(filePath, backupRoot) {
342
399
  }
343
400
  }
344
401
 
402
+ function copyPathToBackup(sourcePath, backupRoot) {
403
+ const stat = fs.statSync(sourcePath)
404
+ const backupFile = toBackupFileName(sourcePath)
405
+ const backupPath = path.join(backupRoot, 'files', backupFile)
406
+ ensureDir(path.dirname(backupPath))
407
+ fs.cpSync(sourcePath, backupPath, { recursive: true, force: true })
408
+ return {
409
+ source: sourcePath,
410
+ backup: path.relative(backupRoot, backupPath),
411
+ type: stat.isDirectory() ? 'directory' : 'file'
412
+ }
413
+ }
414
+
345
415
  function loadBackupManifest(dir) {
346
416
  const manifestPath = path.join(dir, 'manifest.json')
347
417
  if (!fileExists(manifestPath)) return null
@@ -352,6 +422,43 @@ function loadBackupManifest(dir) {
352
422
  }
353
423
  }
354
424
 
425
+ function readCache() {
426
+ if (!fileExists(CACHE_FILE)) return {}
427
+ try {
428
+ return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'))
429
+ } catch {
430
+ return {}
431
+ }
432
+ }
433
+
434
+ function writeCache(cache) {
435
+ ensureDir(APP_DIR)
436
+ fs.writeFileSync(CACHE_FILE, json({
437
+ ...cache,
438
+ updated_at: new Date().toISOString()
439
+ }), { encoding: 'utf8', mode: 0o600 })
440
+ }
441
+
442
+ function getCachedApiKey() {
443
+ const cache = readCache()
444
+ return typeof cache.api_key === 'string' ? cache.api_key : ''
445
+ }
446
+
447
+ function setCachedApiKey(apiKey) {
448
+ if (!apiKey) return
449
+ writeCache({
450
+ ...readCache(),
451
+ api_key: apiKey,
452
+ api_key_hint: maskKey(apiKey)
453
+ })
454
+ }
455
+
456
+ function deletePath(target) {
457
+ if (!fs.existsSync(target)) return false
458
+ fs.rmSync(target, { recursive: true, force: true })
459
+ return true
460
+ }
461
+
355
462
  function writeFileWithBackup(filePath, content, backupRoot) {
356
463
  ensureDir(path.dirname(filePath))
357
464
  if (fs.existsSync(filePath)) {
@@ -564,7 +671,10 @@ async function quickConfig() {
564
671
  if (step === 0) {
565
672
  clear()
566
673
  banner()
567
- const apiKey = await question('请输入 API Key [Esc 返回主菜单]', { mask: true })
674
+ const cachedApiKey = getCachedApiKey()
675
+ const defaultHint = cachedApiKey ? maskKey(cachedApiKey) : ''
676
+ const apiKeyInput = await question('请输入 API Key [Esc 返回主菜单]', { mask: true, defaultValue: defaultHint })
677
+ const apiKey = apiKeyInput === defaultHint && cachedApiKey ? cachedApiKey : apiKeyInput
568
678
  if (apiKey === '__exit__') return '__exit__'
569
679
  if (apiKey === null) return
570
680
  if (!apiKey) {
@@ -573,6 +683,7 @@ async function quickConfig() {
573
683
  continue
574
684
  }
575
685
  state.apiKey = apiKey
686
+ setCachedApiKey(apiKey)
576
687
  step = 1
577
688
  continue
578
689
  }
@@ -698,6 +809,7 @@ async function backupConfig() {
698
809
  ensureDir(backupRoot)
699
810
  const files = existing.map((filePath) => copyConfigToBackup(filePath, backupRoot))
700
811
  const manifest = {
812
+ kind: 'config',
701
813
  name,
702
814
  target,
703
815
  target_label: backupTargetLabel(target),
@@ -721,27 +833,105 @@ async function backupConfig() {
721
833
  await pause()
722
834
  }
723
835
 
836
+ async function backupChatRecords() {
837
+ clear()
838
+ banner()
839
+ const target = await select('请选择聊天记录备份目标:', CHAT_BACKUP_TARGETS)
840
+ if (target === '__exit__') return '__exit__'
841
+ if (!target) return
842
+
843
+ const defaultName = `${timestampForName()}_chats_${target}`
844
+ clear()
845
+ banner()
846
+ const rawName = await question('请输入聊天记录备份名称 [Esc 返回上一步]', { defaultValue: defaultName })
847
+ if (rawName === '__exit__') return '__exit__'
848
+ if (rawName === null) return backupChatRecords()
849
+
850
+ const name = safeBackupName(rawName) || defaultName
851
+ const backupRoot = path.join(APP_DIR, 'backups', name)
852
+ if (fs.existsSync(backupRoot)) {
853
+ console.log(c('red', `备份名称已存在: ${name}`))
854
+ await pause()
855
+ return backupChatRecords()
856
+ }
857
+
858
+ const candidates = uniqueFiles(pathsForChatBackupTarget(target))
859
+ const existing = candidates.filter(pathExists)
860
+ if (existing.length === 0) {
861
+ console.log(c('yellow', `没有找到可备份的 ${chatBackupTargetLabel(target)}。`))
862
+ console.log()
863
+ console.log(c('dim', '已检查常见目录:'))
864
+ for (const item of candidates) {
865
+ console.log(c('dim', `- ${item}`))
866
+ }
867
+ await pause()
868
+ return
869
+ }
870
+
871
+ ensureDir(backupRoot)
872
+ const files = existing.map((sourcePath) => copyPathToBackup(sourcePath, backupRoot))
873
+ const manifest = {
874
+ kind: 'chat',
875
+ name,
876
+ target,
877
+ target_label: chatBackupTargetLabel(target),
878
+ created_at: new Date().toISOString(),
879
+ files
880
+ }
881
+ fs.writeFileSync(path.join(backupRoot, 'manifest.json'), json(manifest), 'utf8')
882
+
883
+ clear()
884
+ banner()
885
+ console.log(c('green', '聊天记录备份完成。'))
886
+ console.log()
887
+ console.log(`备份名称: ${name}`)
888
+ console.log(`备份目标: ${chatBackupTargetLabel(target)}`)
889
+ console.log(`备份目录: ${backupRoot}`)
890
+ console.log()
891
+ console.log(c('cyan', '已备份路径:'))
892
+ for (const file of files) {
893
+ console.log(`- ${file.source}`)
894
+ }
895
+ await pause()
896
+ }
897
+
898
+ function backupEntryMatchesKind(entryName, backupDir, kind) {
899
+ const manifest = loadBackupManifest(path.join(backupDir, entryName))
900
+ if (!manifest) return kind === 'config'
901
+ if (kind === 'config') return !manifest.kind || manifest.kind === 'config'
902
+ return manifest.kind === kind
903
+ }
904
+
724
905
  async function restoreBackup() {
906
+ return restoreBackupByKind('config', '请选择要查看的配置备份:', '暂无配置备份。')
907
+ }
908
+
909
+ async function restoreChatRecords() {
910
+ return restoreBackupByKind('chat', '请选择要还原的聊天记录备份:', '暂无聊天记录备份。')
911
+ }
912
+
913
+ async function restoreBackupByKind(kind, title, emptyMessage) {
725
914
  clear()
726
915
  banner()
727
916
  const backupDir = path.join(APP_DIR, 'backups')
728
917
  if (!fs.existsSync(backupDir)) {
729
- console.log(c('yellow', '暂无备份。'))
918
+ console.log(c('yellow', emptyMessage))
730
919
  return pause()
731
920
  }
732
921
 
733
922
  const entries = fs.readdirSync(backupDir, { withFileTypes: true })
734
923
  .filter((entry) => entry.isDirectory())
735
924
  .map((entry) => entry.name)
925
+ .filter((name) => backupEntryMatchesKind(name, backupDir, kind))
736
926
  .sort()
737
927
  .reverse()
738
928
 
739
929
  if (entries.length === 0) {
740
- console.log(c('yellow', '暂无备份。'))
930
+ console.log(c('yellow', emptyMessage))
741
931
  return pause()
742
932
  }
743
933
 
744
- const selected = await select('请选择要查看的备份:', [
934
+ const selected = await select(title, [
745
935
  ...entries.map((name) => ({ label: name, value: name })),
746
936
  { label: '返回', value: null }
747
937
  ])
@@ -768,13 +958,16 @@ async function restoreBackup() {
768
958
  console.log(`备份目标: ${manifest.target_label || manifest.target || '未知'}`)
769
959
  console.log(`创建时间: ${manifest.created_at || '未知'}`)
770
960
  console.log()
771
- console.log(c('cyan', '将恢复以下文件:'))
961
+ console.log(c('cyan', kind === 'chat' ? '将还原以下聊天记录路径:' : '将恢复以下文件:'))
772
962
  for (const file of manifest.files || []) {
773
963
  console.log(`- ${file.source}`)
774
964
  }
775
965
  console.log()
776
966
 
777
- const confirm = await select('确认恢复这个备份吗?当前同名配置会先自动备份。', RESTORE_CONFIRM_OPTIONS)
967
+ const confirmText = kind === 'chat'
968
+ ? '确认还原这个聊天记录备份吗?当前同名聊天记录会先自动备份。'
969
+ : '确认恢复这个备份吗?当前同名配置会先自动备份。'
970
+ const confirm = await select(confirmText, RESTORE_CONFIRM_OPTIONS)
778
971
  if (confirm === '__exit__') return '__exit__'
779
972
  if (confirm !== 'yes') return
780
973
 
@@ -784,18 +977,21 @@ async function restoreBackup() {
784
977
  for (const file of manifest.files || []) {
785
978
  const source = file.source
786
979
  const backupPath = path.join(selectedDir, file.backup)
787
- if (!source || !fileExists(backupPath)) continue
788
- if (fileExists(source)) {
789
- copyConfigToBackup(source, safetyBackup)
980
+ if (!source || !pathExists(backupPath)) continue
981
+ if (pathExists(source)) {
982
+ copyPathToBackup(source, safetyBackup)
790
983
  }
791
984
  ensureDir(path.dirname(source))
792
- fs.copyFileSync(backupPath, source)
985
+ if (pathExists(source)) {
986
+ deletePath(source)
987
+ }
988
+ fs.cpSync(backupPath, source, { recursive: true, force: true })
793
989
  restored.push(source)
794
990
  }
795
991
 
796
992
  clear()
797
993
  banner()
798
- console.log(c('green', '恢复完成。'))
994
+ console.log(c('green', kind === 'chat' ? '聊天记录还原完成。' : '恢复完成。'))
799
995
  console.log()
800
996
  for (const file of restored) {
801
997
  console.log(`- ${file}`)
@@ -805,6 +1001,57 @@ async function restoreBackup() {
805
1001
  await pause()
806
1002
  }
807
1003
 
1004
+ async function clearCacheAndBackups() {
1005
+ clear()
1006
+ banner()
1007
+ console.log(c('yellow', '将删除以下 iivo-sub 数据:'))
1008
+ console.log(`- API Key 缓存: ${CACHE_FILE}`)
1009
+ console.log(`- 备份目录: ${path.join(APP_DIR, 'backups')} ${c('dim', '(包含配置备份和聊天记录备份)')}`)
1010
+ console.log(`- 临时配置脚本: ${path.join(APP_DIR, '*.env / *.ps1 / *.cmd')}`)
1011
+ console.log(`- 最近配置摘要: ${path.join(APP_DIR, 'config.json')}`)
1012
+ console.log()
1013
+ console.log('不会删除 Codex、Claude Code、Hermes、OpenClaw 的实际配置文件。')
1014
+ console.log()
1015
+
1016
+ const confirm = await select('确认清空缓存及备份吗?', CLEAR_CONFIRM_OPTIONS)
1017
+ if (confirm === '__exit__') return '__exit__'
1018
+ if (confirm !== 'yes') return
1019
+
1020
+ const removed = []
1021
+ for (const target of [
1022
+ CACHE_FILE,
1023
+ path.join(APP_DIR, 'backups'),
1024
+ path.join(APP_DIR, 'config.json'),
1025
+ path.join(APP_DIR, 'codex.env'),
1026
+ path.join(APP_DIR, 'codex.ps1'),
1027
+ path.join(APP_DIR, 'codex.cmd'),
1028
+ path.join(APP_DIR, 'claude-code.env'),
1029
+ path.join(APP_DIR, 'claude-code.ps1'),
1030
+ path.join(APP_DIR, 'claude-code.cmd'),
1031
+ path.join(APP_DIR, 'hermes.env'),
1032
+ path.join(APP_DIR, 'hermes.ps1'),
1033
+ path.join(APP_DIR, 'hermes.cmd'),
1034
+ path.join(APP_DIR, 'openclaw.env'),
1035
+ path.join(APP_DIR, 'openclaw.ps1'),
1036
+ path.join(APP_DIR, 'openclaw.cmd')
1037
+ ]) {
1038
+ if (deletePath(target)) removed.push(target)
1039
+ }
1040
+
1041
+ clear()
1042
+ banner()
1043
+ if (removed.length === 0) {
1044
+ console.log(c('yellow', '没有找到需要清空的数据。'))
1045
+ } else {
1046
+ console.log(c('green', '缓存及备份已清空。'))
1047
+ console.log()
1048
+ for (const target of removed) {
1049
+ console.log(`- ${target}`)
1050
+ }
1051
+ }
1052
+ await pause()
1053
+ }
1054
+
808
1055
  async function pause() {
809
1056
  await question('按回车继续')
810
1057
  }
@@ -826,6 +1073,18 @@ async function main() {
826
1073
  const result = await restoreBackup()
827
1074
  if (result === '__exit__') break
828
1075
  }
1076
+ if (action === 'backup-chats') {
1077
+ const result = await backupChatRecords()
1078
+ if (result === '__exit__') break
1079
+ }
1080
+ if (action === 'restore-chats') {
1081
+ const result = await restoreChatRecords()
1082
+ if (result === '__exit__') break
1083
+ }
1084
+ if (action === 'clear-cache') {
1085
+ const result = await clearCacheAndBackups()
1086
+ if (result === '__exit__') break
1087
+ }
829
1088
  }
830
1089
  clear()
831
1090
  console.log('已退出 iivo-sub。')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iivo-sub",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "IIVO AI gateway quick configuration CLI",
5
5
  "type": "module",
6
6
  "bin": {