easyai 2.0.0 → 2.2.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 +77 -56
- data/lib/easyai/config/local_config.rb +35 -6
- data/lib/easyai/version.rb +1 -1
- data/lib/easyai.rb +0 -1
- data//344/275/277/347/224/250/350/257/264/346/230/216.md +117 -0
- metadata +6 -3
- data/lib/easyai/command/gemini.rb +0 -38
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
|
|
@@ -41,21 +42,21 @@ DESC
|
|
|
41
42
|
'claude_official' => {
|
|
42
43
|
label: 'Claude 官方',
|
|
43
44
|
required_env: %w[ANTHROPIC_AUTH_TOKEN],
|
|
44
|
-
|
|
45
|
+
fixed_env: {
|
|
45
46
|
'ANTHROPIC_BASE_URL' => 'https://api.anthropic.com'
|
|
46
47
|
}
|
|
47
48
|
},
|
|
48
49
|
'kimi' => {
|
|
49
50
|
label: 'Kimi(Moonshot)',
|
|
50
51
|
required_env: %w[ANTHROPIC_AUTH_TOKEN],
|
|
51
|
-
|
|
52
|
+
fixed_env: {
|
|
52
53
|
'ANTHROPIC_BASE_URL' => 'https://api.kimi.com/coding/'
|
|
53
54
|
}
|
|
54
55
|
},
|
|
55
56
|
'deepseek' => {
|
|
56
57
|
label: 'DeepSeek',
|
|
57
58
|
required_env: %w[ANTHROPIC_AUTH_TOKEN],
|
|
58
|
-
|
|
59
|
+
fixed_env: {
|
|
59
60
|
'ANTHROPIC_BASE_URL' => 'https://api.deepseek.com/anthropic',
|
|
60
61
|
'ANTHROPIC_MODEL' => 'deepseek-v4-pro[1m]',
|
|
61
62
|
'ANTHROPIC_DEFAULT_OPUS_MODEL' => 'deepseek-v4-pro[1m]',
|
|
@@ -68,7 +69,7 @@ DESC
|
|
|
68
69
|
'aliqwen' => {
|
|
69
70
|
label: '阿里千问 Coding Plan(兼容 Anthropic 协议)',
|
|
70
71
|
required_env: %w[ANTHROPIC_AUTH_TOKEN],
|
|
71
|
-
|
|
72
|
+
fixed_env: {
|
|
72
73
|
'ANTHROPIC_BASE_URL' => 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
|
|
73
74
|
'ANTHROPIC_MODEL' => 'qwen3.6-plus',
|
|
74
75
|
'ANTHROPIC_DEFAULT_OPUS_MODEL' => 'qwen3.6-plus',
|
|
@@ -82,7 +83,7 @@ DESC
|
|
|
82
83
|
'minimax' => {
|
|
83
84
|
label: 'MiniMax(兼容 Anthropic 协议)',
|
|
84
85
|
required_env: %w[ANTHROPIC_AUTH_TOKEN],
|
|
85
|
-
|
|
86
|
+
fixed_env: {
|
|
86
87
|
'ANTHROPIC_BASE_URL' => 'https://api.minimaxi.com/anthropic',
|
|
87
88
|
'ANTHROPIC_MODEL' => 'MiniMax-M2.7-highspeed',
|
|
88
89
|
'ANTHROPIC_DEFAULT_SONNET_MODEL' => 'MiniMax-M2.7-highspeed',
|
|
@@ -93,41 +94,49 @@ DESC
|
|
|
93
94
|
'glm' => {
|
|
94
95
|
label: 'GLM Coding Plan(bigmodel.cn)',
|
|
95
96
|
required_env: %w[ANTHROPIC_AUTH_TOKEN],
|
|
96
|
-
|
|
97
|
+
fixed_env: {
|
|
97
98
|
'ANTHROPIC_BASE_URL' => 'https://open.bigmodel.cn/api/anthropic',
|
|
98
99
|
'API_TIMEOUT_MS' => '3000000',
|
|
99
|
-
'
|
|
100
|
-
'
|
|
101
|
-
'
|
|
100
|
+
'CLAUDE_CODE_AUTO_COMPACT_WINDOW' => '1000000',
|
|
101
|
+
'ANTHROPIC_DEFAULT_OPUS_MODEL' => 'glm-5.2[1m]',
|
|
102
|
+
'ANTHROPIC_DEFAULT_SONNET_MODEL' => 'glm-5.2[1m]',
|
|
103
|
+
'ANTHROPIC_DEFAULT_HAIKU_MODEL' => 'glm-4.7'
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
'longcat' => {
|
|
107
|
+
label: 'LongCat(美团,兼容 Anthropic 协议)',
|
|
108
|
+
required_env: %w[ANTHROPIC_AUTH_TOKEN],
|
|
109
|
+
fixed_env: {
|
|
110
|
+
'ANTHROPIC_BASE_URL' => 'https://api.longcat.chat/anthropic',
|
|
111
|
+
'ANTHROPIC_MODEL' => 'LongCat-2.0',
|
|
112
|
+
'ANTHROPIC_SMALL_FAST_MODEL' => 'LongCat-2.0',
|
|
113
|
+
'ANTHROPIC_DEFAULT_SONNET_MODEL' => 'LongCat-2.0',
|
|
114
|
+
'ANTHROPIC_DEFAULT_OPUS_MODEL' => 'LongCat-2.0',
|
|
115
|
+
'CLAUDE_CODE_MAX_OUTPUT_TOKENS' => '131072',
|
|
116
|
+
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' => '1'
|
|
102
117
|
}
|
|
103
|
-
}
|
|
104
|
-
},
|
|
105
|
-
'gemini' => {
|
|
106
|
-
'google_official' => {
|
|
107
|
-
label: 'Google 官方',
|
|
108
|
-
required_env: %w[GEMINI_API_KEY],
|
|
109
|
-
optional_env: {}
|
|
110
118
|
}
|
|
111
119
|
},
|
|
112
120
|
'codex' => {
|
|
113
121
|
'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: {}
|
|
122
|
+
label: 'ChatGPT Subscribe(使用 ChatGPT 订阅账户授权信息)',
|
|
123
|
+
# 不录入 API Key:直接复用 ~/.codex 的 ChatGPT OAuth 登录态,
|
|
124
|
+
# 配置动作 = 等同 easyai backup codex(仅当存在官方 token 时更新备份)
|
|
125
|
+
chatgpt_subscribe: true
|
|
124
126
|
},
|
|
125
127
|
'deepseek' => {
|
|
126
|
-
label: 'DeepSeek(兼容 OpenAI
|
|
128
|
+
label: 'DeepSeek(兼容 OpenAI 协议,codex 自动写 responses provider profile)',
|
|
127
129
|
required_env: %w[OPENAI_API_KEY],
|
|
128
|
-
|
|
130
|
+
fixed_env: {
|
|
129
131
|
'OPENAI_BASE_URL' => 'https://api.deepseek.com/v1'
|
|
130
132
|
}
|
|
133
|
+
},
|
|
134
|
+
'longcat' => {
|
|
135
|
+
label: 'LongCat(美团,兼容 OpenAI 协议)',
|
|
136
|
+
required_env: %w[OPENAI_API_KEY],
|
|
137
|
+
fixed_env: {
|
|
138
|
+
'OPENAI_BASE_URL' => 'https://api.longcat.chat/openai/v1'
|
|
139
|
+
}
|
|
131
140
|
}
|
|
132
141
|
}
|
|
133
142
|
}.freeze
|
|
@@ -137,7 +146,7 @@ DESC
|
|
|
137
146
|
|
|
138
147
|
def self.options
|
|
139
148
|
[
|
|
140
|
-
['--tool=NAME', '指定要配置的工具(claude /
|
|
149
|
+
['--tool=NAME', '指定要配置的工具(claude / codex)'],
|
|
141
150
|
['--add=PLATFORM', '在指定工具下追加或覆盖单个平台'],
|
|
142
151
|
['--remove=PLATFORM', '在指定工具下删除单个平台'],
|
|
143
152
|
['--list', '脱敏方式展示当前所有配置'],
|
|
@@ -192,7 +201,7 @@ DESC
|
|
|
192
201
|
puts "schema 版本:#{cfg['version']}"
|
|
193
202
|
|
|
194
203
|
printed_any = false
|
|
195
|
-
%w[claude
|
|
204
|
+
%w[claude codex].each do |tool|
|
|
196
205
|
tool_cfg = cfg[tool]
|
|
197
206
|
next unless tool_cfg.is_a?(Hash)
|
|
198
207
|
|
|
@@ -211,7 +220,7 @@ DESC
|
|
|
211
220
|
# 列出未列入三家但用户自定义的工具键
|
|
212
221
|
cfg.each do |key, value|
|
|
213
222
|
next if key == 'version'
|
|
214
|
-
next if %w[claude
|
|
223
|
+
next if %w[claude codex].include?(key)
|
|
215
224
|
next unless value.is_a?(Hash) && value['platforms'].is_a?(Hash)
|
|
216
225
|
|
|
217
226
|
printed_any = true
|
|
@@ -307,11 +316,17 @@ DESC
|
|
|
307
316
|
|
|
308
317
|
unless tool_known.key?(@add)
|
|
309
318
|
print_error("未知平台:#{@add}(不在 KNOWN_PLATFORMS[#{@tool}] 中)")
|
|
310
|
-
puts " 可用平台:#{
|
|
319
|
+
puts " 可用平台:#{known_platform_keys(@tool).join(', ')}"
|
|
311
320
|
puts ' 提示:如确需新增自定义平台,可使用 --edit 手工编辑 config.json'
|
|
312
321
|
exit 1
|
|
313
322
|
end
|
|
314
323
|
|
|
324
|
+
if Config::LocalConfig.disabled_platform?(@tool, @add)
|
|
325
|
+
print_error("平台 #{@add} 已被屏蔽(暂不可用)")
|
|
326
|
+
puts " 可用平台:#{known_platform_keys(@tool).join(', ')}"
|
|
327
|
+
exit 1
|
|
328
|
+
end
|
|
329
|
+
|
|
315
330
|
existing = Config::LocalConfig.available_platforms(@tool).include?(@add)
|
|
316
331
|
if existing
|
|
317
332
|
print_status('覆盖', "#{@tool} / #{@add}(已存在,将覆盖原有数据)")
|
|
@@ -392,7 +407,7 @@ DESC
|
|
|
392
407
|
tools.each do |tool|
|
|
393
408
|
puts
|
|
394
409
|
puts "[#{tool}]".green.bold
|
|
395
|
-
available =
|
|
410
|
+
available = known_platform_keys(tool)
|
|
396
411
|
chosen = select_multi(
|
|
397
412
|
" 请选择 #{tool} 下要配置的平台(逗号分隔多个序号,回车默认全部)",
|
|
398
413
|
available, allow_all_default: true
|
|
@@ -426,7 +441,7 @@ DESC
|
|
|
426
441
|
end
|
|
427
442
|
|
|
428
443
|
tools.each do |tool|
|
|
429
|
-
available_known =
|
|
444
|
+
available_known = known_platform_keys(tool)
|
|
430
445
|
puts
|
|
431
446
|
puts "[#{tool}]".green.bold
|
|
432
447
|
chosen = select_multi(
|
|
@@ -487,24 +502,47 @@ DESC
|
|
|
487
502
|
end
|
|
488
503
|
end
|
|
489
504
|
|
|
490
|
-
#
|
|
505
|
+
# 返回某工具下「未被屏蔽」的内置平台模板 key(屏蔽清单见 LocalConfig::DISABLED_PLATFORMS)
|
|
506
|
+
def known_platform_keys(tool)
|
|
507
|
+
KNOWN_PLATFORMS[tool].keys.reject { |p| Config::LocalConfig.disabled_platform?(tool, p) }
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# 收集单个平台的 env 数据(不再交互询问代理:setup 默认不配置 HTTP/HTTPS 代理,
|
|
511
|
+
# 如需代理请用 `easyai setup --edit` 手动向平台补 proxy 字段;runtime 与 --list 仍支持 proxy)
|
|
491
512
|
def collect_platform_data(tool, platform)
|
|
492
513
|
spec = KNOWN_PLATFORMS.dig(tool, platform) || {}
|
|
514
|
+
return collect_chatgpt_subscribe_data if spec[:chatgpt_subscribe]
|
|
515
|
+
|
|
493
516
|
env = {}
|
|
494
517
|
|
|
495
518
|
Array(spec[:required_env]).each do |key|
|
|
496
519
|
env[key] = ask_required(key)
|
|
497
520
|
end
|
|
498
521
|
|
|
522
|
+
(spec[:fixed_env] || {}).each do |key, value|
|
|
523
|
+
env[key] = value.to_s
|
|
524
|
+
print_status('默认', "#{key} = #{value}")
|
|
525
|
+
end
|
|
526
|
+
|
|
499
527
|
(spec[:optional_env] || {}).each do |key, default|
|
|
500
528
|
env[key] = ask_optional(key, default)
|
|
501
529
|
end
|
|
502
530
|
|
|
503
|
-
|
|
531
|
+
{ 'env' => env }
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# ChatGPT Subscribe:不录入 API Key,直接复用 ~/.codex 的 ChatGPT OAuth 登录态。
|
|
535
|
+
# 配置动作等同 easyai backup codex —— 仅当 auth.json 存在官方 token 时才更新备份。
|
|
536
|
+
# 平台数据本身为空 env(runtime 直接用原生 auth.json 启动 codex)。
|
|
537
|
+
def collect_chatgpt_subscribe_data
|
|
538
|
+
backup = Command::Backup::Codex.new(CLAide::ARGV.new([]))
|
|
539
|
+
if backup.backup_if_official
|
|
540
|
+
print_success('已备份当前 ChatGPT 官方登录信息(~/.codex/auth.json → ~/.easyai/backup/.codex.json)')
|
|
541
|
+
else
|
|
542
|
+
print_warning('未检测到 ChatGPT 官方登录 token,已跳过备份。请先运行 codex 完成 ChatGPT 登录后再配置。')
|
|
543
|
+
end
|
|
504
544
|
|
|
505
|
-
|
|
506
|
-
data['proxy'] = proxy unless proxy.empty?
|
|
507
|
-
data
|
|
545
|
+
{ 'env' => {} }
|
|
508
546
|
end
|
|
509
547
|
|
|
510
548
|
def ask_required(key)
|
|
@@ -543,23 +581,6 @@ DESC
|
|
|
543
581
|
value
|
|
544
582
|
end
|
|
545
583
|
|
|
546
|
-
def ask_proxy
|
|
547
|
-
print ' 是否配置 HTTP/HTTPS 代理?(y/N) > '
|
|
548
|
-
ans = read_line
|
|
549
|
-
return {} unless ans && ans.downcase.start_with?('y')
|
|
550
|
-
|
|
551
|
-
print ' HTTP_PROXY (例如 http://127.0.0.1:7890): '
|
|
552
|
-
http = read_line
|
|
553
|
-
print ' HTTPS_PROXY (回车与 HTTP_PROXY 相同): '
|
|
554
|
-
https = read_line
|
|
555
|
-
https = http if https.nil? || https.empty?
|
|
556
|
-
|
|
557
|
-
proxy = {}
|
|
558
|
-
proxy['HTTP_PROXY'] = http unless http.nil? || http.empty?
|
|
559
|
-
proxy['HTTPS_PROXY'] = https unless https.nil? || https.empty?
|
|
560
|
-
proxy
|
|
561
|
-
end
|
|
562
|
-
|
|
563
584
|
def read_line
|
|
564
585
|
line = $stdin.gets
|
|
565
586
|
raise Interrupt, '用户取消输入' if line.nil?
|