easyai 1.7.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.
@@ -1,494 +1,643 @@
1
- require_relative '../config/easyai_config'
2
- require_relative '../base/system_keychain'
3
- require 'colored2'
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
4
  require 'json'
5
- require 'open3'
5
+ require 'colored2'
6
+ require_relative '../command'
7
+ require_relative '../config/local_config'
8
+ require_relative '../base/secret_masker'
9
+ require_relative 'backup'
6
10
 
7
11
  module EasyAI
8
12
  class Command
13
+ # 交互式本地配置生成器
14
+ #
15
+ # 子命令矩阵:
16
+ # easyai setup # 全量交互(首次)/ 选择菜单 upsert(已存在)
17
+ # easyai setup --tool=claude # 仅配置指定工具
18
+ # easyai setup --add=kimi --tool=claude # 追加 / 覆盖单平台
19
+ # easyai setup --remove=kimi --tool=claude # 删除单平台
20
+ # easyai setup --list # 脱敏概览
21
+ # easyai setup --reset # 删除现有 config 后重走交互
22
+ # easyai setup --edit # 用 $EDITOR 直接编辑 config.json
9
23
  class Setup < Command
10
- self.summary = '初始化配置环境'
24
+ self.summary = '配置 EasyAI(写入 ~/.easyai/config.json)'
11
25
  self.description = <<-DESC
12
- 初始化 EasyAI 配置环境,自动下载并设置配置仓库。
13
-
14
- 主要功能:
15
- * 自动下载配置仓库
16
- * 验证配置完整性
17
- * 解密配置文件(如需要)
18
- * 显示可用用户列表
19
- * 管理密码存储
20
-
21
- 使用示例:
22
- $ easyai setup # 标准初始化
23
- $ easyai setup --force # 强制重新下载配置
24
- $ easyai setup --branch dev # 使用开发分支
25
- $ easyai setup --verify # 仅验证配置状态
26
- $ easyai setup --list-users # 列出可用用户
27
- DESC
26
+ 配置 EasyAI 的本地凭证 / 代理设置。配置文件位于 ~/.easyai/config.json。
27
+
28
+ 使用示例:
29
+
30
+ $ easyai setup # 首次:全量交互;已存在:upsert 选择菜单
31
+ $ easyai setup --tool=claude # 仅配置 claude
32
+ $ easyai setup --add=kimi --tool=claude # 追加或覆盖单个平台
33
+ $ easyai setup --remove=kimi --tool=claude # 删除单个平台
34
+ $ easyai setup --list # 脱敏方式打印当前配置概览
35
+ $ easyai setup --reset # 删除现有 config 后重走交互
36
+ $ easyai setup --edit # 用 $EDITOR 打开 config.json 直接编辑
37
+ DESC
38
+
39
+ # setup 内含的硬编码平台清单;runtime(AIToolBase)不读取此常量
40
+ KNOWN_PLATFORMS = {
41
+ 'claude' => {
42
+ 'claude_official' => {
43
+ label: 'Claude 官方',
44
+ required_env: %w[ANTHROPIC_AUTH_TOKEN],
45
+ optional_env: {
46
+ 'ANTHROPIC_BASE_URL' => 'https://api.anthropic.com'
47
+ }
48
+ },
49
+ 'kimi' => {
50
+ label: 'Kimi(Moonshot)',
51
+ required_env: %w[ANTHROPIC_AUTH_TOKEN],
52
+ optional_env: {
53
+ 'ANTHROPIC_BASE_URL' => 'https://api.kimi.com/coding/'
54
+ }
55
+ },
56
+ 'deepseek' => {
57
+ label: 'DeepSeek',
58
+ required_env: %w[ANTHROPIC_AUTH_TOKEN],
59
+ optional_env: {
60
+ 'ANTHROPIC_BASE_URL' => 'https://api.deepseek.com/anthropic',
61
+ 'ANTHROPIC_MODEL' => 'deepseek-v4-pro[1m]',
62
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL' => 'deepseek-v4-pro[1m]',
63
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL' => 'deepseek-v4-pro[1m]',
64
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL' => 'deepseek-v4-flash',
65
+ 'CLAUDE_CODE_SUBAGENT_MODEL' => 'deepseek-v4-flash',
66
+ 'CLAUDE_CODE_EFFORT_LEVEL' => 'max'
67
+ }
68
+ },
69
+ 'aliqwen' => {
70
+ label: '阿里千问 Coding Plan(兼容 Anthropic 协议)',
71
+ required_env: %w[ANTHROPIC_AUTH_TOKEN],
72
+ optional_env: {
73
+ 'ANTHROPIC_BASE_URL' => 'https://coding.dashscope.aliyuncs.com/apps/anthropic',
74
+ 'ANTHROPIC_MODEL' => 'qwen3.6-plus',
75
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL' => 'qwen3.6-plus',
76
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL' => 'qwen3.6-plus',
77
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL' => 'qwen3.6-plus',
78
+ 'ANTHROPIC_SMALL_FAST_MODEL' => 'qwen3.6-plus',
79
+ 'CLAUDE_CODE_SUBAGENT_MODEL' => 'qwen3.6-plus',
80
+ 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS' => '1'
81
+ }
82
+ },
83
+ 'minimax' => {
84
+ label: 'MiniMax(兼容 Anthropic 协议)',
85
+ required_env: %w[ANTHROPIC_AUTH_TOKEN],
86
+ optional_env: {
87
+ 'ANTHROPIC_BASE_URL' => 'https://api.minimaxi.com/anthropic',
88
+ 'ANTHROPIC_MODEL' => 'MiniMax-M2.7-highspeed',
89
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL' => 'MiniMax-M2.7-highspeed',
90
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL' => 'MiniMax-M2.7-highspeed',
91
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL' => 'MiniMax-M2.7-highspeed'
92
+ }
93
+ },
94
+ 'glm' => {
95
+ label: 'GLM Coding Plan(bigmodel.cn)',
96
+ required_env: %w[ANTHROPIC_AUTH_TOKEN],
97
+ optional_env: {
98
+ 'ANTHROPIC_BASE_URL' => 'https://open.bigmodel.cn/api/anthropic',
99
+ 'API_TIMEOUT_MS' => '3000000',
100
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL' => 'glm-5.1',
101
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL' => 'glm-5-turbo',
102
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL' => 'glm-4.5-air'
103
+ }
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
+ }
117
+ }
118
+ },
119
+ 'codex' => {
120
+ 'openai_official' => {
121
+ label: 'ChatGPT Subscribe(使用 ChatGPT 订阅账户授权信息)',
122
+ # 不录入 API Key:直接复用 ~/.codex 的 ChatGPT OAuth 登录态,
123
+ # 配置动作 = 等同 easyai backup codex(仅当存在官方 token 时更新备份)
124
+ chatgpt_subscribe: true
125
+ },
126
+ 'deepseek' => {
127
+ label: 'DeepSeek(兼容 OpenAI 协议,codex 自动写 responses provider profile)',
128
+ required_env: %w[OPENAI_API_KEY],
129
+ fixed_env: {
130
+ 'OPENAI_BASE_URL' => 'https://api.deepseek.com/v1'
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
+ }
139
+ }
140
+ }
141
+ }.freeze
142
+
143
+ # 敏感字段脱敏统一走 Base::SecretMasker,保留常量别名仅用于历史兼容
144
+ SENSITIVE_KEY_PATTERN = Base::SecretMasker::SENSITIVE_KEY_PATTERN
28
145
 
