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.
@@ -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'
@@ -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
- optional_env: {
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
- optional_env: {
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
- optional_env: {
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
- optional_env: {
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
- optional_env: {
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
- optional_env: {
97
+ fixed_env: {
97
98
  'ANTHROPIC_BASE_URL' => 'https://open.bigmodel.cn/api/anthropic',
98
99
  'API_TIMEOUT_MS' => '3000000',
99
- 'ANTHROPIC_DEFAULT_OPUS_MODEL' => 'glm-5.1',
100
- 'ANTHROPIC_DEFAULT_SONNET_MODEL' => 'glm-5-turbo',
101
- 'ANTHROPIC_DEFAULT_HAIKU_MODEL' => 'glm-4.5-air'
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: 'OpenAI 官方',
115
- required_env: %w[OPENAI_API_KEY],
116
- optional_env: {
117
- 'OPENAI_BASE_URL' => 'https://api.openai.com/v1'
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
- optional_env: {
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 / gemini / codex)'],
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 gemini codex].each do |tool|
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 gemini codex].include?(key)
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 " 可用平台:#{tool_known.keys.join(', ')}"
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 = KNOWN_PLATFORMS[tool].keys
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 = KNOWN_PLATFORMS[tool].keys
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
- # 收集单个平台的 env / proxy 数据
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
- proxy = ask_proxy
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
- data = { 'env' => env }
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?