easyai 1.0.2 → 1.0.3

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.
@@ -0,0 +1,440 @@
1
+ require 'json'
2
+ require 'etc'
3
+ require 'open3'
4
+ require 'fileutils'
5
+
6
+ module EasyAI
7
+ module Auth
8
+ class AuthClaude
9
+ def self.configure(config, user_name = nil)
10
+ return false unless config
11
+
12
+ # 检查认证配置优先级
13
+ is_macos = RUBY_PLATFORM.include?('darwin')
14
+ keychain_exists = is_macos && config["key_chain"] && config["key_chain"]["claudeAiOauth"] && config["key_chain"]["service_name"]
15
+ env_token_exists = config["env"] && config["env"]["CLAUDE_CODE_OAUTH_TOKEN"]
16
+
17
+ # 如果使用 Keychain,需要将令牌从 Keychain 读取到配置中
18
+ if keychain_exists
19
+ token_from_keychain = get_token_from_keychain(config["key_chain"]["service_name"])
20
+ if token_from_keychain
21
+ config["env"] ||= {}
22
+ config["env"]["CLAUDE_CODE_OAUTH_TOKEN"] = token_from_keychain
23
+ end
24
+ end
25
+
26
+ # 根据优先级确定认证方法
27
+ if keychain_exists
28
+ configure_keychain(config)
29
+ clean_env_variables
30
+ elsif env_token_exists
31
+ verify_token_config(config)
32
+ clean_keychain if is_macos
33
+ else
34
+ token = prompt_for_token
35
+ return false if token.nil? || token.empty?
36
+
37
+ # 将令牌添加到配置中进行处理
38
+ config["env"] ||= {}
39
+ config["env"]["CLAUDE_CODE_OAUTH_TOKEN"] = token
40
+
41
+ verify_token_config(config)
42
+ clean_keychain
43
+ end
44
+
45
+ # 更新 ~/.claude.json 文件
46
+ update_claude_json(config) if config["claude_json"]
47
+
48
+ # 配置代理设置
49
+ configure_proxy(config) if config["claude_proxy"]
50
+
51
+ true
52
+ end
53
+
54
+ private
55
+
56
+ def self.get_token_from_keychain(service_name)
57
+ return nil unless RUBY_PLATFORM.include?('darwin')
58
+
59
+ account_name = Etc.getlogin || ENV['USER'] || ENV['LOGNAME']
60
+
61
+ cmd = [
62
+ "security",
63
+ "find-generic-password",
64
+ "-a", account_name,
65
+ "-s", service_name,
66
+ "-w"
67
+ ]
68
+
69
+ begin
70
+ stdout, stderr, status = Open3.capture3(*cmd)
71
+
72
+ if status.success? && !stdout.strip.empty?
73
+ # 解析 JSON 格式的密码
74
+ credentials = JSON.parse(stdout.strip)
75
+ return credentials["claudeAiOauth"]
76
+ end
77
+ rescue JSON::ParserError, StandardError
78
+ # 如果不是 JSON 格式,尝试直接返回
79
+ return stdout.strip if status.success? && !stdout.strip.empty?
80
+ end
81
+
82
+ nil
83
+ end
84
+
85
+ def self.configure_keychain(config)
86
+ credentials = {
87
+ "claudeAiOauth" => config["key_chain"]["claudeAiOauth"]
88
+ }
89
+
90
+ service_name = config["key_chain"]["service_name"]
91
+ account_name = Etc.getlogin || ENV['USER'] || ENV['LOGNAME']
92
+ label = service_name
93
+ password = credentials.to_json
94
+
95
+ cmd = [
96
+ "security",
97
+ "add-generic-password",
98
+ "-a", account_name,
99
+ "-s", service_name,
100
+ "-l", label,
101
+ "-w", password,
102
+ "-U",
103
+ "-T", ""
104
+ ]
105
+
106
+ system(*cmd)
107
+
108
+ if $?.success?
109
+ verify_keychain_entry(account_name, service_name)
110
+ else
111
+ puts "✗ 保存凭证到 Keychain 失败"
112
+ return false
113
+ end
114
+
115
+ true
116
+ end
117
+
118
+ def self.verify_keychain_entry(account_name, service_name)
119
+ verify_cmd = [
120
+ "security",
121
+ "find-generic-password",
122
+ "-a", account_name,
123
+ "-s", service_name
124
+ ]
125
+
126
+ unless system(*verify_cmd, out: File::NULL, err: File::NULL)
127
+ puts "✗ 无法验证 Keychain 条目"
128
+ end
129
+ end
130
+
131
+ def self.verify_token_config(config)
132
+ token = config["env"]["CLAUDE_CODE_OAUTH_TOKEN"]
133
+ return false unless token
134
+
135
+ # 只验证令牌存在且不为空,不写入任何配置文件
136
+ if token && !token.strip.empty?
137
+ # 清理任何现有的环境配置文件中的令牌定义
138
+ clean_env_variables
139
+ return true
140
+ else
141
+ puts "✗ 令牌配置无效"
142
+ return false
143
+ end
144
+ end
145
+
146
+ def self.update_claude_json(config)
147
+ user_claude_file = File.expand_path("~/.claude.json")
148
+
149
+ begin
150
+ # 读取现有文件或创建空哈希
151
+ if File.exist?(user_claude_file)
152
+ user_config = JSON.parse(File.read(user_claude_file))
153
+ else
154
+ user_config = {}
155
+ end
156
+
157
+ # 合并 claude_json 内容,过滤掉代理相关字段
158
+ claude_config = config["claude_json"].dup
159
+ claude_config.delete("claude_proxy")
160
+ claude_config.delete("HTTP_PROXY")
161
+ claude_config.delete("HTTPS_PROXY")
162
+
163
+ user_config.merge!(claude_config)
164
+
165
+ # 写回文件
166
+ File.write(user_claude_file, JSON.pretty_generate(user_config))
167
+
168
+ rescue JSON::ParserError => e
169
+ puts "✗ 解析现有 ~/.claude.json 失败: #{e.message}"
170
+ false
171
+ rescue => e
172
+ puts "✗ 更新 ~/.claude.json 失败: #{e.message}"
173
+ false
174
+ end
175
+ end
176
+
177
+ def self.configure_proxy(config)
178
+ return unless config["claude_proxy"] && (config["claude_proxy"]["HTTP_PROXY"] || config["claude_proxy"]["HTTPS_PROXY"])
179
+
180
+ http_proxy = config["claude_proxy"]["HTTP_PROXY"]
181
+ https_proxy = config["claude_proxy"]["HTTPS_PROXY"]
182
+
183
+ shell_files = get_shell_files
184
+
185
+ shell_files.each do |shell_file|
186
+ next unless File.exist?(shell_file)
187
+
188
+ begin
189
+ content = File.read(shell_file)
190
+ lines = content.split("\n", -1)
191
+ lines.pop if lines.last == ""
192
+
193
+ # 移除所有现有的代理相关配置
194
+ lines = remove_proxy_config(lines, shell_file)
195
+
196
+ # 只添加代理别名/函数,不直接设置环境变量
197
+ lines = add_proxy_aliases(lines, shell_file, http_proxy, https_proxy)
198
+
199
+ # 写回文件
200
+ File.write(shell_file, lines.join("\n") + "\n")
201
+
202
+ rescue => e
203
+ puts " ✗ 更新 #{shell_file} 失败: #{e.message}"
204
+ end
205
+ end
206
+
207
+ # 显示代理配置完成信息
208
+ puts "✅ 代理别名已配置 | claude_proxy / unclaude_proxy"
209
+ end
210
+
211
+ def self.prompt_for_token
212
+ puts "请输入您的 CLAUDE_CODE_OAUTH_TOKEN:"
213
+ print "> "
214
+
215
+ begin
216
+ # 简化输入处理,直接使用 STDIN
217
+ STDIN.gets&.chomp
218
+ rescue => e
219
+ puts "✗ 无法读取输入: #{e.message}"
220
+ puts " 请使用本地配置文件或 --user 参数"
221
+ nil
222
+ end
223
+ end
224
+
225
+ def self.clean_env_variables
226
+ shell_files = get_shell_files
227
+
228
+ shell_files.each do |shell_file|
229
+ next unless File.exist?(shell_file)
230
+
231
+ begin
232
+ content = File.read(shell_file)
233
+ lines = content.split("\n", -1)
234
+ lines.pop if lines.last == ""
235
+
236
+ patterns = get_token_patterns(shell_file)
237
+ original_count = lines.length
238
+ lines.reject! { |line| patterns.any? { |pattern| line.match(pattern) } }
239
+ removed_count = original_count - lines.length
240
+
241
+ if removed_count > 0
242
+ File.write(shell_file, lines.join("\n") + "\n")
243
+ end
244
+ rescue => e
245
+ puts " ✗ 清理 #{shell_file} 失败: #{e.message}"
246
+ end
247
+ end
248
+ end
249
+
250
+ def self.clean_keychain
251
+ is_macos = RUBY_PLATFORM.include?('darwin')
252
+
253
+ if is_macos
254
+ service_name = "Claude Code-credentials"
255
+ account_name = Etc.getlogin || ENV['USER'] || ENV['LOGNAME']
256
+
257
+ cmd = [
258
+ "security",
259
+ "delete-generic-password",
260
+ "-a", account_name,
261
+ "-s", service_name
262
+ ]
263
+
264
+ stdout, stderr, status = Open3.capture3(*cmd)
265
+
266
+ unless status.success? || stderr.include?("could not be found")
267
+ puts " 警告: 清理 Keychain 失败: #{stderr}"
268
+ end
269
+ end
270
+ end
271
+
272
+ def self.get_shell_files
273
+ shell_files = []
274
+ current_shell = ENV['SHELL']&.split('/')&.last || 'bash'
275
+
276
+ case current_shell
277
+ when 'zsh'
278
+ shell_files << File.expand_path("~/.zshrc")
279
+ when 'bash'
280
+ bash_profile = File.expand_path("~/.bash_profile")
281
+ bashrc = File.expand_path("~/.bashrc")
282
+ shell_files << (File.exist?(bash_profile) ? bash_profile : bashrc)
283
+ when 'fish'
284
+ fish_config = File.expand_path("~/.config/fish/config.fish")
285
+ shell_files << fish_config
286
+
287
+ # 确保 fish 配置目录存在
288
+ fish_dir = File.dirname(fish_config)
289
+ unless File.exist?(fish_dir)
290
+ FileUtils.mkdir_p(fish_dir)
291
+ puts " 创建目录: #{fish_dir}"
292
+ end
293
+ else
294
+ shell_files << File.expand_path("~/.profile")
295
+ end
296
+
297
+ # 总是添加 ~/.profile 作为备份
298
+ profile_file = File.expand_path("~/.profile")
299
+ shell_files << profile_file unless shell_files.include?(profile_file)
300
+
301
+ shell_files
302
+ end
303
+
304
+ def self.get_token_patterns(shell_file)
305
+ key = "CLAUDE_CODE_OAUTH_TOKEN"
306
+
307
+ if shell_file.include?("config.fish")
308
+ [
309
+ /^set\s+-gx\s+#{Regexp.escape(key)}\s/,
310
+ /^set\s+-x\s+#{Regexp.escape(key)}\s/,
311
+ /^#set\s+-gx\s+#{Regexp.escape(key)}\s/
312
+ ]
313
+ else
314
+ [
315
+ /^export\s+#{Regexp.escape(key)}=/,
316
+ /^#{Regexp.escape(key)}=/,
317
+ /^#export\s+#{Regexp.escape(key)}=/
318
+ ]
319
+ end
320
+ end
321
+
322
+ def self.get_export_line(shell_file, token)
323
+ key = "CLAUDE_CODE_OAUTH_TOKEN"
324
+
325
+ if shell_file.include?("config.fish")
326
+ "set -gx #{key} \"#{token}\""
327
+ else
328
+ "export #{key}=\"#{token}\""
329
+ end
330
+ end
331
+
332
+ def self.remove_proxy_config(lines, shell_file)
333
+ if shell_file.include?("config.fish")
334
+ # Fish shell 代理配置清理 - 移除直接的环境变量设置和代理相关的别名/函数
335
+ lines.reject! { |line|
336
+ line.match(/^set\s+-[gx]+\s+(HTTP_PROXY|HTTPS_PROXY|http_proxy|https_proxy)\s/) ||
337
+ line.match(/^export\s+(HTTP_PROXY|HTTPS_PROXY|http_proxy|https_proxy)=/) ||
338
+ line.match(/^alias\s+claude_proxy=/) ||
339
+ line.match(/^alias\s+unclaude_proxy=/) ||
340
+ line.match(/^function\s+(claude_proxy|unclaude_proxy)/)
341
+ }
342
+
343
+ # 移除函数块
344
+ in_function = false
345
+ lines.select do |line|
346
+ if line.match(/^function\s+(claude_proxy|unclaude_proxy)/)
347
+ in_function = true
348
+ false
349
+ elsif in_function && line.strip == "end"
350
+ in_function = false
351
+ false
352
+ elsif in_function
353
+ false
354
+ else
355
+ true
356
+ end
357
+ end
358
+ else
359
+ # Bash/zsh 代理配置清理 - 移除直接的环境变量设置和代理相关的别名/函数
360
+ lines.reject! { |line|
361
+ line.match(/^export\s+(HTTP_PROXY|HTTPS_PROXY|http_proxy|https_proxy)=/) ||
362
+ line.match(/^(HTTP_PROXY|HTTPS_PROXY|http_proxy|https_proxy)=/) ||
363
+ line.match(/^alias\s+(claude_proxy|unclaude_proxy)=/) ||
364
+ line.match(/^(claude_proxy|unclaude_proxy)\(\)/)
365
+ }
366
+
367
+ # 移除函数块
368
+ in_function = false
369
+ function_depth = 0
370
+ lines.select do |line|
371
+ if line.match(/^(claude_proxy|unclaude_proxy)\(\)/)
372
+ in_function = true
373
+ function_depth = 0
374
+ false
375
+ elsif in_function
376
+ function_depth += 1 if line.include?("{")
377
+ if line.include?("}")
378
+ function_depth -= 1
379
+ in_function = false if function_depth <= 0
380
+ end
381
+ false
382
+ else
383
+ true
384
+ end
385
+ end
386
+ end
387
+ end
388
+
389
+ def self.add_proxy_aliases(lines, shell_file, http_proxy, https_proxy)
390
+ if shell_file.include?("config.fish")
391
+ # Fish shell - 只添加函数,不直接设置环境变量
392
+ proxy_commands = []
393
+ proxy_commands << ""
394
+ proxy_commands << "function claude_proxy"
395
+ if http_proxy
396
+ proxy_commands << " set -gx HTTP_PROXY \"#{http_proxy}\""
397
+ proxy_commands << " set -gx http_proxy \"#{http_proxy}\""
398
+ end
399
+ if https_proxy
400
+ proxy_commands << " set -gx HTTPS_PROXY \"#{https_proxy}\""
401
+ proxy_commands << " set -gx https_proxy \"#{https_proxy}\""
402
+ end
403
+ proxy_commands << " echo '代理已启用: HTTP_PROXY=#{http_proxy || 'not set'}, HTTPS_PROXY=#{https_proxy || 'not set'}'"
404
+ proxy_commands << "end"
405
+
406
+ proxy_commands << ""
407
+ proxy_commands << "function unclaude_proxy"
408
+ proxy_commands << " set -e HTTP_PROXY"
409
+ proxy_commands << " set -e http_proxy"
410
+ proxy_commands << " set -e HTTPS_PROXY"
411
+ proxy_commands << " set -e https_proxy"
412
+ proxy_commands << " echo '代理已禁用'"
413
+ proxy_commands << "end"
414
+
415
+ lines.concat(proxy_commands)
416
+ else
417
+ # Bash/zsh - 只添加别名,不直接设置环境变量
418
+ proxy_exports = []
419
+ if http_proxy
420
+ proxy_exports << "export HTTP_PROXY=\"#{http_proxy}\""
421
+ proxy_exports << "export http_proxy=\"#{http_proxy}\""
422
+ end
423
+ if https_proxy
424
+ proxy_exports << "export HTTPS_PROXY=\"#{https_proxy}\""
425
+ proxy_exports << "export https_proxy=\"#{https_proxy}\""
426
+ end
427
+
428
+ if proxy_exports.any?
429
+ lines << ""
430
+ lines << "alias claude_proxy='#{proxy_exports.join("; ")}; echo \"代理已启用: HTTP_PROXY=#{http_proxy || 'not set'}, HTTPS_PROXY=#{https_proxy || 'not set'}\"'"
431
+ end
432
+
433
+ lines << "alias unclaude_proxy='unset HTTP_PROXY http_proxy HTTPS_PROXY https_proxy; echo \"代理已禁用\"'"
434
+ end
435
+
436
+ lines
437
+ end
438
+ end
439
+ end
440
+ end