29
146
  def self.options
30
147
  [
31
- ['--force', '强制重新下载配置仓库'],
32
- ['--branch=BRANCH', '指定配置仓库分支 (默认: master)'],
33
- ['--verify', '仅验证现有配置,不进行修改'],
34
- ['--list-users', '列出所有可用用户'],
35
- ['--clean', '清理所有临时解密文件'],
36
- ['--no-password', '跳过密码设置'],
148
+ ['--tool=NAME', '指定要配置的工具(claude / codex)'],
149
+ ['--add=PLATFORM', '在指定工具下追加或覆盖单个平台'],
150
+ ['--remove=PLATFORM', '在指定工具下删除单个平台'],
151
+ ['--list', '脱敏方式展示当前所有配置'],
152
+ ['--reset', '删除现有 config.json 后重走全量交互'],
153
+ ['--edit', '使用 $EDITOR / $VISUAL 直接编辑 config.json']
37
154
  ].concat(super)
38
155
  end
39
156
 
40
157
  def initialize(argv)
41
- @force = argv.flag?('force')
42
- @branch = argv.option('branch') || 'master'
43
- @verify_only = argv.flag?('verify')
44
- @list_users = argv.flag?('list-users')
45
- @clean = argv.flag?('clean')
46
- @skip_password = argv.flag?('no-password')
158
+ @tool = argv.option('tool')
159
+ @add = argv.option('add')
160
+ @remove = argv.option('remove')
161
+ @list = argv.flag?('list')
162
+ @reset = argv.flag?('reset')
163
+ @edit = argv.flag?('edit')
47
164
  super
48
-
49
- @config_dir = File.expand_path('~/.easyai')
50
- @repo_dir = File.join(@config_dir, 'EasyAISetting')
51
165
  end
52
166
 
53
167
  def run
54
- show_welcome_banner
168
+ return run_list if @list
169
+ return run_remove if @remove
170
+ return run_add if @add
171
+ return run_reset if @reset
172
+ return run_edit if @edit
173
+
174
+ run_full_interactive
175
+ rescue Config::LocalConfig::ParseError, Config::LocalConfig::IncompatibleVersionError => e
176
+ print_error(e.message)
177
+ puts " 可运行: #{'easyai setup --reset'.yellow} 重新生成配置"
178
+ exit 1
179
+ rescue Interrupt
180
+ puts
181
+ print_error('用户取消操作')
182
+ exit 130
183
+ end
55
184
 
