easyai 1.7.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENTS.md +10 -8
- data/CLAUDE.md +211 -126
- data/README.md +176 -36
- data/easyai.gemspec +12 -9
- data/lib/easyai/base/secret_masker.rb +39 -0
- data/lib/easyai/base/system_info.rb +17 -203
- data/lib/easyai/command/ai_tool_base.rb +218 -0
- data/lib/easyai/command/backup/claude.rb +124 -0
- data/lib/easyai/command/backup.rb +23 -0
- data/lib/easyai/command/claude.rb +72 -357
- data/lib/easyai/command/clean.rb +90 -395
- data/lib/easyai/command/codex.rb +39 -0
- data/lib/easyai/command/gemini.rb +23 -41
- data/lib/easyai/command/restore/claude.rb +150 -0
- data/lib/easyai/command/restore.rb +24 -0
- data/lib/easyai/command/setup.rb +487 -378
- data/lib/easyai/command/update.rb +39 -188
- data/lib/easyai/command/utils.rb +2 -7
- data/lib/easyai/command.rb +1 -3
- data/lib/easyai/config/local_config.rb +161 -0
- data/lib/easyai/version.rb +1 -1
- data/lib/easyai.rb +29 -35
- metadata +20 -37
- data/lib/easyai/auth/authclaude.rb +0 -519
- data/lib/easyai/auth/jpsloginhelper.rb +0 -98
- data/lib/easyai/base/system_keychain.rb +0 -283
- data/lib/easyai/command/gpt.rb +0 -259
- data/lib/easyai/command/utils/export.rb +0 -263
- data/lib/easyai/config/config.rb +0 -357
- data/lib/easyai/config/easyai_config.rb +0 -258
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'colored2'
|
|
5
|
+
require_relative '../command'
|
|
6
|
+
require_relative '../config/local_config'
|
|
7
|
+
require_relative '../base/system_info'
|
|
8
|
+
require_relative '../base/secret_masker'
|
|
9
|
+
|
|
10
|
+
module EasyAI
|
|
11
|
+
class Command
|
|
12
|
+
# AI 工具命令的公共基类:claude / gemini / codex 共享的运行流程
|
|
13
|
+
#
|
|
14
|
+
# 子类必须实现:
|
|
15
|
+
# - tool_name 返回 config.json 顶层键,如 'claude'
|
|
16
|
+
# - exec_command 返回子进程命令名,如 'claude'
|
|
17
|
+
# - install_hint 返回未安装时的中文安装提示
|
|
18
|
+
class AIToolBase < Command
|
|
19
|
+
self.ignore_in_command_lookup = true
|
|
20
|
+
|
|
21
|
+
# 系统关键变量保护清单:永远不允许被 config 中的 env / proxy 覆盖
|
|
22
|
+
PROTECTED_ENV_KEYS = %w[PATH HOME USER SHELL].freeze
|
|
23
|
+
|
|
24
|
+
def self.options
|
|
25
|
+
[
|
|
26
|
+
['--platform=PLATFORM', '指定平台(如 claude_official / kimi / deepseek)'],
|
|
27
|
+
['--verbose', '显示详细信息']
|
|
28
|
+
].concat(super)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(argv)
|
|
32
|
+
@platform = argv.option('platform')
|
|
33
|
+
@verbose = argv.flag?('verbose')
|
|
34
|
+
|
|
35
|
+
super
|
|
36
|
+
|
|
37
|
+
remaining = @argv.remainder!
|
|
38
|
+
@local_config_file = extract_local_config_file!(remaining)
|
|
39
|
+
@passthrough_args = remaining
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def validate!
|
|
43
|
+
super
|
|
44
|
+
return if Base::SystemInfo.which_command(exec_command)
|
|
45
|
+
|
|
46
|
+
help! "未找到 #{exec_command} CLI。#{install_hint}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def run
|
|
50
|
+
cfg = resolve_config
|
|
51
|
+
print_platform_env(cfg)
|
|
52
|
+
env = build_environment(cfg)
|
|
53
|
+
export_environment_variables(env)
|
|
54
|
+
pre_exec
|
|
55
|
+
exec(env, exec_command, *@passthrough_args)
|
|
56
|
+
rescue Config::LocalConfig::NotFoundError => e
|
|
57
|
+
print_error(e.message)
|
|
58
|
+
puts " 请运行: #{'easyai setup'.yellow}"
|
|
59
|
+
exit 1
|
|
60
|
+
rescue Config::LocalConfig::PlatformNotFoundError => e
|
|
61
|
+
print_error(e.message)
|
|
62
|
+
available = Config::LocalConfig.available_platforms(tool_name)
|
|
63
|
+
puts " 当前可用平台:#{available.join(', ')}" unless available.empty?
|
|
64
|
+
exit 1
|
|
65
|
+
rescue Config::LocalConfig::ToolNotConfiguredError => e
|
|
66
|
+
print_error(e.message)
|
|
67
|
+
puts " 请运行: #{"easyai setup --tool=#{tool_name}".yellow}"
|
|
68
|
+
exit 1
|
|
69
|
+
rescue Config::LocalConfig::ParseError, Config::LocalConfig::IncompatibleVersionError => e
|
|
70
|
+
print_error(e.message)
|
|
71
|
+
puts " 可运行: #{'easyai setup --reset'.yellow} 重新生成配置"
|
|
72
|
+
exit 1
|
|
73
|
+
rescue Interrupt
|
|
74
|
+
puts
|
|
75
|
+
print_error('用户取消操作')
|
|
76
|
+
exit 130
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# 子类必须实现的抽象方法
|
|
80
|
+
def tool_name
|
|
81
|
+
raise NotImplementedError, "#{self.class} 必须实现 #tool_name"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def exec_command
|
|
85
|
+
raise NotImplementedError, "#{self.class} 必须实现 #exec_command"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def install_hint
|
|
89
|
+
raise NotImplementedError, "#{self.class} 必须实现 #install_hint"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# 子类可选重写:返回该工具固定要注入的环境变量。
|
|
93
|
+
# 优先级低于 cfg.env(用户在 config.json 中显式声明的同名 key 会覆盖默认值)。
|
|
94
|
+
def default_env
|
|
95
|
+
{}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# 子类可选重写:在 exec 启动子进程之前执行的钩子(如:写入子进程依赖的状态文件)。
|
|
99
|
+
# 默认 no-op;不应阻塞主流程,异常应降级为 warning。
|
|
100
|
+
def pre_exec
|
|
101
|
+
# no-op
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# 从 remainder 中识别第一个以 .json 结尾且文件存在的位置参数作为本地覆盖入口
|
|
107
|
+
def extract_local_config_file!(remaining)
|
|
108
|
+
idx = remaining.find_index { |arg| arg.is_a?(String) && arg.end_with?('.json') && File.file?(arg) }
|
|
109
|
+
return nil if idx.nil?
|
|
110
|
+
|
|
111
|
+
remaining.delete_at(idx)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# 解析最终配置(hash with env/proxy 字段)。
|
|
115
|
+
# 选定平台 / 加载本地覆盖文件后,统一打印一行绿色 ✓ 提示,让用户明确看到当前生效的来源。
|
|
116
|
+
def resolve_config
|
|
117
|
+
if @local_config_file
|
|
118
|
+
cfg = load_local_override(@local_config_file)
|
|
119
|
+
print_success("使用本地覆盖配置 #{@local_config_file}(工具:#{tool_name})")
|
|
120
|
+
cfg
|
|
121
|
+
else
|
|
122
|
+
Config::LocalConfig.resolve_platform(tool: tool_name, platform: @platform, verbose: @verbose) do |name|
|
|
123
|
+
print_success("使用平台 #{name}(工具:#{tool_name})")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def load_local_override(path)
|
|
129
|
+
raw = File.read(path)
|
|
130
|
+
data = JSON.parse(raw)
|
|
131
|
+
unless data.is_a?(Hash)
|
|
132
|
+
raise Config::LocalConfig::ParseError, "本地覆盖配置必须是 JSON 对象:#{path}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
data
|
|
136
|
+
rescue JSON::ParserError => e
|
|
137
|
+
raise Config::LocalConfig::ParseError, "解析本地覆盖配置失败 #{path}: #{e.message}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# 构造注入子进程的 env:当前 ENV + default_env(子类钩子)+ config.env + proxy(同时大小写),
|
|
141
|
+
# 保护系统关键变量。优先级从低到高:当前 ENV → default_env → cfg.env → proxy。
|
|
142
|
+
def build_environment(cfg)
|
|
143
|
+
env = ENV.to_h
|
|
144
|
+
|
|
145
|
+
default_env.each do |key, value|
|
|
146
|
+
next if PROTECTED_ENV_KEYS.include?(key)
|
|
147
|
+
|
|
148
|
+
env[key.to_s] = value.to_s
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
cfg_env = cfg.is_a?(Hash) ? cfg['env'] : nil
|
|
152
|
+
if cfg_env.is_a?(Hash)
|
|
153
|
+
cfg_env.each do |key, value|
|
|
154
|
+
next if PROTECTED_ENV_KEYS.include?(key)
|
|
155
|
+
|
|
156
|
+
env[key.to_s] = value.to_s
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
proxy = cfg.is_a?(Hash) ? cfg['proxy'] : nil
|
|
161
|
+
if proxy.is_a?(Hash)
|
|
162
|
+
if (http_proxy = proxy['HTTP_PROXY'] || proxy['http_proxy'])
|
|
163
|
+
env['HTTP_PROXY'] = http_proxy.to_s
|
|
164
|
+
env['http_proxy'] = http_proxy.to_s
|
|
165
|
+
end
|
|
166
|
+
if (https_proxy = proxy['HTTPS_PROXY'] || proxy['https_proxy'])
|
|
167
|
+
env['HTTPS_PROXY'] = https_proxy.to_s
|
|
168
|
+
env['https_proxy'] = https_proxy.to_s
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
env
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# 把 env 写入当前进程 ENV,但保护 PATH/HOME/USER/SHELL 不被覆盖
|
|
176
|
+
def export_environment_variables(env)
|
|
177
|
+
env.each do |key, value|
|
|
178
|
+
next if PROTECTED_ENV_KEYS.include?(key)
|
|
179
|
+
|
|
180
|
+
ENV[key] = value
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# 输出辅助方法(与 Setup / Clean 等命令保持一致)
|
|
185
|
+
def print_success(message)
|
|
186
|
+
puts " ✓ #{message.green}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def print_error(message)
|
|
190
|
+
puts " ✗ #{message.red}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# 打印当前平台的 env / proxy(敏感字段走 Base::SecretMasker 脱敏),
|
|
194
|
+
# 让用户在 exec 前肉眼确认实际生效的配置,又不暴露 API Key 明文。
|
|
195
|
+
def print_platform_env(cfg)
|
|
196
|
+
return unless cfg.is_a?(Hash)
|
|
197
|
+
|
|
198
|
+
env = cfg['env']
|
|
199
|
+
proxy = cfg['proxy']
|
|
200
|
+
return if (env.nil? || env.empty?) && (proxy.nil? || proxy.empty?)
|
|
201
|
+
|
|
202
|
+
if env.is_a?(Hash) && !env.empty?
|
|
203
|
+
puts ' env:'.cyan
|
|
204
|
+
env.keys.sort.each do |key|
|
|
205
|
+
puts " #{key} = #{Base::SecretMasker.format_value(key, env[key])}"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
if proxy.is_a?(Hash) && !proxy.empty?
|
|
210
|
+
puts ' proxy:'.cyan
|
|
211
|
+
proxy.keys.sort.each do |key|
|
|
212
|
+
puts " #{key} = #{proxy[key]}"
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module EasyAI
|
|
6
|
+
class Command
|
|
7
|
+
class Backup
|
|
8
|
+
class Claude < Backup
|
|
9
|
+
self.summary = '备份 Claude Code 登录信息'
|
|
10
|
+
self.description = <<-DESC
|
|
11
|
+
读取 ~/.claude.json(去除顶层 projects)+ macOS Keychain 中 "Claude Code-credentials" 条目,
|
|
12
|
+
按字段级深度 merge 更新到 ~/.easyai/backup/.claude.json。
|
|
13
|
+
|
|
14
|
+
特点:
|
|
15
|
+
|
|
16
|
+
* 字段级深度 merge:嵌套对象内部字段一对一合并,源字段覆盖/新增到目标,目标独有字段保留
|
|
17
|
+
|
|
18
|
+
* 排除 projects:避免会话历史等大数据进入备份
|
|
19
|
+
|
|
20
|
+
* 包含 Keychain token:macOS 上额外读取 "Claude Code-credentials" 条目(含 OAuth access/refresh token),
|
|
21
|
+
存放在备份文件的 _easyai_keychain 字段下;非 macOS 环境跳过此步
|
|
22
|
+
|
|
23
|
+
* 安全权限:备份文件 chmod 600(仅当前用户可读写)
|
|
24
|
+
|
|
25
|
+
使用示例:
|
|
26
|
+
|
|
27
|
+
$ easyai backup claude
|
|
28
|
+
DESC
|
|
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
|
+
EXCLUDED_KEYS = %w[projects].freeze
|
|
34
|
+
KEYCHAIN_LABEL = 'Claude Code-credentials'.freeze
|
|
35
|
+
KEYCHAIN_BACKUP_KEY = '_easyai_keychain'.freeze
|
|
36
|
+
|
|
37
|
+
def validate!
|
|
38
|
+
super
|
|
39
|
+
help! "源文件不存在: #{SOURCE_PATH}\n请先登录 Claude Code 后再备份。" unless File.exist?(SOURCE_PATH)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run
|
|
43
|
+
source = read_json(SOURCE_PATH)
|
|
44
|
+
EXCLUDED_KEYS.each { |key| source.delete(key) }
|
|
45
|
+
|
|
46
|
+
keychain_value = read_keychain_credential(KEYCHAIN_LABEL)
|
|
47
|
+
if keychain_value
|
|
48
|
+
source[KEYCHAIN_BACKUP_KEY] = { KEYCHAIN_LABEL => keychain_value }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
target = File.exist?(BACKUP_PATH) ? read_json(BACKUP_PATH) : {}
|
|
52
|
+
before_keys = target.keys
|
|
53
|
+
|
|
54
|
+
merged = deep_merge(target, source)
|
|
55
|
+
|
|
56
|
+
FileUtils.mkdir_p(BACKUP_DIR)
|
|
57
|
+
File.write(BACKUP_PATH, JSON.pretty_generate(merged))
|
|
58
|
+
File.chmod(0o600, BACKUP_PATH) unless windows?
|
|
59
|
+
|
|
60
|
+
report(before_keys, source.keys, merged.keys, keychain_value)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def read_json(path)
|
|
66
|
+
JSON.parse(File.read(path))
|
|
67
|
+
rescue JSON::ParserError => e
|
|
68
|
+
raise "解析 JSON 失败 (#{path}): #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# 深度递归 merge:嵌套 hash 逐层合并,叶子值由源覆盖目标
|
|
72
|
+
def deep_merge(target, source)
|
|
73
|
+
target.merge(source) do |_key, old_val, new_val|
|
|
74
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
75
|
+
deep_merge(old_val, new_val)
|
|
76
|
+
else
|
|
77
|
+
new_val
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# 读取 macOS Keychain 中指定 label 的 generic password 值。
|
|
83
|
+
# 非 macOS 平台返回 nil;找不到条目或无权限访问也返回 nil(仅 warning,不阻塞备份)。
|
|
84
|
+
def read_keychain_credential(label)
|
|
85
|
+
return nil unless macos?
|
|
86
|
+
|
|
87
|
+
output, status = Open3.capture2('security', 'find-generic-password', '-l', label, '-w')
|
|
88
|
+
return output.strip if status.success? && !output.strip.empty?
|
|
89
|
+
|
|
90
|
+
warn " ⚠ 未读到 Keychain 条目「#{label}」(exit=#{status.exitstatus});本次备份不含 Keychain token"
|
|
91
|
+
nil
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
warn " ⚠ 读取 Keychain 异常:#{e.message};本次备份不含 Keychain token"
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def report(before_keys, source_keys, after_keys, keychain_value)
|
|
98
|
+
updated = source_keys & before_keys
|
|
99
|
+
added = source_keys - before_keys
|
|
100
|
+
kept = before_keys - source_keys
|
|
101
|
+
|
|
102
|
+
puts "✓ Claude 登录信息已备份"
|
|
103
|
+
puts " 源文件: #{SOURCE_PATH}"
|
|
104
|
+
puts " 备份路径: #{BACKUP_PATH}"
|
|
105
|
+
puts " 排除字段: #{EXCLUDED_KEYS.join(', ')}"
|
|
106
|
+
puts " 字段更新: 新增 #{added.size},更新 #{updated.size},保留目标独有 #{kept.size}(合计 #{after_keys.size})"
|
|
107
|
+
if keychain_value
|
|
108
|
+
puts " Keychain: 已读取「#{KEYCHAIN_LABEL}」(#{keychain_value.bytesize} 字节),写入 #{KEYCHAIN_BACKUP_KEY} 字段"
|
|
109
|
+
else
|
|
110
|
+
puts " Keychain: 未包含(非 macOS 或未找到该条目)"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def windows?
|
|
115
|
+
Base::SystemInfo.windows?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def macos?
|
|
119
|
+
Base::SystemInfo.macos?
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module EasyAI
|
|
2
|
+
class Command
|
|
3
|
+
class Backup < Command
|
|
4
|
+
self.summary = '备份 AI CLI 登录信息'
|
|
5
|
+
self.description = <<-DESC
|
|
6
|
+
备份各家 AI CLI 工具的登录信息到 ~/.easyai/backup/,便于跨机器迁移或灾难恢复。
|
|
7
|
+
|
|
8
|
+
可用命令:
|
|
9
|
+
|
|
10
|
+
* claude - 备份 Claude Code 登录信息
|
|
11
|
+
|
|
12
|
+
使用示例:
|
|
13
|
+
|
|
14
|
+
$ easyai backup claude # 备份 ~/.claude.json(去除 projects)到 ~/.easyai/backup/.claude.json
|
|
15
|
+
DESC
|
|
16
|
+
|
|
17
|
+
self.abstract_command = true
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# 加载子命令
|
|
23
|
+
require_relative 'backup/claude'
|