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.
@@ -0,0 +1,228 @@
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
+ @resolved_config = cfg # 供子类 pre_exec 读取当前生效的平台配置
52
+ print_platform_env(cfg)
53
+ @env = build_environment(cfg) # 存 ivar:pre_exec 可按需追加(如 codex 注入 CODEX_HOME)
54
+ export_environment_variables(@env)
55
+ pre_exec
56
+ exec(@env, exec_command, *@passthrough_args)
57
+ rescue Config::LocalConfig::NotFoundError => e
58
+ print_error(e.message)
59
+ puts " 请运行: #{'easyai setup'.yellow}"
60
+ exit 1
61
+ rescue Config::LocalConfig::PlatformNotFoundError => e
62
+ print_error(e.message)
63
+ available = Config::LocalConfig.available_platforms(tool_name)
64
+ puts " 当前可用平台:#{available.join(', ')}" unless available.empty?
65
+ exit 1
66
+ rescue Config::LocalConfig::ToolNotConfiguredError => e
67
+ print_error(e.message)
68
+ puts " 请运行: #{"easyai setup --tool=#{tool_name}".yellow}"
69
+ exit 1
70
+ rescue Config::LocalConfig::ParseError, Config::LocalConfig::IncompatibleVersionError => e
71
+ print_error(e.message)
72
+ puts " 可运行: #{'easyai setup --reset'.yellow} 重新生成配置"
73
+ exit 1
74
+ rescue Interrupt
75
+ puts
76
+ print_error('用户取消操作')
77
+ exit 130
78
+ end
79
+
80
+ # 子类必须实现的抽象方法
81
+ def tool_name
82
+ raise NotImplementedError, "#{self.class} 必须实现 #tool_name"
83
+ end
84
+
85
+ def exec_command
86
+ raise NotImplementedError, "#{self.class} 必须实现 #exec_command"
87
+ end
88
+
89
+ def install_hint
90
+ raise NotImplementedError, "#{self.class} 必须实现 #install_hint"
91
+ end
92
+
93
+ # 子类可选重写:返回该工具固定要注入的环境变量。
94
+ # 优先级低于 cfg.env(用户在 config.json 中显式声明的同名 key 会覆盖默认值)。
95
+ def default_env
96
+ {}
97
+ end
98
+
99
+ # 子类可选重写:在 exec 启动子进程之前执行的钩子(如:写入子进程依赖的状态文件、
100
+ # 追加只对本次子进程生效的环境变量到 @env,如 codex 注入 CODEX_HOME)。
101
+ # 默认 no-op;不应阻塞主流程,异常应降级为 warning。
102
+ def pre_exec
103
+ # no-op
104
+ end
105
+
106
+ # 子类可选重写:向平台选择列表注入的虚拟平台 { key => data }(如 codex 的 ChatGPT Subscribe)。
107
+ # 默认空;与 config.json 真实平台合并参与交互选择。
108
+ def extra_platforms
109
+ {}
110
+ end
111
+
112
+ private
113
+
114
+ # 从 remainder 中识别第一个以 .json 结尾且文件存在的位置参数作为本地覆盖入口
115
+ def extract_local_config_file!(remaining)
116
+ idx = remaining.find_index { |arg| arg.is_a?(String) && arg.end_with?('.json') && File.file?(arg) }
117
+ return nil if idx.nil?
118
+
119
+ remaining.delete_at(idx)
120
+ end
121
+
122
+ # 解析最终配置(hash with env/proxy 字段)。
123
+ # 选定平台 / 加载本地覆盖文件后,统一打印一行绿色 ✓ 提示,让用户明确看到当前生效的来源。
124
+ def resolve_config
125
+ if @local_config_file
126
+ cfg = load_local_override(@local_config_file)
127
+ print_success("使用本地覆盖配置 #{@local_config_file}(工具:#{tool_name})")
128
+ cfg
129
+ else
130
+ Config::LocalConfig.resolve_platform(
131
+ tool: tool_name, platform: @platform, verbose: @verbose, extra: extra_platforms
132
+ ) do |name|
133
+ print_success("使用平台 #{name}(工具:#{tool_name})")
134
+ end
135
+ end
136
+ end
137
+
138
+ def load_local_override(path)
139
+ raw = File.read(path)
140
+ data = JSON.parse(raw)
141
+ unless data.is_a?(Hash)
142
+ raise Config::LocalConfig::ParseError, "本地覆盖配置必须是 JSON 对象:#{path}"
143
+ end
144
+
145
+ data
146
+ rescue JSON::ParserError => e
147
+ raise Config::LocalConfig::ParseError, "解析本地覆盖配置失败 #{path}: #{e.message}"
148
+ end
149
+
150
+ # 构造注入子进程的 env:当前 ENV + default_env(子类钩子)+ config.env + proxy(同时大小写),
151
+ # 保护系统关键变量。优先级从低到高:当前 ENV → default_env → cfg.env → proxy。
152
+ def build_environment(cfg)
153
+ env = ENV.to_h
154
+
155
+ default_env.each do |key, value|
156
+ next if PROTECTED_ENV_KEYS.include?(key)
157
+
158
+ env[key.to_s] = value.to_s
159
+ end
160
+
161
+ cfg_env = cfg.is_a?(Hash) ? cfg['env'] : nil
162
+ if cfg_env.is_a?(Hash)
163
+ cfg_env.each do |key, value|
164
+ next if PROTECTED_ENV_KEYS.include?(key)
165
+
166
+ env[key.to_s] = value.to_s
167
+ end
168
+ end
169
+
170
+ proxy = cfg.is_a?(Hash) ? cfg['proxy'] : nil
171
+ if proxy.is_a?(Hash)
172
+ if (http_proxy = proxy['HTTP_PROXY'] || proxy['http_proxy'])
173
+ env['HTTP_PROXY'] = http_proxy.to_s
174
+ env['http_proxy'] = http_proxy.to_s
175
+ end
176
+ if (https_proxy = proxy['HTTPS_PROXY'] || proxy['https_proxy'])
177
+ env['HTTPS_PROXY'] = https_proxy.to_s
178
+ env['https_proxy'] = https_proxy.to_s
179
+ end
180
+ end
181
+
182
+ env
183
+ end
184
+
185
+ # 把 env 写入当前进程 ENV,但保护 PATH/HOME/USER/SHELL 不被覆盖
186
+ def export_environment_variables(env)
187
+ env.each do |key, value|
188
+ next if PROTECTED_ENV_KEYS.include?(key)
189
+
190
+ ENV[key] = value
191
+ end
192
+ end
193
+
194
+ # 输出辅助方法(与 Setup / Clean 等命令保持一致)
195
+ def print_success(message)
196
+ puts " ✓ #{message.green}"
197
+ end
198
+
199
+ def print_error(message)
200
+ puts " ✗ #{message.red}"
201
+ end
202
+
203
+ # 打印当前平台的 env / proxy(敏感字段走 Base::SecretMasker 脱敏),
204
+ # 让用户在 exec 前肉眼确认实际生效的配置,又不暴露 API Key 明文。
205
+ def print_platform_env(cfg)
206
+ return unless cfg.is_a?(Hash)
207
+
208
+ env = cfg['env']
209
+ proxy = cfg['proxy']
210
+ return if (env.nil? || env.empty?) && (proxy.nil? || proxy.empty?)
211
+
212
+ if env.is_a?(Hash) && !env.empty?
213
+ puts ' env:'.cyan
214
+ env.keys.sort.each do |key|
215
+ puts " #{key} = #{Base::SecretMasker.format_value(key, env[key])}"
216
+ end
217
+ end
218
+
219
+ if proxy.is_a?(Hash) && !proxy.empty?
220
+ puts ' proxy:'.cyan
221
+ proxy.keys.sort.each do |key|
222
+ puts " #{key} = #{proxy[key]}"
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,140 @@
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
+ EXCLUDED_KEYS = %w[projects].freeze
31
+ KEYCHAIN_LABEL = 'Claude Code-credentials'.freeze
32
+ KEYCHAIN_BACKUP_KEY = '_easyai_keychain'.freeze
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
+
40
+ def validate!
41
+ super
42
+ help! "源文件不存在: #{source_path}\n请先登录 Claude Code 后再备份。" unless File.exist?(source_path)
43
+ end
44
+
45
+ def run
46
+ source = read_json(source_path)
47
+ EXCLUDED_KEYS.each { |key| source.delete(key) }
48
+
49
+ keychain_value = read_keychain_credential(KEYCHAIN_LABEL)
50
+ if keychain_value
51
+ source[KEYCHAIN_BACKUP_KEY] = { KEYCHAIN_LABEL => keychain_value }
52
+ end
53
+
54
+ target = File.exist?(backup_path) ? read_json(backup_path) : {}
55
+ before_keys = target.keys
56
+
57
+ merged = deep_merge(target, source)
58
+
59
+ FileUtils.mkdir_p(backup_dir)
60
+ File.write(backup_path, JSON.pretty_generate(merged))
61
+ File.chmod(0o600, backup_path) unless windows?
62
+
63
+ report(before_keys, source.keys, merged.keys, keychain_value)
64
+ end
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
+
79
+ private
80
+
81
+ def read_json(path)
82
+ JSON.parse(File.read(path))
83
+ rescue JSON::ParserError => e
84
+ raise "解析 JSON 失败 (#{path}): #{e.message}"
85
+ end
86
+
87
+ # 深度递归 merge:嵌套 hash 逐层合并,叶子值由源覆盖目标
88
+ def deep_merge(target, source)
89
+ target.merge(source) do |_key, old_val, new_val|
90
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
91
+ deep_merge(old_val, new_val)
92
+ else
93
+ new_val
94
+ end
95
+ end
96
+ end
97
+
98
+ # 读取 macOS Keychain 中指定 label 的 generic password 值。
99
+ # 非 macOS 平台返回 nil;找不到条目或无权限访问也返回 nil(仅 warning,不阻塞备份)。
100
+ def read_keychain_credential(label)
101
+ return nil unless macos?
102
+
103
+ output, status = Open3.capture2('security', 'find-generic-password', '-l', label, '-w')
104
+ return output.strip if status.success? && !output.strip.empty?
105
+
106
+ warn " ⚠ 未读到 Keychain 条目「#{label}」(exit=#{status.exitstatus});本次备份不含 Keychain token"
107
+ nil
108
+ rescue StandardError => e
109
+ warn " ⚠ 读取 Keychain 异常:#{e.message};本次备份不含 Keychain token"
110
+ nil
111
+ end
112
+
113
+ def report(before_keys, source_keys, after_keys, keychain_value)
114
+ updated = source_keys & before_keys
115
+ added = source_keys - before_keys
116
+ kept = before_keys - source_keys
117
+
118
+ puts "✓ Claude 登录信息已备份"
119
+ puts " 源文件: #{source_path}"
120
+ puts " 备份路径: #{backup_path}"
121
+ puts " 排除字段: #{EXCLUDED_KEYS.join(', ')}"
122
+ puts " 字段更新: 新增 #{added.size},更新 #{updated.size},保留目标独有 #{kept.size}(合计 #{after_keys.size})"
123
+ if keychain_value
124
+ puts " Keychain: 已读取「#{KEYCHAIN_LABEL}」(#{keychain_value.bytesize} 字节),写入 #{KEYCHAIN_BACKUP_KEY} 字段"
125
+ else
126
+ puts " Keychain: 未包含(非 macOS 或未找到该条目)"
127
+ end
128
+ end
129
+
130
+ def windows?
131
+ Base::SystemInfo.windows?
132
+ end
133
+
134
+ def macos?
135
+ Base::SystemInfo.macos?
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -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
@@ -0,0 +1,26 @@
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
+ * codex - 备份 Codex 登录信息
12
+
13
+ 使用示例:
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
17
+ DESC
18
+
19
+ self.abstract_command = true
20
+ end
21
+ end
22
+ end
23
+
24
+ # 加载子命令
25
+ require_relative 'backup/claude'
26
+ require_relative 'backup/codex'