56
- if @verify_only
57
- verify_configuration
58
- return
59
- end
185
+ private
60
186
 
61
- if @clean
62
- cleanup_files
63
- return
64
- end
187
+ # ----- list -----
65
188
 
66
- if @list_users
67
- show_available_users
68
- return
189
+ def run_list
190
+ unless Config::LocalConfig.exists?
191
+ print_error("未找到本地配置 #{Config::LocalConfig.config_path}")
192
+ puts " 请运行: #{'easyai setup'.yellow}"
193
+ exit 1
69
194
  end
70
195
 
71
- # 执行主要设置流程
72
- perform_setup
73
-
74
- # 显示完成信息
75
- show_completion_info
76
- end
77
-
78
- private
79
-
80
- def show_welcome_banner
81
- puts
82
- puts "🚀 " + "EasyAI 配置环境初始化".green.bold
83
- puts "=" * 60
196
+ cfg = Config::LocalConfig.load
84
197
  puts
85
- end
198
+ puts "EasyAI 配置概览(#{Config::LocalConfig.config_path})".cyan.bold
199
+ puts '=' * 60
200
+ puts "schema 版本:#{cfg['version']}"
86
201
 
87
- def perform_setup
88
- step_counter = 0
202
+ printed_any = false
203
+ %w[claude codex].each do |tool|
204
+ tool_cfg = cfg[tool]
205
+ next unless tool_cfg.is_a?(Hash)
89
206
 
90
- # 步骤1: 检查并创建目录
91
- step_counter += 1
92
- print_step(step_counter, "检查配置目录")
93
- ensure_directories
207
+ platforms = tool_cfg['platforms']
208
+ next unless platforms.is_a?(Hash) && !platforms.empty?
94
209
 
95
- # 步骤2: 下载或更新配置仓库
96
- step_counter += 1
97
- print_step(step_counter, "下载配置仓库")
98
- if @force || !repo_exists?
99
- download_repo
100
- else
101
- update_repo
210
+ printed_any = true
211
+ puts
212
+ puts "[#{tool}]".green.bold
213
+ platforms.each do |name, data|
214
+ puts " - #{name.cyan}"
215
+ print_platform_data(data)
216
+ end
102
217
  end
103
218
 
104
- # 步骤3: 处理加密文件
105
- step_counter += 1
106
- print_step(step_counter, "处理配置文件")
107
- handle_encrypted_files unless @skip_password
219
+ # 列出未列入三家但用户自定义的工具键
220
+ cfg.each do |key, value|
221
+ next if key == 'version'
222
+ next if %w[claude codex].include?(key)
223
+ next unless value.is_a?(Hash) && value['platforms'].is_a?(Hash)
108
224
 
109
- # 步骤4: 验证配置
110
- step_counter += 1
111
- print_step(step_counter, "验证配置完整性")
112
- verify_basic_config
225
+ printed_any = true
226
+ puts
227
+ puts "[#{key}]".green.bold
228
+ value['platforms'].each do |name, data|
229
+ puts " - #{name.cyan}"
230
+ print_platform_data(data)
231
+ end
232
+ end
113
233
 
114
- # 步骤5: 显示可用用户
115
- step_counter += 1
116
- print_step(step_counter, "加载用户列表")
117
- show_available_users(compact: true)
234
+ puts ' (暂无任何平台配置)' unless printed_any
118
235
  end
119
236
 
120
- def print_step(number, description)
121
- puts "\n" + "步骤 #{number}:".cyan + " #{description}"
122
- puts "-" * 40
123
- end
237
+ def print_platform_data(data)
238
+ return unless data.is_a?(Hash)
124
239
 
125
- def ensure_directories
126
- unless Dir.exist?(@config_dir)
127
- FileUtils.mkdir_p(@config_dir)
128
- puts " ✓ 创建配置目录: #{@config_dir}"
240
+ env = data['env']
241
+ if env.is_a?(Hash) && !env.empty?
242
+ puts ' env:'
243
+ env.each do |k, v|
244
+ puts " #{k} = #{format_value(k, v)}"
245
+ end
129
246
  else
130
- puts " ✓ 配置目录已存在: #{@config_dir}"
247
+ puts ' env: (空)'
248
+ end
249
+
250
+ proxy = data['proxy']
251
+ if proxy.is_a?(Hash) && !proxy.empty?
252
+ puts ' proxy:'
253
+ proxy.each do |k, v|
254
+ puts " #{k} = #{v}"
255
+ end
131
256
  end
132
257
  end
133
258
 
134
- def repo_exists?
135
- Dir.exist?(@repo_dir) && Dir.exist?(File.join(@repo_dir, '.git'))
259
+ def format_value(key, value)
260
+ Base::SecretMasker.format_value(key, value)
136
261
  end
137
262
 
138
- def download_repo
139
- if repo_exists? && @force
140
- puts " 正在删除现有仓库..."
141
- FileUtils.rm_rf(@repo_dir)
142
- end
263
+ def sensitive_key?(key)
264
+ Base::SecretMasker.sensitive_key?(key)
265
+ end
143
266
 
