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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0e9e5abc64041d1ca3e0a9fb59a81aa3dcdd16edfdfa716069fa17a71df1f4b
4
- data.tar.gz: b729e5453a287e1e7fb6908fba42bd4516e48800eafff811d4b0fea80bb83653
3
+ metadata.gz: 75408704158dabc22070ec012de28eaced8080bbfc7ebd4b45e5c0481bdfe071
4
+ data.tar.gz: 58b0d079f864fc04c3cb82690af5f0e904b0eef17a4737dc44fa83443a66bd56
5
5
  SHA512:
6
- metadata.gz: f5fa95bca000bf57323f11026e5392b90fa71d22f606e77ddf49e87420afe1b128ef8313e3c51e6940bec15a177df3bc1ba7b81de532613c23cd394556a1c2f4
7
- data.tar.gz: a4b24f06ec5dfa6ea7ed8a7476619eb580f4b606151e22a0921ad7a6760775e8ee5202e7832d7bfca2a8c470664e57daa0be0ac1944ca55d3b82e502a0c779de
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(tool: tool_name, platform: @platform, verbose: @verbose) do |name|
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! "源文件不存在: #{SOURCE_PATH}\n请先登录 Claude Code 后再备份。" unless File.exist?(SOURCE_PATH)
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(SOURCE_PATH)
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?(BACKUP_PATH) ? read_json(BACKUP_PATH) : {}
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(BACKUP_DIR)
57
- File.write(BACKUP_PATH, JSON.pretty_generate(merged))
58
- File.chmod(0o600, BACKUP_PATH) unless windows?
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 " 源文件: #{SOURCE_PATH}"
104
- puts " 备份路径: #{BACKUP_PATH}"
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
@@ -9,18 +9,17 @@ module EasyAI
9
9
  # 清理 AI CLI 自身在本地产生的缓存与配置目录。
10
10
  #
11
11
  # 设计要点(详见 docs/设计文档.md §2.6):
12
- # - 仅清理三家 AI CLI(claude / gemini / codex)的缓存路径
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 / gemini / codex 各自在本地产生的缓存目录与配置文件。
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 gemini codex all].freeze
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
 
@@ -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
@@ -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
- 'gemini' => {
106
- 'google_official' => {
107
- label: 'Google 官方',
108
- required_env: %w[GEMINI_API_KEY],
109
- optional_env: {}
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: '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: {}
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
- optional_env: {
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 / gemini / codex)'],
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 gemini codex].each do |tool|
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 gemini codex].include?(key)
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 " 可用平台:#{tool_known.keys.join(', ')}"
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 = KNOWN_PLATFORMS[tool].keys
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 = KNOWN_PLATFORMS[tool].keys
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
- 'minimax' => 'MiniMax'
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
- def resolve_platform(tool:, platform: nil, verbose: false)
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
- if !platforms.is_a?(Hash) || platforms.empty?
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
- available = platforms.keys
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
- platforms[selected]
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)
@@ -1,3 +1,3 @@
1
1
  module EasyAI
2
- VERSION = '2.0.0'
2
+ VERSION = '2.1.0'
3
3
  end
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.0.0
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: 3.6.9
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