easyai 2.0.0 → 2.1.0
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.
- checksums.yaml +4 -4
- data/CLAUDE.md +10 -0
- data/lib/easyai/base/toml_sections.rb +105 -0
- data/lib/easyai/command/ai_tool_base.rb +15 -5
- data/lib/easyai/command/backup/claude.rb +27 -11
- data/lib/easyai/command/backup/codex.rb +206 -0
- data/lib/easyai/command/backup.rb +3 -0
- data/lib/easyai/command/claude.rb +3 -0
- data/lib/easyai/command/clean.rb +3 -5
- data/lib/easyai/command/codex.rb +230 -0
- data/lib/easyai/command/restore/codex.rb +165 -0
- data/lib/easyai/command/restore.rb +3 -0
- data/lib/easyai/command/setup.rb +65 -25
- data/lib/easyai/config/local_config.rb +35 -6
- data/lib/easyai/version.rb +1 -1
- data/lib/easyai.rb +0 -1
- metadata +5 -3
- data/lib/easyai/command/gemini.rb +0 -38
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 75408704158dabc22070ec012de28eaced8080bbfc7ebd4b45e5c0481bdfe071
|
|
4
|
+
data.tar.gz: 58b0d079f864fc04c3cb82690af5f0e904b0eef17a4737dc44fa83443a66bd56
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1e6e34f8e8d716042ac88d690a82a4f38a5a1a61cc1cd8e7a5fc32f09f611233d6b48439fa1234c29dd392bb021b8a7f6b8138ea9c5022888382bcb59af8259f
|
|
7
|
+
data.tar.gz: 45990675aba9f65881fa41946539ea5cffb626c0e1adf1a48f2265deb18620125040436d391b581ce71c24f86d90c53ecdaf4f8eca1e13940f89dde8e6ed8aa4
|
data/CLAUDE.md
CHANGED
|
@@ -272,3 +272,13 @@ CLAide::Command
|
|
|
272
272
|
- 输入用 `IO#noecho` 不回显
|
|
273
273
|
- `--list` 输出做"前 4 + 后 4 + 长度"脱敏
|
|
274
274
|
- 不得把 `~/.easyai/config.json` 上传到代码仓库或日志服务
|
|
275
|
+
|
|
276
|
+
## Ruby Rules
|
|
277
|
+
|
|
278
|
+
编写 Ruby 代码时参考以下规范:
|
|
279
|
+
- 通用编码风格:`.claude/rules/ecc/common/coding-style.md`
|
|
280
|
+
- Ruby 专项规范:`.claude/rules/ecc/ruby/` 目录(coding-style / testing / security / patterns / hooks)
|
|
281
|
+
|
|
282
|
+
## 提交规范
|
|
283
|
+
|
|
284
|
+
提交代码时参考 `.claude/rules/git-flow/git-flow.md` 中的规范。
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasyAI
|
|
4
|
+
module Base
|
|
5
|
+
# 极简 TOML "段" 处理工具:只按 table 头切块,不解析具体值。
|
|
6
|
+
#
|
|
7
|
+
# 适用范围:codex ~/.codex/config.toml 这类结构简单的文件
|
|
8
|
+
# (顶层 key/value + 若干 [table] / [a.b] 段,无多行数组 / 复杂内联表)。
|
|
9
|
+
#
|
|
10
|
+
# 提供能力:
|
|
11
|
+
# - split_sections 把文本切成有序的段(每段含 header + 原始行)
|
|
12
|
+
# - reject_projects 过滤掉所有 [projects.*] 段,返回新文本
|
|
13
|
+
# - merge 段级合并:override 覆盖 base 同名段,base 独有段保留
|
|
14
|
+
module TomlSections
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
PROJECT_HEADER_PREFIX = '[projects.'
|
|
18
|
+
|
|
19
|
+
Section = Struct.new(:header, :body, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
# 判断一行是否是 table 头:去掉前导空白后以 '[' 开头(含 '[[' 数组表)
|
|
22
|
+
def header_line?(line)
|
|
23
|
+
line.lstrip.start_with?('[')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# 归一化 header(整行去空白),用于同名段比对
|
|
27
|
+
def header_key(line)
|
|
28
|
+
line.strip
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def project_header?(header)
|
|
32
|
+
return false if header.nil?
|
|
33
|
+
|
|
34
|
+
header.start_with?(PROJECT_HEADER_PREFIX)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# 切段:返回 [Section, ...]。文件开头无 table 头的部分作为 header=nil 的前导段。
|
|
38
|
+
def split_sections(text)
|
|
39
|
+
sections = []
|
|
40
|
+
current = Section.new(header: nil, body: +'')
|
|
41
|
+
|
|
42
|
+
text.to_s.each_line do |line|
|
|
43
|
+
if header_line?(line)
|
|
44
|
+
sections << current unless empty_preamble?(current)
|
|
45
|
+
current = Section.new(header: header_key(line), body: +line.dup)
|
|
46
|
+
else
|
|
47
|
+
current.body << line
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
sections << current unless empty_preamble?(current)
|
|
51
|
+
sections
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def reject_projects(text)
|
|
55
|
+
kept = split_sections(text).reject { |s| project_header?(s.header) }
|
|
56
|
+
render(kept)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 段级合并,override 优先:
|
|
60
|
+
# - override 出现的 header 覆盖 base 同名段
|
|
61
|
+
# - base 独有 header(含 [projects.*])保留,追加在 override 具名段之后
|
|
62
|
+
# - 前导段(header=nil):override 非空用 override,否则用 base
|
|
63
|
+
def merge(base_text, override_text)
|
|
64
|
+
base = split_sections(base_text)
|
|
65
|
+
override = split_sections(override_text)
|
|
66
|
+
override_keys = override.map(&:header).compact
|
|
67
|
+
|
|
68
|
+
result = []
|
|
69
|
+
|
|
70
|
+
over_pre = override.find { |s| s.header.nil? }
|
|
71
|
+
base_pre = base.find { |s| s.header.nil? }
|
|
72
|
+
preamble = over_pre && !over_pre.body.strip.empty? ? over_pre : base_pre
|
|
73
|
+
result << preamble if preamble
|
|
74
|
+
|
|
75
|
+
override.each { |s| result << s unless s.header.nil? }
|
|
76
|
+
base.each do |s|
|
|
77
|
+
next if s.header.nil?
|
|
78
|
+
next if override_keys.include?(s.header)
|
|
79
|
+
|
|
80
|
+
result << s
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
render(result)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# ----- private -----
|
|
87
|
+
|
|
88
|
+
def render(sections)
|
|
89
|
+
sections.map { |s| ensure_trailing_newline(s.body) }.join
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def ensure_trailing_newline(body)
|
|
93
|
+
return body if body.empty? || body.end_with?("\n")
|
|
94
|
+
|
|
95
|
+
"#{body}\n"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def empty_preamble?(section)
|
|
99
|
+
section.header.nil? && section.body.empty?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private_class_method :render, :ensure_trailing_newline, :empty_preamble?
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -48,11 +48,12 @@ module EasyAI
|
|
|
48
48
|
|
|
49
49
|
def run
|
|
50
50
|
cfg = resolve_config
|
|
51
|
+
@resolved_config = cfg # 供子类 pre_exec 读取当前生效的平台配置
|
|
51
52
|
print_platform_env(cfg)
|
|
52
|
-
env = build_environment(cfg)
|
|
53
|
-
export_environment_variables(env)
|
|
53
|
+
@env = build_environment(cfg) # 存 ivar:pre_exec 可按需追加(如 codex 注入 CODEX_HOME)
|
|
54
|
+
export_environment_variables(@env)
|
|
54
55
|
pre_exec
|
|
55
|
-
exec(env, exec_command, *@passthrough_args)
|
|
56
|
+
exec(@env, exec_command, *@passthrough_args)
|
|
56
57
|
rescue Config::LocalConfig::NotFoundError => e
|
|
57
58
|
print_error(e.message)
|
|
58
59
|
puts " 请运行: #{'easyai setup'.yellow}"
|
|
@@ -95,12 +96,19 @@ module EasyAI
|
|
|
95
96
|
{}
|
|
96
97
|
end
|
|
97
98
|
|
|
98
|
-
# 子类可选重写:在 exec
|
|
99
|
+
# 子类可选重写:在 exec 启动子进程之前执行的钩子(如:写入子进程依赖的状态文件、
|
|
100
|
+
# 追加只对本次子进程生效的环境变量到 @env,如 codex 注入 CODEX_HOME)。
|
|
99
101
|
# 默认 no-op;不应阻塞主流程,异常应降级为 warning。
|
|
100
102
|
def pre_exec
|
|
101
103
|
# no-op
|
|
102
104
|
end
|
|
103
105
|
|
|
106
|
+
# 子类可选重写:向平台选择列表注入的虚拟平台 { key => data }(如 codex 的 ChatGPT Subscribe)。
|
|
107
|
+
# 默认空;与 config.json 真实平台合并参与交互选择。
|
|
108
|
+
def extra_platforms
|
|
109
|
+
{}
|
|
110
|
+
end
|
|
111
|
+
|
|
104
112
|
private
|
|
105
113
|
|
|
106
114
|
# 从 remainder 中识别第一个以 .json 结尾且文件存在的位置参数作为本地覆盖入口
|
|
@@ -119,7 +127,9 @@ module EasyAI
|
|
|
119
127
|
print_success("使用本地覆盖配置 #{@local_config_file}(工具:#{tool_name})")
|
|
120
128
|
cfg
|
|
121
129
|
else
|
|
122
|
-
Config::LocalConfig.resolve_platform(
|
|
130
|
+
Config::LocalConfig.resolve_platform(
|
|
131
|
+
tool: tool_name, platform: @platform, verbose: @verbose, extra: extra_platforms
|
|
132
|
+
) do |name|
|
|
123
133
|
print_success("使用平台 #{name}(工具:#{tool_name})")
|
|
124
134
|
end
|
|
125
135
|
end
|
|
@@ -27,20 +27,23 @@ module EasyAI
|
|
|
27
27
|
$ easyai backup claude
|
|
28
28
|
DESC
|
|
29
29
|
|
|
30
|
-
SOURCE_PATH = File.expand_path('~/.claude.json').freeze
|
|
31
|
-
BACKUP_DIR = File.expand_path('~/.easyai/backup').freeze
|
|
32
|
-
BACKUP_PATH = File.join(BACKUP_DIR, '.claude.json').freeze
|
|
33
30
|
EXCLUDED_KEYS = %w[projects].freeze
|
|
34
31
|
KEYCHAIN_LABEL = 'Claude Code-credentials'.freeze
|
|
35
32
|
KEYCHAIN_BACKUP_KEY = '_easyai_keychain'.freeze
|
|
36
33
|
|
|
34
|
+
# 注意:路径用方法动态 expand_path,避免常量在加载期冻结真实 HOME,
|
|
35
|
+
# 让 spec 切换 ENV['HOME'] 后定位失败(同 LocalConfig.config_path 的处理)
|
|
36
|
+
def source_path = File.expand_path('~/.claude.json')
|
|
37
|
+
def backup_dir = File.expand_path('~/.easyai/backup')
|
|
38
|
+
def backup_path = File.join(backup_dir, '.claude.json')
|
|
39
|
+
|
|
37
40
|
def validate!
|
|
38
41
|
super
|
|
39
|
-
help! "源文件不存在: #{
|
|
42
|
+
help! "源文件不存在: #{source_path}\n请先登录 Claude Code 后再备份。" unless File.exist?(source_path)
|
|
40
43
|
end
|
|
41
44
|
|
|
42
45
|
def run
|
|
43
|
-
source = read_json(
|
|
46
|
+
source = read_json(source_path)
|
|
44
47
|
EXCLUDED_KEYS.each { |key| source.delete(key) }
|
|
45
48
|
|
|
46
49
|
keychain_value = read_keychain_credential(KEYCHAIN_LABEL)
|
|
@@ -48,18 +51,31 @@ module EasyAI
|
|
|
48
51
|
source[KEYCHAIN_BACKUP_KEY] = { KEYCHAIN_LABEL => keychain_value }
|
|
49
52
|
end
|
|
50
53
|
|
|
51
|
-
target = File.exist?(
|
|
54
|
+
target = File.exist?(backup_path) ? read_json(backup_path) : {}
|
|
52
55
|
before_keys = target.keys
|
|
53
56
|
|
|
54
57
|
merged = deep_merge(target, source)
|
|
55
58
|
|
|
56
|
-
FileUtils.mkdir_p(
|
|
57
|
-
File.write(
|
|
58
|
-
File.chmod(0o600,
|
|
59
|
+
FileUtils.mkdir_p(backup_dir)
|
|
60
|
+
File.write(backup_path, JSON.pretty_generate(merged))
|
|
61
|
+
File.chmod(0o600, backup_path) unless windows?
|
|
59
62
|
|
|
60
63
|
report(before_keys, source.keys, merged.keys, keychain_value)
|
|
61
64
|
end
|
|
62
65
|
|
|
66
|
+
# 供 easyai claude 运行前调用:仅当备份不存在且源文件存在时自动备份一次。
|
|
67
|
+
# 返回是否执行了备份;任何异常降级为 warning,绝不阻塞启动。
|
|
68
|
+
def backup_if_absent
|
|
69
|
+
return false if File.exist?(backup_path)
|
|
70
|
+
return false unless File.exist?(source_path)
|
|
71
|
+
|
|
72
|
+
run
|
|
73
|
+
true
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
warn " ⚠ 自动备份 Claude 登录信息失败(#{e.message}),继续启动"
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
63
79
|
private
|
|
64
80
|
|
|
65
81
|
def read_json(path)
|
|
@@ -100,8 +116,8 @@ module EasyAI
|
|
|
100
116
|
kept = before_keys - source_keys
|
|
101
117
|
|
|
102
118
|
puts "✓ Claude 登录信息已备份"
|
|
103
|
-
puts " 源文件: #{
|
|
104
|
-
puts " 备份路径: #{
|
|
119
|
+
puts " 源文件: #{source_path}"
|
|
120
|
+
puts " 备份路径: #{backup_path}"
|
|
105
121
|
puts " 排除字段: #{EXCLUDED_KEYS.join(', ')}"
|
|
106
122
|
puts " 字段更新: 新增 #{added.size},更新 #{updated.size},保留目标独有 #{kept.size}(合计 #{after_keys.size})"
|
|
107
123
|
if keychain_value
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
require_relative '../../base/toml_sections'
|
|
4
|
+
require_relative '../../base/system_info'
|
|
5
|
+
|
|
6
|
+
module EasyAI
|
|
7
|
+
class Command
|
|
8
|
+
class Backup
|
|
9
|
+
class Codex < Backup
|
|
10
|
+
self.summary = '备份 Codex 登录信息'
|
|
11
|
+
self.description = <<-DESC
|
|
12
|
+
把 ~/.codex/auth.json(登录凭证)+ ~/.codex/config.toml(去除 [projects.*] 授信段)
|
|
13
|
+
合并存进单个文件 ~/.easyai/backup/.codex.json,便于跨机器迁移或灾难恢复。
|
|
14
|
+
|
|
15
|
+
特点:
|
|
16
|
+
|
|
17
|
+
* 单文件存储:auth 与 config.toml 文本一并放进 .codex.json 的 auth / config_toml 字段
|
|
18
|
+
|
|
19
|
+
* auth 字段级深度 merge:源字段覆盖/新增到备份,备份独有字段保留
|
|
20
|
+
|
|
21
|
+
* config.toml 排除 [projects.*]:本地按项目授信的段不进备份,其余段段级 merge(源覆盖)
|
|
22
|
+
|
|
23
|
+
* 安全权限:备份文件 chmod 600(仅当前用户可读写)
|
|
24
|
+
|
|
25
|
+
使用示例:
|
|
26
|
+
|
|
27
|
+
$ easyai backup codex
|
|
28
|
+
DESC
|
|
29
|
+
|
|
30
|
+
AUTH_KEY = 'auth'.freeze
|
|
31
|
+
CONFIG_KEY = 'config_toml'.freeze
|
|
32
|
+
|
|
33
|
+
# 注意:路径用方法动态 expand_path,避免常量在加载期冻结真实 HOME,
|
|
34
|
+
# 让 spec 切换 ENV['HOME'] 后定位失败(同 LocalConfig.config_path 的处理)
|
|
35
|
+
def auth_source = File.expand_path('~/.codex/auth.json')
|
|
36
|
+
def config_source = File.expand_path('~/.codex/config.toml')
|
|
37
|
+
def backup_dir = File.expand_path('~/.easyai/backup')
|
|
38
|
+
def backup_path = File.join(backup_dir, '.codex.json')
|
|
39
|
+
|
|
40
|
+
def validate!
|
|
41
|
+
super
|
|
42
|
+
ensure_file_credentials_supported!
|
|
43
|
+
help! "源文件不存在: #{auth_source}\n请先登录 Codex 后再备份。" unless File.exist?(auth_source)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def run
|
|
47
|
+
ensure_file_credentials_supported!
|
|
48
|
+
unless official_token_present?
|
|
49
|
+
puts '⚠ 未在 ~/.codex/auth.json 检测到 ChatGPT 官方登录 token(OAuth),跳过备份。'
|
|
50
|
+
puts ' 如需备份,请先运行 codex 完成 ChatGPT 订阅账户登录后重试。'
|
|
51
|
+
return
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
perform_backup
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# 供 easyai setup 选择 ChatGPT Subscribe 时复用:仅当存在官方 token 才更新备份,
|
|
58
|
+
# 效果等同 easyai backup codex。返回是否实际执行了备份。
|
|
59
|
+
def backup_if_official
|
|
60
|
+
return false unless official_token_present?
|
|
61
|
+
|
|
62
|
+
perform_backup
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# 供 easyai codex 运行前调用:仅当备份不存在、源文件存在且含官方 token 时自动备份一次。
|
|
67
|
+
# 返回是否执行了备份;任何异常降级为 warning,绝不阻塞启动。
|
|
68
|
+
def backup_if_absent
|
|
69
|
+
return false if File.exist?(backup_path)
|
|
70
|
+
return false unless File.exist?(auth_source)
|
|
71
|
+
# keyring 模式下凭证不在文件里,自动备份会一路触发 help! → 每次启动刷屏;此处静默跳过
|
|
72
|
+
return false if File.exist?(config_source) && keyring_credentials_store?(File.read(config_source))
|
|
73
|
+
# 只备份真正的 ChatGPT 官方登录态;EasyAI 给 longcat/deepseek 写的纯 API Key auth.json 不在此列
|
|
74
|
+
return false unless official_token_present?
|
|
75
|
+
|
|
76
|
+
perform_backup
|
|
77
|
+
true
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
warn " ⚠ 自动备份 Codex 登录信息失败(#{e.message}),继续启动"
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# 备份文件 .codex.json 中是否含 ChatGPT 官方 OAuth token,
|
|
84
|
+
# 供 easyai codex 运行时决定是否提供 ChatGPT Subscribe 选项。
|
|
85
|
+
def backup_has_official_token?
|
|
86
|
+
return false unless File.exist?(backup_path)
|
|
87
|
+
|
|
88
|
+
oauth_token?(read_json(backup_path)[AUTH_KEY])
|
|
89
|
+
rescue StandardError
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def perform_backup
|
|
96
|
+
FileUtils.mkdir_p(backup_dir)
|
|
97
|
+
existing = File.exist?(backup_path) ? read_json(backup_path) : {}
|
|
98
|
+
|
|
99
|
+
auth_summary = merge_auth(existing)
|
|
100
|
+
config_summary = merge_config(existing)
|
|
101
|
+
|
|
102
|
+
File.write(backup_path, JSON.pretty_generate(existing))
|
|
103
|
+
File.chmod(0o600, backup_path) unless windows?
|
|
104
|
+
|
|
105
|
+
report(auth_summary, config_summary)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# 检测 ~/.codex/auth.json 是否为 ChatGPT 官方登录(OAuth)。
|
|
109
|
+
def official_token_present?
|
|
110
|
+
return false unless File.exist?(auth_source)
|
|
111
|
+
|
|
112
|
+
oauth_token?(read_json(auth_source))
|
|
113
|
+
rescue StandardError
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# auth hash 是否带 ChatGPT OAuth 凭证:含非空 tokens 段,或 auth_mode == "chatgpt"。
|
|
118
|
+
# 仅有第三方 OPENAI_API_KEY 不算。
|
|
119
|
+
def oauth_token?(auth)
|
|
120
|
+
return false unless auth.is_a?(Hash)
|
|
121
|
+
|
|
122
|
+
tokens = auth['tokens']
|
|
123
|
+
has_oauth = tokens.is_a?(Hash) && tokens.values.any? { |v| !v.to_s.strip.empty? }
|
|
124
|
+
has_oauth || auth['auth_mode'].to_s == 'chatgpt'
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def ensure_file_credentials_supported!
|
|
128
|
+
return unless File.exist?(config_source)
|
|
129
|
+
|
|
130
|
+
return unless keyring_credentials_store?(File.read(config_source))
|
|
131
|
+
|
|
132
|
+
help! <<~MSG
|
|
133
|
+
当前 Codex 配置 cli_auth_credentials_store = "keyring",凭证存储在系统钥匙串中。
|
|
134
|
+
easyai backup codex 目前只支持 file/auth.json 凭证备份,请先改用 file 存储或手动备份系统钥匙串凭证。
|
|
135
|
+
MSG
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def keyring_credentials_store?(toml_text)
|
|
139
|
+
toml_text.to_s.each_line.any? do |line|
|
|
140
|
+
line.match?(/^\s*cli_auth_credentials_store\s*=\s*["']keyring["']\s*(?:#.*)?$/)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# 把 ~/.codex/auth.json 深度 merge 进 existing[AUTH_KEY],返回统计
|
|
145
|
+
def merge_auth(existing)
|
|
146
|
+
source = read_json(auth_source)
|
|
147
|
+
target = existing[AUTH_KEY].is_a?(Hash) ? existing[AUTH_KEY] : {}
|
|
148
|
+
before = target.keys
|
|
149
|
+
existing[AUTH_KEY] = deep_merge(target, source)
|
|
150
|
+
|
|
151
|
+
{
|
|
152
|
+
added: (source.keys - before).size,
|
|
153
|
+
updated: (source.keys & before).size,
|
|
154
|
+
kept: (before - source.keys).size,
|
|
155
|
+
total: existing[AUTH_KEY].keys.size
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# 把 config.toml(去 projects)段级 merge 进 existing[CONFIG_KEY],返回统计
|
|
160
|
+
def merge_config(existing)
|
|
161
|
+
return nil unless File.exist?(config_source)
|
|
162
|
+
|
|
163
|
+
raw = File.read(config_source)
|
|
164
|
+
excluded = Base::TomlSections.split_sections(raw)
|
|
165
|
+
.count { |s| Base::TomlSections.project_header?(s.header) }
|
|
166
|
+
filtered = Base::TomlSections.reject_projects(raw)
|
|
167
|
+
base = existing[CONFIG_KEY].is_a?(String) ? existing[CONFIG_KEY] : ''
|
|
168
|
+
existing[CONFIG_KEY] = Base::TomlSections.merge(base, filtered)
|
|
169
|
+
|
|
170
|
+
{ excluded_projects: excluded }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def read_json(path)
|
|
174
|
+
JSON.parse(File.read(path))
|
|
175
|
+
rescue JSON::ParserError => e
|
|
176
|
+
raise "解析 JSON 失败 (#{path}): #{e.message}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def deep_merge(target, source)
|
|
180
|
+
target.merge(source) do |_key, old_val, new_val|
|
|
181
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
182
|
+
deep_merge(old_val, new_val)
|
|
183
|
+
else
|
|
184
|
+
new_val
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def report(auth, config)
|
|
190
|
+
puts "✓ Codex 登录信息已备份"
|
|
191
|
+
puts " 备份文件: #{backup_path}"
|
|
192
|
+
puts " auth: 新增 #{auth[:added]},更新 #{auth[:updated]},保留备份独有 #{auth[:kept]}(合计 #{auth[:total]})"
|
|
193
|
+
if config
|
|
194
|
+
puts " config.toml: 已备份(排除 #{config[:excluded_projects]} 个 [projects.*] 授信段)"
|
|
195
|
+
else
|
|
196
|
+
puts " config.toml: 源文件不存在,跳过"
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def windows?
|
|
201
|
+
Base::SystemInfo.windows?
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -8,10 +8,12 @@ module EasyAI
|
|
|
8
8
|
可用命令:
|
|
9
9
|
|
|
10
10
|
* claude - 备份 Claude Code 登录信息
|
|
11
|
+
* codex - 备份 Codex 登录信息
|
|
11
12
|
|
|
12
13
|
使用示例:
|
|
13
14
|
|
|
14
15
|
$ easyai backup claude # 备份 ~/.claude.json(去除 projects)到 ~/.easyai/backup/.claude.json
|
|
16
|
+
$ easyai backup codex # 备份 ~/.codex/auth.json + config.toml(去除 [projects.*])到 ~/.easyai/backup/.codex.json
|
|
15
17
|
DESC
|
|
16
18
|
|
|
17
19
|
self.abstract_command = true
|
|
@@ -21,3 +23,4 @@ end
|
|
|
21
23
|
|
|
22
24
|
# 加载子命令
|
|
23
25
|
require_relative 'backup/claude'
|
|
26
|
+
require_relative 'backup/codex'
|
|
@@ -4,6 +4,7 @@ require 'json'
|
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require 'open3'
|
|
6
6
|
require_relative 'ai_tool_base'
|
|
7
|
+
require_relative 'backup'
|
|
7
8
|
|
|
8
9
|
module EasyAI
|
|
9
10
|
class Command
|
|
@@ -57,6 +58,8 @@ DESC
|
|
|
57
58
|
# 会清掉 OAuth 状态。EasyAI 设计前提是统一走 ANTHROPIC_AUTH_TOKEN 环境变量路径,OAuth 登录
|
|
58
59
|
# 态由 `easyai backup claude` / `easyai restore claude` 显式管理。
|
|
59
60
|
def pre_exec
|
|
61
|
+
# 方案 A:清理 OAuth 残留前先兜底备份,确保首次运行可恢复(仅当备份缺失时触发)
|
|
62
|
+
Backup::Claude.new(CLAide::ARGV.new([])).backup_if_absent
|
|
60
63
|
reset_claude_state_to_token_mode
|
|
61
64
|
delete_keychain_credential(KEYCHAIN_LABEL)
|
|
62
65
|
end
|
data/lib/easyai/command/clean.rb
CHANGED
|
@@ -9,18 +9,17 @@ module EasyAI
|
|
|
9
9
|
# 清理 AI CLI 自身在本地产生的缓存与配置目录。
|
|
10
10
|
#
|
|
11
11
|
# 设计要点(详见 docs/设计文档.md §2.6):
|
|
12
|
-
# -
|
|
12
|
+
# - 仅清理两家 AI CLI(claude / codex)的缓存路径
|
|
13
13
|
# - 不会触碰 ~/.easyai/config.json(重置 EasyAI 自身配置请用 easyai setup --reset)
|
|
14
14
|
# - --force 跳过确认;--dry-run 仅打印
|
|
15
15
|
# - 删除时遇到 Errno::EACCES 继续删余下项;最终如有失败项以退出码 1 退出
|
|
16
16
|
class Clean < Command
|
|
17
17
|
self.summary = '清理 AI CLI 自身缓存(不影响 EasyAI 配置)'
|
|
18
18
|
self.description = <<-DESC
|
|
19
|
-
清理 claude /
|
|
19
|
+
清理 claude / codex 各自在本地产生的缓存目录与配置文件。
|
|
20
20
|
|
|
21
21
|
支持范围:
|
|
22
22
|
* claude → ~/.claude、~/.claude.json、~/.config/claude
|
|
23
|
-
* gemini → ~/.gemini、~/.config/gemini
|
|
24
23
|
* codex → ~/.codex、~/.config/codex
|
|
25
24
|
* all → 上述全部
|
|
26
25
|
|
|
@@ -37,12 +36,11 @@ module EasyAI
|
|
|
37
36
|
$ easyai setup --reset
|
|
38
37
|
DESC
|
|
39
38
|
|
|
40
|
-
VALID_TOOLS = %w[claude
|
|
39
|
+
VALID_TOOLS = %w[claude codex all].freeze
|
|
41
40
|
|
|
42
41
|
# AI CLI 自身的缓存路径表(不含 ~/.easyai/config.json)
|
|
43
42
|
CACHE_PATHS = {
|
|
44
43
|
'claude' => %w[~/.claude ~/.claude.json ~/.config/claude],
|
|
45
|
-
'gemini' => %w[~/.gemini ~/.config/gemini],
|
|
46
44
|
'codex' => %w[~/.codex ~/.config/codex]
|
|
47
45
|
}.freeze
|
|
48
46
|
|
data/lib/easyai/command/codex.rb
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
3
5
|
require_relative 'ai_tool_base'
|
|
6
|
+
require_relative 'backup'
|
|
7
|
+
require_relative 'restore'
|
|
8
|
+
require_relative '../base/system_info'
|
|
4
9
|
|
|
5
10
|
module EasyAI
|
|
6
11
|
class Command
|
|
@@ -34,6 +39,231 @@ DESC
|
|
|
34
39
|
def install_hint
|
|
35
40
|
'未找到 codex CLI。请安装:npm install -g @openai/codex'
|
|
36
41
|
end
|
|
42
|
+
|
|
43
|
+
# 部分第三方端点无法只靠环境变量接入 Codex:需要在 <profile>.config.toml
|
|
44
|
+
# 写自定义 model_provider(model / wire_api 等无法用环境变量表达)。
|
|
45
|
+
# · LongCat 用 Responses API(wire_api=responses),文档:https://longcat.chat/platform/docs/zh/Codex.html
|
|
46
|
+
# · DeepSeek:codex 已移除 wire_api="chat"(2026-02-01),只认 responses;故此处也写 responses。
|
|
47
|
+
# 注意:实测 https://api.deepseek.com/v1/responses 返回 404(DeepSeek 仅实现 /chat/completions),
|
|
48
|
+
# 该 profile 能加载但运行时可能 404,待 DeepSeek 支持 Responses API 或在前面加协议转换代理。
|
|
49
|
+
#
|
|
50
|
+
# 实现策略(凭证隔离 + 配置共享):按 base_url 标记匹配 → 给该平台分配独立的
|
|
51
|
+
# CODEX_HOME(~/.easyai/codex-home/<profile>),把 auth.json + profile layer 作为
|
|
52
|
+
# 真实文件写进去,其余(config.toml / prompts / sessions …)软链回 ~/.codex 共享;
|
|
53
|
+
# 再注入 CODEX_HOME + --profile。这样各平台 auth.json 物理隔离——可同时开多个终端跑
|
|
54
|
+
# 不同平台互不覆盖(如 LongCat 与 ChatGPT 官方并行),而用户的全局 config.toml 仍共享。
|
|
55
|
+
# 官方 / ChatGPT Subscribe 不走此路径,继续用默认 ~/.codex。新增端点只需往本表追加一项。
|
|
56
|
+
PROFILE_SPECS = [
|
|
57
|
+
{
|
|
58
|
+
profile: 'longcat',
|
|
59
|
+
marker: 'api.longcat.chat',
|
|
60
|
+
toml: <<~TOML
|
|
61
|
+
model_provider = "codex"
|
|
62
|
+
model = "LongCat-2.0"
|
|
63
|
+
disable_response_storage = true
|
|
64
|
+
web_search = "disabled"
|
|
65
|
+
model_reasoning_effort = "high"
|
|
66
|
+
model_supports_reasoning_summaries = true
|
|
67
|
+
|
|
68
|
+
[model_providers.codex]
|
|
69
|
+
name = "codex"
|
|
70
|
+
base_url = "https://api.longcat.chat/openai/v1"
|
|
71
|
+
wire_api = "responses"
|
|
72
|
+
requires_openai_auth = true
|
|
73
|
+
TOML
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
profile: 'deepseek',
|
|
77
|
+
marker: 'api.deepseek.com',
|
|
78
|
+
toml: <<~TOML
|
|
79
|
+
model_provider = "deepseek"
|
|
80
|
+
model = "deepseek-v4-pro"
|
|
81
|
+
disable_response_storage = true
|
|
82
|
+
web_search = "disabled"
|
|
83
|
+
model_reasoning_effort = "high"
|
|
84
|
+
model_supports_reasoning_summaries = true
|
|
85
|
+
|
|
86
|
+
[model_providers.deepseek]
|
|
87
|
+
name = "deepseek"
|
|
88
|
+
base_url = "https://api.deepseek.com/v1"
|
|
89
|
+
wire_api = "responses"
|
|
90
|
+
requires_openai_auth = true
|
|
91
|
+
TOML
|
|
92
|
+
}
|
|
93
|
+
].freeze
|
|
94
|
+
|
|
95
|
+
# ChatGPT Subscribe 虚拟平台标记(写入虚拟平台 data,runtime 据此还原官方 token)
|
|
96
|
+
CHATGPT_SUBSCRIBE_MARKER = 'chatgpt_subscribe'
|
|
97
|
+
|
|
98
|
+
# 存在含官方 token 的备份时,向 codex 平台选择列表注入「ChatGPT Subscribe」虚拟项。
|
|
99
|
+
# 复用 openai_official 这个 key(PLATFORM_DISPLAY_NAMES 已映射为「ChatGPT Subscribe」),
|
|
100
|
+
# 选中后用备份里的官方 OAuth token 还原 auth.json,再原生启动 codex。
|
|
101
|
+
def extra_platforms
|
|
102
|
+
return {} unless Backup::Codex.new(CLAide::ARGV.new([])).backup_has_official_token?
|
|
103
|
+
|
|
104
|
+
{ 'openai_official' => { 'env' => {}, CHATGPT_SUBSCRIBE_MARKER => true } }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def pre_exec
|
|
108
|
+
# 方案 A:运行前兜底备份 Codex 登录信息(仅当备份缺失时触发),确保可恢复
|
|
109
|
+
Backup::Codex.new(CLAide::ARGV.new([])).backup_if_absent
|
|
110
|
+
|
|
111
|
+
# ChatGPT Subscribe:从备份还原官方 OAuth token 后原生启动,不走 provider profile
|
|
112
|
+
if chatgpt_subscribe_selected?
|
|
113
|
+
restore_chatgpt_subscribe
|
|
114
|
+
return
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# 命中自定义 provider:写独立 profile layer + 注入 --profile,不污染 config.toml
|
|
118
|
+
spec = matched_profile_spec
|
|
119
|
+
apply_profile(spec) if spec
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def chatgpt_subscribe_selected?
|
|
125
|
+
@resolved_config.is_a?(Hash) && @resolved_config[CHATGPT_SUBSCRIBE_MARKER]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# 用备份里的官方 token 还原 auth.json(仅 auth,不动 config.toml)。异常降级为 warning。
|
|
129
|
+
def restore_chatgpt_subscribe
|
|
130
|
+
restored = Restore::Codex.new(CLAide::ARGV.new([])).restore_auth_only
|
|
131
|
+
if restored
|
|
132
|
+
puts ' ✓ 已从备份恢复 ChatGPT 官方登录信息(~/.codex/auth.json)'
|
|
133
|
+
else
|
|
134
|
+
warn ' ⚠ 备份中未找到可恢复的官方登录信息'
|
|
135
|
+
end
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
warn " ⚠ 恢复 ChatGPT 官方登录失败(#{e.message}),继续启动"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# 按当前生效平台的 OPENAI_BASE_URL 匹配 profile 规格;未命中返回 nil
|
|
141
|
+
def matched_profile_spec
|
|
142
|
+
env = @resolved_config.is_a?(Hash) ? @resolved_config['env'] : nil
|
|
143
|
+
base_url = env.is_a?(Hash) ? (env['OPENAI_BASE_URL'] || env['openai_base_url']) : nil
|
|
144
|
+
return nil if base_url.to_s.empty?
|
|
145
|
+
|
|
146
|
+
PROFILE_SPECS.find { |spec| base_url.to_s.include?(spec[:marker]) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# 默认 CODEX_HOME 与隔离 home 根目录
|
|
150
|
+
DEFAULT_CODEX_HOME = '~/.codex'
|
|
151
|
+
ISOLATED_HOME_ROOT = '~/.easyai/codex-home'
|
|
152
|
+
|
|
153
|
+
# 给命中平台分配独立 CODEX_HOME:软链共享 ~/.codex 下的配置 → 写真实 auth.json + profile
|
|
154
|
+
# layer → 注入 CODEX_HOME + --profile。异常降级为 warning,不阻塞 exec。
|
|
155
|
+
#
|
|
156
|
+
# 前置校验:这些第三方端点必须用 OPENAI_API_KEY;缺 key 则整体跳过隔离(不注入 CODEX_HOME /
|
|
157
|
+
# --profile),避免"隔离目录没有 auth.json 却打印成功、codex 启动找不到凭证"的误导态。
|
|
158
|
+
def apply_profile(spec)
|
|
159
|
+
key = platform_api_key
|
|
160
|
+
if key.to_s.empty?
|
|
161
|
+
warn " ⚠ 平台缺少 OPENAI_API_KEY,跳过 CODEX_HOME 隔离,按默认 ~/.codex 启动"
|
|
162
|
+
return
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
home = isolated_home_for(spec[:profile])
|
|
166
|
+
link_shared_home(File.expand_path(DEFAULT_CODEX_HOME), home)
|
|
167
|
+
write_layer(spec, home)
|
|
168
|
+
write_clean_auth_json(home, key)
|
|
169
|
+
inject_codex_home(home)
|
|
170
|
+
ensure_profile_arg(spec[:profile])
|
|
171
|
+
puts " ✓ 使用隔离的 CODEX_HOME:#{home}(auth.json 独立,config.toml 等已链接共享)"
|
|
172
|
+
rescue StandardError => e
|
|
173
|
+
warn " ⚠ 应用 Codex provider profile(#{spec[:profile]})失败(#{e.message}),继续启动"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def platform_api_key
|
|
177
|
+
return nil unless @resolved_config.is_a?(Hash)
|
|
178
|
+
|
|
179
|
+
@resolved_config.dig('env', 'OPENAI_API_KEY')
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def isolated_home_for(profile)
|
|
183
|
+
File.expand_path("#{ISOLATED_HOME_ROOT}/#{profile}")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# 隔离目录内必须"各自独立"的真实文件名:不参与软链共享(否则会写穿污染 ~/.codex)
|
|
187
|
+
def isolation_names
|
|
188
|
+
['auth.json', *PROFILE_SPECS.map { |s| "#{s[:profile]}.config.toml" }]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# 把 src(~/.codex) 下除隔离文件外的条目,逐个共享进隔离 home(config.toml / prompts /
|
|
192
|
+
# sessions … 共享)。dest 由本工具独占,重复运行幂等;src 不存在则仅建目录。
|
|
193
|
+
#
|
|
194
|
+
# 跨平台策略:非 Windows 用软链(运行时实时共享,codex 写 [projects.*] 授信能回写 ~/.codex);
|
|
195
|
+
# Windows 上 File.symlink 常因缺 SeCreateSymbolicLinkPrivilege 抛错,退而用复制——每次启动
|
|
196
|
+
# 重跑本方法幂等覆盖,等于"启动时刻同步"共享配置,规避软链权限限制。
|
|
197
|
+
def link_shared_home(src, dest)
|
|
198
|
+
FileUtils.mkdir_p(dest)
|
|
199
|
+
File.chmod(0o700, dest) unless Base::SystemInfo.windows?
|
|
200
|
+
return unless File.directory?(src)
|
|
201
|
+
|
|
202
|
+
Dir.each_child(src) do |name|
|
|
203
|
+
next if isolation_names.include?(name)
|
|
204
|
+
|
|
205
|
+
share_entry(File.join(dest, name), File.join(src, name))
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def share_entry(dest_path, src_path)
|
|
210
|
+
if Base::SystemInfo.windows?
|
|
211
|
+
refresh_copy(dest_path, src_path)
|
|
212
|
+
else
|
|
213
|
+
refresh_symlink(dest_path, src_path)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# 幂等地把 link_path 指向 target。已是正确软链则跳过;旧软链先删;已是真实文件则不动。
|
|
218
|
+
def refresh_symlink(link_path, target)
|
|
219
|
+
if File.symlink?(link_path)
|
|
220
|
+
return if File.readlink(link_path) == target
|
|
221
|
+
|
|
222
|
+
File.delete(link_path)
|
|
223
|
+
elsif File.exist?(link_path)
|
|
224
|
+
return
|
|
225
|
+
end
|
|
226
|
+
File.symlink(target, link_path)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Windows 复制共享:整体覆盖复制(dest 由本工具独占,天然幂等)。src 为目录则递归复制。
|
|
230
|
+
def refresh_copy(dest_path, src_path)
|
|
231
|
+
FileUtils.rm_rf(dest_path)
|
|
232
|
+
FileUtils.cp_r(src_path, dest_path)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# 把 CODEX_HOME 追加到本次子进程 env(@env 由基类 run 赋值,exec 时透传给子进程)。
|
|
236
|
+
# 只改 @env、不碰全局 ENV:exec 已显式传 @env,污染当前进程 ENV 既多余又会泄漏到测试。
|
|
237
|
+
def inject_codex_home(home)
|
|
238
|
+
@env['CODEX_HOME'] = home if @env.is_a?(Hash)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def write_layer(spec, home)
|
|
242
|
+
path = File.join(home, "#{spec[:profile]}.config.toml")
|
|
243
|
+
write_real_file(path, spec[:toml])
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# 写入纯净的 auth.json:只保留 OPENAI_API_KEY,清除 auth_mode / tokens / last_refresh
|
|
247
|
+
# 等 ChatGPT OAuth 残留,避免 Codex 优先走 OAuth 而忽略 API Key(这些端点必须用 API Key)。
|
|
248
|
+
# 整文件覆盖,写进隔离 home,本工具独占写主权,无需深度 merge。key 由 apply_profile 预校验非空。
|
|
249
|
+
def write_clean_auth_json(home, key)
|
|
250
|
+
write_real_file(File.join(home, 'auth.json'), JSON.pretty_generate({ 'OPENAI_API_KEY' => key }))
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# 写真实文件到隔离 home:若同名位置残留软链先删(防止写穿到共享 ~/.codex),再整文件覆盖 + 600
|
|
254
|
+
def write_real_file(path, content)
|
|
255
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
256
|
+
File.delete(path) if File.symlink?(path)
|
|
257
|
+
File.write(path, content)
|
|
258
|
+
File.chmod(0o600, path) unless Base::SystemInfo.windows?
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# 注入 --profile <profile>;用户已显式指定 profile(--profile[=x] / -p)时不覆盖
|
|
262
|
+
def ensure_profile_arg(profile)
|
|
263
|
+
return if @passthrough_args.any? { |a| a == '--profile' || a == '-p' || a.start_with?('--profile=') }
|
|
264
|
+
|
|
265
|
+
@passthrough_args.unshift('--profile', profile)
|
|
266
|
+
end
|
|
37
267
|
end
|
|
38
268
|
end
|
|
39
269
|
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
require_relative '../../base/toml_sections'
|
|
4
|
+
require_relative '../../base/system_info'
|
|
5
|
+
|
|
6
|
+
module EasyAI
|
|
7
|
+
class Command
|
|
8
|
+
class Restore
|
|
9
|
+
class Codex < Restore
|
|
10
|
+
self.summary = '恢复 Codex 登录信息'
|
|
11
|
+
self.description = <<-DESC
|
|
12
|
+
把 easyai backup codex 生成的 ~/.easyai/backup/.codex.json 合并回 ~/.codex/。
|
|
13
|
+
|
|
14
|
+
特点:
|
|
15
|
+
|
|
16
|
+
* auth 字段级深度 merge:备份字段覆盖/新增,目标独有字段保留
|
|
17
|
+
|
|
18
|
+
* config.toml 段级 merge:备份段(不含 [projects.*])覆盖/新增回目标,
|
|
19
|
+
目标本地的 [projects.*] 授信段及其他独有段原样保留
|
|
20
|
+
|
|
21
|
+
* 安全权限:恢复后的文件仍 chmod 600
|
|
22
|
+
|
|
23
|
+
使用示例:
|
|
24
|
+
|
|
25
|
+
$ easyai restore codex
|
|
26
|
+
DESC
|
|
27
|
+
|
|
28
|
+
AUTH_KEY = 'auth'.freeze
|
|
29
|
+
CONFIG_KEY = 'config_toml'.freeze
|
|
30
|
+
|
|
31
|
+
# 注意:路径用方法动态 expand_path,避免常量在加载期冻结真实 HOME
|
|
32
|
+
def auth_target = File.expand_path('~/.codex/auth.json')
|
|
33
|
+
def config_target = File.expand_path('~/.codex/config.toml')
|
|
34
|
+
def codex_dir = File.expand_path('~/.codex')
|
|
35
|
+
def backup_path = File.expand_path('~/.easyai/backup/.codex.json')
|
|
36
|
+
|
|
37
|
+
def validate!
|
|
38
|
+
super
|
|
39
|
+
help! "备份文件不存在: #{backup_path}\n请先运行 easyai backup codex 创建备份。" unless File.exist?(backup_path)
|
|
40
|
+
ensure_file_credentials_supported!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def run
|
|
44
|
+
FileUtils.mkdir_p(codex_dir)
|
|
45
|
+
backup = read_json(backup_path)
|
|
46
|
+
ensure_file_credentials_supported!(backup)
|
|
47
|
+
|
|
48
|
+
auth_summary = restore_auth(backup[AUTH_KEY])
|
|
49
|
+
config_summary = restore_config(backup[CONFIG_KEY])
|
|
50
|
+
|
|
51
|
+
report(auth_summary, config_summary)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# 仅还原 auth.json(供 easyai codex 选择 ChatGPT Subscribe 时取回官方 token),
|
|
55
|
+
# 不触碰 config.toml。返回是否实际还原。异常由调用方处理。
|
|
56
|
+
def restore_auth_only
|
|
57
|
+
return false unless File.exist?(backup_path)
|
|
58
|
+
|
|
59
|
+
auth = read_json(backup_path)[AUTH_KEY]
|
|
60
|
+
return false unless auth.is_a?(Hash) && !auth.empty?
|
|
61
|
+
|
|
62
|
+
FileUtils.mkdir_p(codex_dir)
|
|
63
|
+
restore_auth(auth)
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def ensure_file_credentials_supported!(backup = nil)
|
|
70
|
+
backup ||= read_json(backup_path)
|
|
71
|
+
if keyring_credentials_store?(backup[CONFIG_KEY])
|
|
72
|
+
help! <<~MSG
|
|
73
|
+
备份中的 Codex 配置 cli_auth_credentials_store = "keyring",凭证原本存储在系统钥匙串中。
|
|
74
|
+
easyai restore codex 目前只支持 file/auth.json 凭证还原,无法自动恢复 keyring 凭证。
|
|
75
|
+
MSG
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
return unless File.exist?(config_target)
|
|
79
|
+
return unless keyring_credentials_store?(File.read(config_target))
|
|
80
|
+
|
|
81
|
+
help! <<~MSG
|
|
82
|
+
当前 Codex 配置 cli_auth_credentials_store = "keyring",Codex 将从系统钥匙串读取凭证。
|
|
83
|
+
easyai restore codex 目前只支持 file/auth.json 凭证还原,请先改用 file 存储或手动恢复系统钥匙串凭证。
|
|
84
|
+
MSG
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def keyring_credentials_store?(toml_text)
|
|
88
|
+
toml_text.to_s.each_line.any? do |line|
|
|
89
|
+
line.match?(/^\s*cli_auth_credentials_store\s*=\s*["']keyring["']\s*(?:#.*)?$/)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def restore_auth(source)
|
|
94
|
+
source = {} unless source.is_a?(Hash)
|
|
95
|
+
target = File.exist?(auth_target) ? read_json(auth_target) : {}
|
|
96
|
+
before = target.keys
|
|
97
|
+
merged = deep_merge(target, source)
|
|
98
|
+
|
|
99
|
+
# 冲突消解:OAuth 模式(有 tokens)与 API Key 模式互斥。
|
|
100
|
+
# 备份中含 tokens 说明当时走的是 ChatGPT OAuth,恢复时必须清除 OPENAI_API_KEY,
|
|
101
|
+
# 否则 Codex 可能优先用 API Key 请求 OpenAI 官方 API 而忽略 OAuth token。
|
|
102
|
+
if merged.is_a?(Hash) && merged.key?('tokens')
|
|
103
|
+
merged.delete('OPENAI_API_KEY')
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
File.write(auth_target, JSON.pretty_generate(merged))
|
|
107
|
+
File.chmod(0o600, auth_target) unless windows?
|
|
108
|
+
|
|
109
|
+
{
|
|
110
|
+
added: (source.keys - before).size,
|
|
111
|
+
updated: (source.keys & before).size,
|
|
112
|
+
kept: (before - source.keys).size,
|
|
113
|
+
total: merged.keys.size
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def restore_config(backup_text)
|
|
118
|
+
return nil unless backup_text.is_a?(String) && !backup_text.empty?
|
|
119
|
+
|
|
120
|
+
target_text = File.exist?(config_target) ? File.read(config_target) : ''
|
|
121
|
+
kept_projects = Base::TomlSections.split_sections(target_text)
|
|
122
|
+
.count { |s| Base::TomlSections.project_header?(s.header) }
|
|
123
|
+
# base=目标(保留其 projects 等独有段),override=备份(非 projects 段覆盖)
|
|
124
|
+
merged = Base::TomlSections.merge(target_text, backup_text)
|
|
125
|
+
|
|
126
|
+
File.write(config_target, merged)
|
|
127
|
+
File.chmod(0o600, config_target) unless windows?
|
|
128
|
+
|
|
129
|
+
{ kept_projects: kept_projects }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def read_json(path)
|
|
133
|
+
JSON.parse(File.read(path))
|
|
134
|
+
rescue JSON::ParserError => e
|
|
135
|
+
raise "解析 JSON 失败 (#{path}): #{e.message}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def deep_merge(target, source)
|
|
139
|
+
target.merge(source) do |_key, old_val, new_val|
|
|
140
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
141
|
+
deep_merge(old_val, new_val)
|
|
142
|
+
else
|
|
143
|
+
new_val
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def report(auth, config)
|
|
149
|
+
puts "✓ Codex 登录信息已恢复"
|
|
150
|
+
puts " 目标目录: #{codex_dir}"
|
|
151
|
+
puts " auth: 新增 #{auth[:added]},更新 #{auth[:updated]},保留目标独有 #{auth[:kept]}(合计 #{auth[:total]})"
|
|
152
|
+
if config
|
|
153
|
+
puts " config.toml: 已恢复(保留本地 #{config[:kept_projects]} 个 [projects.*] 授信段)"
|
|
154
|
+
else
|
|
155
|
+
puts " config.toml: 备份中不含,跳过"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def windows?
|
|
160
|
+
Base::SystemInfo.windows?
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -9,10 +9,12 @@ module EasyAI
|
|
|
9
9
|
可用命令:
|
|
10
10
|
|
|
11
11
|
* claude - 恢复 Claude Code 登录信息
|
|
12
|
+
* codex - 恢复 Codex 登录信息
|
|
12
13
|
|
|
13
14
|
使用示例:
|
|
14
15
|
|
|
15
16
|
$ easyai restore claude # 把 ~/.easyai/backup/.claude.json 深度 merge 回 ~/.claude.json
|
|
17
|
+
$ easyai restore codex # 把 ~/.easyai/backup/.codex.json 合并回 ~/.codex/(保留本地 [projects.*])
|
|
16
18
|
DESC
|
|
17
19
|
|
|
18
20
|
self.abstract_command = true
|
|
@@ -22,3 +24,4 @@ end
|
|
|
22
24
|
|
|
23
25
|
# 加载子命令
|
|
24
26
|
require_relative 'restore/claude'
|
|
27
|
+
require_relative 'restore/codex'
|
data/lib/easyai/command/setup.rb
CHANGED
|
@@ -6,6 +6,7 @@ require 'colored2'
|
|
|
6
6
|
require_relative '../command'
|
|
7
7
|
require_relative '../config/local_config'
|
|
8
8
|
require_relative '../base/secret_masker'
|
|
9
|
+
require_relative 'backup'
|
|
9
10
|
|
|
10
11
|
module EasyAI
|
|
11
12
|
class Command
|
|
@@ -100,34 +101,41 @@ DESC
|
|
|
100
101
|
'ANTHROPIC_DEFAULT_SONNET_MODEL' => 'glm-5-turbo',
|
|
101
102
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL' => 'glm-4.5-air'
|
|
102
103
|
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
104
|
+
},
|
|
105
|
+
'longcat' => {
|
|
106
|
+
label: 'LongCat(美团,兼容 Anthropic 协议)',
|
|
107
|
+
required_env: %w[ANTHROPIC_AUTH_TOKEN],
|
|
108
|
+
fixed_env: {
|
|
109
|
+
'ANTHROPIC_BASE_URL' => 'https://api.longcat.chat/anthropic',
|
|
110
|
+
'ANTHROPIC_MODEL' => 'LongCat-2.0',
|
|
111
|
+
'ANTHROPIC_SMALL_FAST_MODEL' => 'LongCat-2.0',
|
|
112
|
+
'ANTHROPIC_DEFAULT_SONNET_MODEL' => 'LongCat-2.0',
|
|
113
|
+
'ANTHROPIC_DEFAULT_OPUS_MODEL' => 'LongCat-2.0',
|
|
114
|
+
'CLAUDE_CODE_MAX_OUTPUT_TOKENS' => '131072',
|
|
115
|
+
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' => '1'
|
|
116
|
+
}
|
|
110
117
|
}
|
|
111
118
|
},
|
|
112
119
|
'codex' => {
|
|
113
120
|
'openai_official' => {
|
|
114
|
-
label: '
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
},
|
|
120
|
-
'azure_openai' => {
|
|
121
|
-
label: 'Azure OpenAI',
|
|
122
|
-
required_env: %w[AZURE_OPENAI_API_KEY AZURE_OPENAI_ENDPOINT],
|
|
123
|
-
optional_env: {}
|
|
121
|
+
label: 'ChatGPT Subscribe(使用 ChatGPT 订阅账户授权信息)',
|
|
122
|
+
# 不录入 API Key:直接复用 ~/.codex 的 ChatGPT OAuth 登录态,
|
|
123
|
+
# 配置动作 = 等同 easyai backup codex(仅当存在官方 token 时更新备份)
|
|
124
|
+
chatgpt_subscribe: true
|
|
124
125
|
},
|
|
125
126
|
'deepseek' => {
|
|
126
|
-
label: 'DeepSeek(兼容 OpenAI
|
|
127
|
+
label: 'DeepSeek(兼容 OpenAI 协议,codex 自动写 responses provider profile)',
|
|
127
128
|
required_env: %w[OPENAI_API_KEY],
|
|
128
|
-
|
|
129
|
+
fixed_env: {
|
|
129
130
|
'OPENAI_BASE_URL' => 'https://api.deepseek.com/v1'
|
|
130
131
|
}
|
|
132
|
+
},
|
|
133
|
+
'longcat' => {
|
|
134
|
+
label: 'LongCat(美团,兼容 OpenAI 协议)',
|
|
135
|
+
required_env: %w[OPENAI_API_KEY],
|
|
136
|
+
fixed_env: {
|
|
137
|
+
'OPENAI_BASE_URL' => 'https://api.longcat.chat/openai/v1'
|
|
138
|
+
}
|
|
131
139
|
}
|
|
132
140
|
}
|
|
133
141
|
}.freeze
|
|
@@ -137,7 +145,7 @@ DESC
|
|
|
137
145
|
|
|
138
146
|
def self.options
|
|
139
147
|
[
|
|
140
|
-
['--tool=NAME', '指定要配置的工具(claude /
|
|
148
|
+
['--tool=NAME', '指定要配置的工具(claude / codex)'],
|
|
141
149
|
['--add=PLATFORM', '在指定工具下追加或覆盖单个平台'],
|
|
142
150
|
['--remove=PLATFORM', '在指定工具下删除单个平台'],
|
|
143
151
|
['--list', '脱敏方式展示当前所有配置'],
|
|
@@ -192,7 +200,7 @@ DESC
|
|
|
192
200
|
puts "schema 版本:#{cfg['version']}"
|
|
193
201
|
|
|
194
202
|
printed_any = false
|
|
195
|
-
%w[claude
|
|
203
|
+
%w[claude codex].each do |tool|
|
|
196
204
|
tool_cfg = cfg[tool]
|
|
197
205
|
next unless tool_cfg.is_a?(Hash)
|
|
198
206
|
|
|
@@ -211,7 +219,7 @@ DESC
|
|
|
211
219
|
# 列出未列入三家但用户自定义的工具键
|
|
212
220
|
cfg.each do |key, value|
|
|
213
221
|
next if key == 'version'
|
|
214
|
-
next if %w[claude
|
|
222
|
+
next if %w[claude codex].include?(key)
|
|
215
223
|
next unless value.is_a?(Hash) && value['platforms'].is_a?(Hash)
|
|
216
224
|
|
|
217
225
|
printed_any = true
|
|
@@ -307,11 +315,17 @@ DESC
|
|
|
307
315
|
|
|
308
316
|
unless tool_known.key?(@add)
|
|
309
317
|
print_error("未知平台:#{@add}(不在 KNOWN_PLATFORMS[#{@tool}] 中)")
|
|
310
|
-
puts " 可用平台:#{
|
|
318
|
+
puts " 可用平台:#{known_platform_keys(@tool).join(', ')}"
|
|
311
319
|
puts ' 提示:如确需新增自定义平台,可使用 --edit 手工编辑 config.json'
|
|
312
320
|
exit 1
|
|
313
321
|
end
|
|
314
322
|
|
|
323
|
+
if Config::LocalConfig.disabled_platform?(@tool, @add)
|
|
324
|
+
print_error("平台 #{@add} 已被屏蔽(暂不可用)")
|
|
325
|
+
puts " 可用平台:#{known_platform_keys(@tool).join(', ')}"
|
|
326
|
+
exit 1
|
|
327
|
+
end
|
|
328
|
+
|
|
315
329
|
existing = Config::LocalConfig.available_platforms(@tool).include?(@add)
|
|
316
330
|
if existing
|
|
317
331
|
print_status('覆盖', "#{@tool} / #{@add}(已存在,将覆盖原有数据)")
|
|
@@ -392,7 +406,7 @@ DESC
|
|
|
392
406
|
tools.each do |tool|
|
|
393
407
|
puts
|
|
394
408
|
puts "[#{tool}]".green.bold
|
|
395
|
-
available =
|
|
409
|
+
available = known_platform_keys(tool)
|
|
396
410
|
chosen = select_multi(
|
|
397
411
|
" 请选择 #{tool} 下要配置的平台(逗号分隔多个序号,回车默认全部)",
|
|
398
412
|
available, allow_all_default: true
|
|
@@ -426,7 +440,7 @@ DESC
|
|
|
426
440
|
end
|
|
427
441
|
|
|
428
442
|
tools.each do |tool|
|
|
429
|
-
available_known =
|
|
443
|
+
available_known = known_platform_keys(tool)
|
|
430
444
|
puts
|
|
431
445
|
puts "[#{tool}]".green.bold
|
|
432
446
|
chosen = select_multi(
|
|
@@ -487,15 +501,27 @@ DESC
|
|
|
487
501
|
end
|
|
488
502
|
end
|
|
489
503
|
|
|
504
|
+
# 返回某工具下「未被屏蔽」的内置平台模板 key(屏蔽清单见 LocalConfig::DISABLED_PLATFORMS)
|
|
505
|
+
def known_platform_keys(tool)
|
|
506
|
+
KNOWN_PLATFORMS[tool].keys.reject { |p| Config::LocalConfig.disabled_platform?(tool, p) }
|
|
507
|
+
end
|
|
508
|
+
|
|
490
509
|
# 收集单个平台的 env / proxy 数据
|
|
491
510
|
def collect_platform_data(tool, platform)
|
|
492
511
|
spec = KNOWN_PLATFORMS.dig(tool, platform) || {}
|
|
512
|
+
return collect_chatgpt_subscribe_data if spec[:chatgpt_subscribe]
|
|
513
|
+
|
|
493
514
|
env = {}
|
|
494
515
|
|
|
495
516
|
Array(spec[:required_env]).each do |key|
|
|
496
517
|
env[key] = ask_required(key)
|
|
497
518
|
end
|
|
498
519
|
|
|
520
|
+
(spec[:fixed_env] || {}).each do |key, value|
|
|
521
|
+
env[key] = value.to_s
|
|
522
|
+
print_status('默认', "#{key} = #{value}")
|
|
523
|
+
end
|
|
524
|
+
|
|
499
525
|
(spec[:optional_env] || {}).each do |key, default|
|
|
500
526
|
env[key] = ask_optional(key, default)
|
|
501
527
|
end
|
|
@@ -507,6 +533,20 @@ DESC
|
|
|
507
533
|
data
|
|
508
534
|
end
|
|
509
535
|
|
|
536
|
+
# ChatGPT Subscribe:不录入 API Key,直接复用 ~/.codex 的 ChatGPT OAuth 登录态。
|
|
537
|
+
# 配置动作等同 easyai backup codex —— 仅当 auth.json 存在官方 token 时才更新备份。
|
|
538
|
+
# 平台数据本身为空 env(runtime 直接用原生 auth.json 启动 codex)。
|
|
539
|
+
def collect_chatgpt_subscribe_data
|
|
540
|
+
backup = Command::Backup::Codex.new(CLAide::ARGV.new([]))
|
|
541
|
+
if backup.backup_if_official
|
|
542
|
+
print_success('已备份当前 ChatGPT 官方登录信息(~/.codex/auth.json → ~/.easyai/backup/.codex.json)')
|
|
543
|
+
else
|
|
544
|
+
print_warning('未检测到 ChatGPT 官方登录 token,已跳过备份。请先运行 codex 完成 ChatGPT 登录后再配置。')
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
{ 'env' => {} }
|
|
548
|
+
end
|
|
549
|
+
|
|
510
550
|
def ask_required(key)
|
|
511
551
|
loop do
|
|
512
552
|
if sensitive_key?(key)
|
|
@@ -62,13 +62,27 @@ module EasyAI
|
|
|
62
62
|
'deepseek' => 'DeepSeek',
|
|
63
63
|
'glm' => 'GLM(Coding Plan)',
|
|
64
64
|
'kimi' => 'Kimi',
|
|
65
|
-
'
|
|
65
|
+
'longcat' => 'LongCat(美团)',
|
|
66
|
+
'minimax' => 'MiniMax',
|
|
67
|
+
'openai_official' => 'ChatGPT Subscribe'
|
|
66
68
|
}.freeze
|
|
67
69
|
|
|
68
70
|
def platform_display_name(key)
|
|
69
71
|
PLATFORM_DISPLAY_NAMES.fetch(key, key)
|
|
70
72
|
end
|
|
71
73
|
|
|
74
|
+
# 已屏蔽的平台:保留模板与 config 数据,仅暂不对外提供(setup 不列出、runtime 不可选)。
|
|
75
|
+
# codex/deepseek:新版 codex 移除了 wire_api="chat",而 DeepSeek 暂无 Responses API
|
|
76
|
+
# (/responses 实测 404),接入即失败,故先屏蔽。待 DeepSeek 支持 Responses 或接入协议
|
|
77
|
+
# 转换代理后,从本表删除该项即可恢复(profile 模板与 KNOWN_PLATFORMS 条目均未删除)。
|
|
78
|
+
DISABLED_PLATFORMS = {
|
|
79
|
+
'codex' => %w[deepseek]
|
|
80
|
+
}.freeze
|
|
81
|
+
|
|
82
|
+
def disabled_platform?(tool, platform)
|
|
83
|
+
Array(DISABLED_PLATFORMS[tool]).include?(platform)
|
|
84
|
+
end
|
|
85
|
+
|
|
72
86
|
def available_platforms(tool)
|
|
73
87
|
return [] unless exists?
|
|
74
88
|
|
|
@@ -81,16 +95,31 @@ module EasyAI
|
|
|
81
95
|
|
|
82
96
|
# 解析平台配置。选定平台后会 yield 平台名(如果给了 block),
|
|
83
97
|
# 让上层(AIToolBase)负责打印 UI 提示,保持 LocalConfig 的输出最小化。
|
|
84
|
-
|
|
98
|
+
# extra:调用方注入的虚拟平台 { key => data }(如 codex 的 ChatGPT Subscribe),
|
|
99
|
+
# 与 config.json 中真实平台合并参与选择;选中虚拟平台时返回其 data。
|
|
100
|
+
def resolve_platform(tool:, platform: nil, verbose: false, extra: {})
|
|
85
101
|
cfg = load
|
|
86
102
|
tool_cfg = cfg[tool]
|
|
87
103
|
platforms = tool_cfg.is_a?(Hash) ? tool_cfg['platforms'] : nil
|
|
88
|
-
|
|
104
|
+
platforms = {} unless platforms.is_a?(Hash)
|
|
105
|
+
extra = {} unless extra.is_a?(Hash)
|
|
106
|
+
|
|
107
|
+
# 显式指定了已屏蔽平台:直接拒绝(extra 虚拟平台不受屏蔽影响)
|
|
108
|
+
if platform && !platform.empty? && disabled_platform?(tool, platform) && !extra.key?(platform)
|
|
109
|
+
raise PlatformNotFoundError, "工具 #{tool} 的平台 #{platform} 已被屏蔽(暂不可用)"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# 屏蔽清单中的真实平台不参与选择(保留 config 数据,仅隐藏)
|
|
113
|
+
visible = platforms.reject { |name, _| disabled_platform?(tool, name) }
|
|
114
|
+
# 数据层:extra(虚拟平台,如 ChatGPT Subscribe)覆盖同名真实平台,保留其 marker
|
|
115
|
+
merged = visible.merge(extra)
|
|
116
|
+
if merged.empty?
|
|
89
117
|
raise ToolNotConfiguredError,
|
|
90
118
|
"工具 #{tool} 未配置任何平台,请运行 easyai setup --tool=#{tool}"
|
|
91
119
|
end
|
|
92
120
|
|
|
93
|
-
|
|
121
|
+
# 顺序层:extra 虚拟平台始终排在选择列表最前(如【ChatGPT Subscribe】恒为第一项)
|
|
122
|
+
available = extra.keys + (visible.keys - extra.keys)
|
|
94
123
|
selected =
|
|
95
124
|
if platform && !platform.empty?
|
|
96
125
|
unless available.include?(platform)
|
|
@@ -107,8 +136,8 @@ module EasyAI
|
|
|
107
136
|
interactive_select(tool, available)
|
|
108
137
|
end
|
|
109
138
|
|
|
110
|
-
yield(platform_display_name(selected)) if block_given?
|
|
111
|
-
|
|
139
|
+
yield(platform_display_name(selected), selected) if block_given?
|
|
140
|
+
merged[selected]
|
|
112
141
|
end
|
|
113
142
|
|
|
114
143
|
def upsert_platform(tool, platform, data)
|
data/lib/easyai/version.rb
CHANGED
data/lib/easyai.rb
CHANGED
|
@@ -3,7 +3,6 @@ require 'easyai/config/local_config'
|
|
|
3
3
|
require 'easyai/command'
|
|
4
4
|
require 'easyai/command/ai_tool_base'
|
|
5
5
|
require 'easyai/command/claude'
|
|
6
|
-
require 'easyai/command/gemini'
|
|
7
6
|
require 'easyai/command/codex'
|
|
8
7
|
require 'easyai/command/utils'
|
|
9
8
|
require 'easyai/command/clean'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: easyai
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Wade
|
|
@@ -114,18 +114,20 @@ files:
|
|
|
114
114
|
- lib/easyai/base/file_crypto.rb
|
|
115
115
|
- lib/easyai/base/secret_masker.rb
|
|
116
116
|
- lib/easyai/base/system_info.rb
|
|
117
|
+
- lib/easyai/base/toml_sections.rb
|
|
117
118
|
- lib/easyai/base/update_notifier.rb
|
|
118
119
|
- lib/easyai/base/version_checker.rb
|
|
119
120
|
- lib/easyai/command.rb
|
|
120
121
|
- lib/easyai/command/ai_tool_base.rb
|
|
121
122
|
- lib/easyai/command/backup.rb
|
|
122
123
|
- lib/easyai/command/backup/claude.rb
|
|
124
|
+
- lib/easyai/command/backup/codex.rb
|
|
123
125
|
- lib/easyai/command/claude.rb
|
|
124
126
|
- lib/easyai/command/clean.rb
|
|
125
127
|
- lib/easyai/command/codex.rb
|
|
126
|
-
- lib/easyai/command/gemini.rb
|
|
127
128
|
- lib/easyai/command/restore.rb
|
|
128
129
|
- lib/easyai/command/restore/claude.rb
|
|
130
|
+
- lib/easyai/command/restore/codex.rb
|
|
129
131
|
- lib/easyai/command/setup.rb
|
|
130
132
|
- lib/easyai/command/update.rb
|
|
131
133
|
- lib/easyai/command/utils.rb
|
|
@@ -151,7 +153,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
151
153
|
- !ruby/object:Gem::Version
|
|
152
154
|
version: '0'
|
|
153
155
|
requirements: []
|
|
154
|
-
rubygems_version:
|
|
156
|
+
rubygems_version: 4.0.11
|
|
155
157
|
specification_version: 4
|
|
156
158
|
summary: Claude / Gemini / Codex 三家 AI CLI 的本地化统一入口
|
|
157
159
|
test_files: []
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative 'ai_tool_base'
|
|
4
|
-
|
|
5
|
-
module EasyAI
|
|
6
|
-
class Command
|
|
7
|
-
class Gemini < AIToolBase
|
|
8
|
-
self.summary = '启动 Gemini 命令行(多平台支持)'
|
|
9
|
-
self.description = <<-DESC
|
|
10
|
-
启动 Google Gemini CLI,从 ~/.easyai/config.json 读取平台配置,并将凭证 / 代理仅注入子进程。
|
|
11
|
-
|
|
12
|
-
使用示例:
|
|
13
|
-
|
|
14
|
-
$ easyai gemini # 多平台时进入交互选择
|
|
15
|
-
$ easyai gemini --platform=google_official
|
|
16
|
-
$ easyai gemini ./adhoc.json # 用一次性 JSON 覆盖(单平台扁平 schema)
|
|
17
|
-
$ easyai gemini -- --help # 透传参数给 gemini CLI
|
|
18
|
-
DESC
|
|
19
|
-
|
|
20
|
-
self.arguments = [
|
|
21
|
-
CLAide::Argument.new('CONFIG.json', false),
|
|
22
|
-
CLAide::Argument.new('ARGS', false, true)
|
|
23
|
-
]
|
|
24
|
-
|
|
25
|
-
def tool_name
|
|
26
|
-
'gemini'
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def exec_command
|
|
30
|
-
'gemini'
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def install_hint
|
|
34
|
-
'未找到 gemini CLI。请安装:npm install -g @google/gemini-cli'
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|