144
- puts " 正在从 Gitee 下载配置仓库 (#{@branch} 分支)..."
267
+ def mask_sensitive(value)
268
+ Base::SecretMasker.mask(value)
269
+ end
145
270
 
146
- # Gitee 不需要代理,临时清除代理环境变量
147
- output = nil
148
- success = false
271
+ # ----- remove -----
149
272
 
150
- if Gem.win_platform?
151
- # Windows: 保存并清除环境变量
152
- old_env = {}
153
- ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'].each do |key|
154
- old_env[key] = ENV[key]
155
- ENV.delete(key)
156
- end
273
+ def run_remove
274
+ unless @tool && !@tool.empty?
275
+ print_error('--remove 必须配合 --tool=<name> 使用')
276
+ exit 1
277
+ end
157
278
 
158
- begin
159
- cmd = "git clone --depth 1 --branch #{@branch} #{EasyAIConfig::REPO_URL} #{@repo_dir} 2>&1"
160
- puts " 执行命令: #{cmd}" if ENV['EASYAI_DEBUG']
161
- output = `#{cmd}`
162
- success = $?.success?
163
- ensure
164
- # 恢复环境变量
165
- old_env.each { |k, v| ENV[k] = v if v }
166
- end
167
- else
168
- # Unix/Linux/macOS: 使用 env -u
169
- cmd = "env -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy git clone --depth 1 --branch #{@branch} #{EasyAIConfig::REPO_URL} #{@repo_dir} 2>&1"
170
- puts " 执行命令: #{cmd}" if ENV['EASYAI_DEBUG']
171
- output = `#{cmd}`
172
- success = $?.success?
279
+ unless Config::LocalConfig.exists?
280
+ print_error("未找到本地配置 #{Config::LocalConfig.config_path}")
281
+ puts " 请运行: #{'easyai setup'.yellow}"
282
+ exit 1
173
283
  end
174
284
 
175
- if success
176
- puts " ✓ 配置仓库下载成功"
177
- else
178
- puts " 下载失败: #{output}".red
285
+ available = Config::LocalConfig.available_platforms(@tool)
286
+ unless available.include?(@remove)
287
+ print_error("工具 #{@tool} 下不存在平台 #{@remove}")
288
+ puts " 当前可用:#{available.join(', ')}" unless available.empty?
179
289
  exit 1
180
290
  end
181
- end
182
291
 
183
- def update_repo
184
- puts " 检查配置仓库更新..."
185
-
186
- Dir.chdir(@repo_dir) do
187
- # 获取当前分支
188
- current_branch = `git branch --show-current`.chomp
189
-
190
- if current_branch != @branch
191
- puts " 切换到 #{@branch} 分支..."
192
- # 不使用代理
193
- if Gem.win_platform?
194
- # Windows: 临时清除环境变量
195
- old_env = {}
196
- ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'].each do |key|
197
- old_env[key] = ENV[key]
198
- ENV.delete(key)
199
- end
200
-
201
- begin
202
- system("git fetch origin #{@branch} > nul 2>&1")
203
- ensure
204
- old_env.each { |k, v| ENV[k] = v if v }
205
- end
206
- else
207
- system("env -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy git fetch origin #{@branch} > /dev/null 2>&1")
208
- end
209
- system("git checkout #{@branch} > /dev/null 2>&1")
210
- end
292
+ Config::LocalConfig.delete_platform(@tool, @remove)
293
+ print_success("已删除 #{@tool} / #{@remove}")
211
294
 
212
- # 拉取更新(不使用代理)
213
- output = nil
214
- if Gem.win_platform?
215
- # Windows: 临时清除环境变量
216
- old_env = {}
217
- ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'].each do |key|
218
- old_env[key] = ENV[key]
219
- ENV.delete(key)
220
- end
295
+ # 如果该 tool 已被一并清除,再做一次温和提示
296
+ unless Config::LocalConfig.available_platforms(@tool).any?
297
+ puts " 工具 #{@tool} 下已无任何平台,已从配置中移除该工具键。"
298
+ end
299
+ end
221
300
 
222
- begin
223
- output = `git pull --force 2>&1`
224
- ensure
225
- old_env.each { |k, v| ENV[k] = v if v }
226
- end
227
- else
228
- output = `env -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy git pull --force 2>&1`
229
- end
301
+ # ----- add -----
230
302
 
231
- if $?.success?
232
- if output.include?("Already up to date")
233
- puts " ✓ 配置已是最新版本"
234
- else
235
- puts " ✓ 配置已更新到最新版本"
236
- end
237
- else
238
- puts " ⚠️ 更新失败,使用现有配置"
239
- end
303
+ def run_add
304
+ unless @tool && !@tool.empty?
305
+ print_error('--add 必须配合 --tool=<name> 使用')
306
+ exit 1
240
307
  end
241
- end
242
308
 
243
- def handle_encrypted_files
244
- encrypted_files = Dir.glob(File.join(@repo_dir, '**/*.encrypted'))
309
+ tool_known = KNOWN_PLATFORMS[@tool]
310
+ unless tool_known
311
+ print_error("未支持的工具:#{@tool}")
312
+ puts " 可用工具:#{KNOWN_PLATFORMS.keys.join(', ')}"
313
+ exit 1
314
+ end
245
315
 
246
- if encrypted_files.empty?
247
- puts " 未发现加密文件"
248
- return
316
+ unless tool_known.key?(@add)
317
+ print_error("未知平台:#{@add}(不在 KNOWN_PLATFORMS[#{@tool}] 中)")
318
+ puts " 可用平台:#{known_platform_keys(@tool).join(', ')}"
319
+ puts ' 提示:如确需新增自定义平台,可使用 --edit 手工编辑 config.json'
320
+ exit 1
249
321
  end
250
322
 
251
- puts " 发现 #{encrypted_files.length} 个加密文件"
323
+ if Config::LocalConfig.disabled_platform?(@tool, @add)
324
+ print_error("平台 #{@add} 已被屏蔽(暂不可用)")
325
+ puts " 可用平台:#{known_platform_keys(@tool).join(', ')}"
326
+ exit 1
327
+ end
252
328
 
253
- # 获取密码
254
- password = get_setup_password
255
- return unless password
329
+ existing = Config::LocalConfig.available_platforms(@tool).include?(@add)
330
+ if existing
331
+ print_status('覆盖', "#{@tool} / #{@add}(已存在,将覆盖原有数据)")
332
+ else
333
+ print_status('新增', "#{@tool} / #{@add}")
334
+ end
256
335
 
257
- # 测试密码
258
- if test_password(password, encrypted_files.first)
259
- puts " 密码验证成功"
336
+ data = collect_platform_data(@tool, @add)
337
+ Config::LocalConfig.upsert_platform(@tool, @add, data)
338
+ print_success("已写入 #{@tool} / #{@add}")
339
+ end
260
340
 
261
- # 保存到钥匙串
262
- if should_save_password?
263
- if Base::SystemKeychain.store_password(password)
264
- puts " ✓ 密码已保存到系统钥匙串"
265
- end
266
- end
341
+ # ----- reset -----
342
+
343
+ def run_reset
344
+ path = Config::LocalConfig.config_path
345
+ if File.exist?(path)
346
+ File.delete(path)
347
+ print_success("已删除原配置文件 #{path}")
267
348
  else
268
- puts " 密码验证失败".red
269
- puts " 提示: 配置文件将在使用时解密"
349
+ print_status('提示', "原配置文件不存在 #{path},直接进入交互配置")
270
350
  end
351
+
352
+ run_full_interactive
271
353
  end
272
354
 
273
- def get_setup_password
274
- # 优先使用环境变量
275
- if ENV['EASYAI_TEST_PASSWORD']
276
- puts " 使用环境变量中的密码"
277
- return ENV['EASYAI_TEST_PASSWORD']
355
+ # ----- edit -----
356
+
357
+ def run_edit
358
+ editor = ENV['VISUAL'] || ENV['EDITOR']
359
+ if editor.nil? || editor.empty?
360
+ print_error('未设置 $VISUAL / $EDITOR 环境变量')
361
+ puts ' 请先 export EDITOR=vim 等并重试'
362
+ exit 1
278
363
  end
279
364
 
280
- # 检查钥匙串
281
- stored_password = Base::SystemKeychain.get_stored_password
282
- if stored_password && !stored_password.empty?
283
- puts " 使用系统钥匙串中的密码"
284
- return stored_password
365
+ path = Config::LocalConfig.config_path
366
+ unless File.exist?(path)
367
+ print_status('提示', "config 不存在,先创建空骨架:#{path}")
368
+ Config::LocalConfig.save('version' => Config::LocalConfig::SCHEMA_VERSION)
285
369
  end
286
370
 
287
- # 提示用户输入
288
- puts " 配置文件已加密,请输入解密密码"
289
- print " 密码: "
371
+ print_status('编辑', "#{editor} #{path}")
372
+ unless system(editor, path)
373
+ print_error("编辑器退出非零:#{editor}")
374
+ exit 1
375
+ end
290
376
 
377
+ # 校验回写后是否仍可解析
291
378
  begin
292
- require 'io/console'
293
- password = STDIN.noecho(&:gets)&.chomp
294
- puts
295
- password
296
- rescue
297
- nil
379
+ Config::LocalConfig.load
380
+ print_success('配置文件解析通过')
381
+ rescue Config::LocalConfig::Error => e
382
+ print_error("配置解析失败:#{e.message}")
383
+ puts " 可运行: #{'easyai setup --reset'.yellow} 重新生成"
384
+ exit 1
298
385
  end
299
386
  end
300
387
 
301
- def test_password(password, test_file)
302
- begin
303
- require_relative '../base/file_crypto'
304
- key = Base::FileCrypto.generate_key(password)
305
- content = File.read(test_file)
306
- Base::FileCrypto.aes_128_ecb_decrypt(key, content)
307
- true
308
- rescue
309
- false
388
+ # ----- 全量交互 / upsert 选择菜单 -----
389
+
390
+ def run_full_interactive
391
+ if Config::LocalConfig.exists?
392
+ run_upsert_menu
393
+ else
394
+ run_first_time_setup
310
395
  end
311
396
  end
312
397
 
313
- def should_save_password?
314
- print " 是否保存密码到系统钥匙串?(y/n): "
315
- response = STDIN.gets&.chomp&.downcase
316
- response == 'y' || response == 'yes'
317
- end
398
+ def run_first_time_setup
399
+ puts
400
+ puts 'EasyAI 首次配置向导'.green.bold
401
+ puts '=' * 60
318
402
 
319
- def verify_basic_config
320
- required_files = [
321
- 'index.json.encrypted',
322
- 'jps_client_config.json.encrypted'
323
- ]
324
-
325
- missing_files = []
326
- required_files.each do |file|
327
- path = File.join(@repo_dir, file)
328
- if File.exist?(path)
329
- puts " ✓ #{file}"
330
- else
331
- missing_files << file
332
- puts " ✗ #{file} (缺失)".red
403
+ tools = select_multi('请选择要配置的 AI 工具(用逗号分隔多个序号,回车默认全部)',
404
+ KNOWN_PLATFORMS.keys, allow_all_default: true)
405
+
406
+ tools.each do |tool|
407
+ puts
408
+ puts "[#{tool}]".green.bold
409
+ available = known_platform_keys(tool)
410
+ chosen = select_multi(
411
+ " 请选择 #{tool} 下要配置的平台(逗号分隔多个序号,回车默认全部)",
412
+ available, allow_all_default: true
413
+ )
414
+
415
+ chosen.each do |platform|
416
+ data = collect_platform_data(tool, platform)
417
+ Config::LocalConfig.upsert_platform(tool, platform, data)
418
+ print_success("已写入 #{tool} / #{platform}")
333
419
  end
334
420
  end
335
421
 
336
- if missing_files.empty?
337
- puts " ✓ 核心配置文件完整"
338
- else
339
- puts " ⚠️ 缺少 #{missing_files.length} 个配置文件".yellow
340
- end
422
+ puts
423
+ run_list
341
424
  end
342
425
 
343
- def show_available_users(compact: false)
344
- # 初始化配置
345
- EasyAIConfig.initialize(verbose: false)
426
+ def run_upsert_menu
427
+ if @tool && !@tool.empty?
428
+ tools = [@tool]
429
+ unless KNOWN_PLATFORMS.key?(@tool)
430
+ print_error("未支持的工具:#{@tool}")
431
+ puts " 可用工具:#{KNOWN_PLATFORMS.keys.join(', ')}"
432
+ exit 1
433
+ end
434
+ else
435
+ puts
436
+ puts 'EasyAI upsert 配置菜单'.green.bold
437
+ puts '=' * 60
438
+ tools = select_multi('请选择要更新的 AI 工具(逗号分隔多个序号)',
439
+ KNOWN_PLATFORMS.keys, allow_all_default: false)
440
+ end
346
441
 
347
- # 获取 index 配置
348
- index_config = EasyAIConfig.get_config('index', verbose: false)
442
+ tools.each do |tool|
443
+ available_known = known_platform_keys(tool)
444
+ puts
445
+ puts "[#{tool}]".green.bold
446
+ chosen = select_multi(
447
+ " 请选择 #{tool} 下要追加 / 覆盖的平台(逗号分隔多个序号)",
448
+ available_known, allow_all_default: false
449
+ )
450
+
451
+ chosen.each do |platform|
452
+ existed = Config::LocalConfig.available_platforms(tool).include?(platform)
453
+ if existed
454
+ print_status('覆盖', "#{tool} / #{platform}(已存在,将覆盖原有数据)")
455
+ else
456
+ print_status('新增', "#{tool} / #{platform}")
457
+ end
349
458
 
350
- unless index_config
351
- puts " ✗ 无法加载用户配置".red
352
- return
459
+ data = collect_platform_data(tool, platform)
460
+ Config::LocalConfig.upsert_platform(tool, platform, data)
461
+ print_success("已写入 #{tool} / #{platform}")
462
+ end
353
463
  end
354
464
 
355
- if compact
356
- user_count = count_users(index_config)
357
- puts " ✓ 发现 #{user_count} 个可用用户配置"
358
- else
359
- display_users_table(index_config)
360
- end
361
- rescue => e
362
- puts " ✗ 加载用户列表失败: #{e.message}".red
465
+ puts
466
+ run_list
363
467
  end
364
468
 
365
- def count_users(config)
366
- count = 0
469
+ # ----- 通用交互辅助 -----
367
470
 
368
- if config.is_a?(Hash)
369
- %w[claude gemini gpt].each do |tool|
370
- next unless config[tool]
471
+ # 多选列表,返回选中的 name 列表
472
+ def select_multi(prompt, options, allow_all_default:)
473
+ loop do
474
+ puts "#{prompt}:"
475
+ options.each_with_index do |name, idx|
476
+ puts " #{idx + 1}) #{Config::LocalConfig.platform_display_name(name)}"
477
+ end
478
+ tail = allow_all_default ? '(回车选全部,q 取消)' : '(q 取消)'
479
+ print "请输入 #{tail} > "
371
480
 
372
- if tool == "claude" && config[tool].is_a?(Hash)
373
- config[tool].each do |_auth_type, users|
374
- count += users.keys.length if users.is_a?(Hash)
375
- end
376
- elsif config[tool].is_a?(Hash)
377
- count += config[tool].keys.length
378
- end
481
+ input = $stdin.gets
482
+ raise Interrupt, '用户取消选择' if input.nil?
483
+
484
+ input = input.chomp.strip
485
+ raise Interrupt, '用户取消选择' if input.casecmp('q').zero?
486
+
487
+ if input.empty?
488
+ return options.dup if allow_all_default
489
+
490
+ print_error('输入不能为空,请重试')
491
+ next
492
+ end
493
+
494
+ indices = input.split(/[,\s]+/).reject(&:empty?).map { |s| Integer(s, 10) rescue nil }
495
+ if indices.any?(&:nil?) || indices.any? { |i| i < 1 || i > options.size }
496
+ print_error("非法序号:#{input},请重试")
497
+ next
379
498
  end
499
+
500
+ return indices.uniq.map { |i| options[i - 1] }
380
501
  end
502
+ end
381
503
 
382
- count
504
+ # 返回某工具下「未被屏蔽」的内置平台模板 key(屏蔽清单见 LocalConfig::DISABLED_PLATFORMS)
505
+ def known_platform_keys(tool)
506
+ KNOWN_PLATFORMS[tool].keys.reject { |p| Config::LocalConfig.disabled_platform?(tool, p) }
383
507
  end
384
508
 
385
- def display_users_table(config)
386
- puts "\n可用用户列表:"
387
- puts "=" * 60
509
+ # 收集单个平台的 env / proxy 数据
510
+ def collect_platform_data(tool, platform)
511
+ spec = KNOWN_PLATFORMS.dig(tool, platform) || {}
512
+ return collect_chatgpt_subscribe_data if spec[:chatgpt_subscribe]
388
513
 
389
- %w[claude gemini gpt].each do |tool|
390
- next unless config[tool]
514
+ env = {}
391
515
 
392
- puts "\n#{tool.upcase}:"
516
+ Array(spec[:required_env]).each do |key|
517
+ env[key] = ask_required(key)
518
+ end
393
519
 
394
- if tool == "claude" && config[tool].is_a?(Hash)
395
- config[tool].each do |auth_type, users|
396
- next unless users.is_a?(Hash)
397
- puts " #{auth_type}:"
398
- users.each do |name, _file|
399
- puts " - #{name}"
400
- end
401
- end
402
- elsif config[tool].is_a?(Hash)
403
- config[tool].each do |name, _file|
404
- puts " - #{name}"
405
- end
406
- end
520
+ (spec[:fixed_env] || {}).each do |key, value|
521
+ env[key] = value.to_s
522
+ print_status('默认', "#{key} = #{value}")
407
523
  end
408
- end
409
524
 
410
- def verify_configuration
411
- puts "\n验证配置状态..."
412
- puts "=" * 60
413
-
414
- # 检查目录
415
- puts "\n目录检查:"
416
- puts " 配置目录: #{@config_dir} - " + (Dir.exist?(@config_dir) ? "✓".green : "✗".red)
417
- puts " 仓库目录: #{@repo_dir} - " + (repo_exists? ? "✓".green : "✗".red)
418
-
419
- # 检查 Git 状态
420
- if repo_exists?
421
- puts "\nGit 状态:"
422
- Dir.chdir(@repo_dir) do
423
- branch = `git branch --show-current`.chomp
424
- puts " 当前分支: #{branch}"
425
-
426
- status = `git status --short`
427
- if status.empty?
428
- puts " 工作区: 干净 ✓".green
429
- else
430
- puts " 工作区: 有修改 ⚠️".yellow
431
- end
432
- end
525
+ (spec[:optional_env] || {}).each do |key, default|
526
+ env[key] = ask_optional(key, default)
433
527
  end
434
528
 
435
- # 检查配置文件
436
- puts "\n配置文件:"
437
- verify_basic_config
529
+ proxy = ask_proxy
438
530
 
439
- # 检查密码
440
- puts "\n密码状态:"
441
- if ENV['EASYAI_TEST_PASSWORD']
442
- puts " 环境变量密码: ✓".green
443
- elsif Base::SystemKeychain.get_stored_password
444
- puts " 系统钥匙串密码: ✓".green
531
+ data = { 'env' => env }
532
+ data['proxy'] = proxy unless proxy.empty?
533
+ data
534
+ end
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)')
445
543
  else
446
- puts " 未设置密码 ⚠️".yellow
544
+ print_warning('未检测到 ChatGPT 官方登录 token,已跳过备份。请先运行 codex 完成 ChatGPT 登录后再配置。')
447
545
  end
448
546
 
449
- # 尝试加载用户
450
- show_available_users(compact: true)
547
+ { 'env' => {} }
451
548
  end
452
549
 
453
- def cleanup_files
454
- puts "\n清理临时文件..."
455
- puts "=" * 60
550
+ def ask_required(key)
551
+ loop do
552
+ if sensitive_key?(key)
553
+ print " #{key}(必填,输入不回显): "
554
+ value = read_secret_line
555
+ else
556
+ print " #{key}(必填): "
557
+ value = read_line
558
+ end
456
559
 
457
- # 清理解密的 JSON 文件
458
- json_files = Dir.glob(File.join(@repo_dir, '**/*.json'))
459
- cleanup_count = 0
560
+ return value if value && !value.empty?
460
561
 
461
- json_files.each do |file|
462
- encrypted_file = "#{file}.encrypted"
463
- if File.exist?(encrypted_file)
464
- FileUtils.rm_f(file)
465
- cleanup_count += 1
466
- puts " 删除: #{File.basename(file)}"
467
- end
562
+ print_error("#{key} 不能为空,请重试")
468
563
  end
564
+ end
469
565
 
470
- if cleanup_count > 0
471
- puts "\n ✓ 清理了 #{cleanup_count} 个临时文件".green
566
+ def ask_optional(key, default)
567
+ if default && !default.to_s.empty?
568
+ print " #{key}(可选,回车使用默认 #{default}): "
472
569
  else
473
- puts " 没有需要清理的文件".green
570
+ print " #{key}(可选,回车跳过): "
474
571
  end
572
+
573
+ value =
574
+ if sensitive_key?(key)
575
+ read_secret_line
576
+ else
577
+ read_line
578
+ end
579
+
580
+ return default.to_s if (value.nil? || value.empty?) && default
581
+ return '' if value.nil?
582
+
583
+ value
475
584
  end
476
585
 
477
- def show_completion_info
478
- puts "\n" + "=" * 60
479
- puts "✅ " + "配置初始化完成!".green.bold
480
- puts
481
- puts "您可以开始使用以下命令:"
482
- puts " easyai claude".cyan + " - 启动 Claude"
483
- puts " • easyai gemini".cyan + " - 启动 Gemini"
484
- puts " • easyai gpt".cyan + " - 启动 GPT"
485
- puts
486
- puts "其他有用的命令:"
487
- puts " • easyai setup --verify".cyan + " - 验证配置状态"
488
- puts " • easyai setup --list-users".cyan + " - 查看可用用户"
489
- puts " • easyai setup --clean".cyan + " - 清理临时文件"
586
+ def ask_proxy
587
+ print ' 是否配置 HTTP/HTTPS 代理?(y/N) > '
588
+ ans = read_line
589
+ return {} unless ans && ans.downcase.start_with?('y')
590
+
591
+ print ' HTTP_PROXY (例如 http://127.0.0.1:7890): '
592
+ http = read_line
593
+ print ' HTTPS_PROXY (回车与 HTTP_PROXY 相同): '
594
+ https = read_line
595
+ https = http if https.nil? || https.empty?
596
+
597
+ proxy = {}
598
+ proxy['HTTP_PROXY'] = http unless http.nil? || http.empty?
599
+ proxy['HTTPS_PROXY'] = https unless https.nil? || https.empty?
600
+ proxy
601
+ end
602
+
603
+ def read_line
604
+ line = $stdin.gets
605
+ raise Interrupt, '用户取消输入' if line.nil?
606
+
607
+ line.chomp
608
+ end
609
+
610
+ def read_secret_line
611
+ line =
612
+ if $stdin.respond_to?(:noecho)
613
+ $stdin.noecho(&:gets)
614
+ else
615
+ # 在非 TTY(如测试用 StringIO)下退化为普通 gets,避免 NoMethodError
616
+ $stdin.gets
617
+ end
490
618
  puts
619
+ raise Interrupt, '用户取消输入' if line.nil?
620
+
621
+ line.chomp
622
+ end
623
+
624
+ # ----- 输出辅助 -----
625
+
626
+ def print_status(icon_text, detail)
627
+ puts " #{icon_text.to_s.ljust(4).cyan} #{detail}"
628
+ end
629
+
630
+ def print_success(message)
631
+ puts " ✓ #{message.green}"
632
+ end
633
+
634
+ def print_warning(message)
635
+ puts " ⚠ #{message.yellow}"
636
+ end
637
+
638
+ def print_error(message)
639
+ puts " ✗ #{message.red}"
491
640
  end
492
641
  end
493
642
  end
494
- end
643
+ end