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.
- checksums.yaml +4 -4
- data/AGENTS.md +10 -8
- data/CLAUDE.md +221 -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/base/toml_sections.rb +105 -0
- data/lib/easyai/command/ai_tool_base.rb +228 -0
- data/lib/easyai/command/backup/claude.rb +140 -0
- data/lib/easyai/command/backup/codex.rb +206 -0
- data/lib/easyai/command/backup.rb +26 -0
- data/lib/easyai/command/claude.rb +75 -357
- data/lib/easyai/command/clean.rb +88 -395
- data/lib/easyai/command/codex.rb +269 -0
- data/lib/easyai/command/restore/claude.rb +150 -0
- data/lib/easyai/command/restore/codex.rb +165 -0
- data/lib/easyai/command/restore.rb +27 -0
- data/lib/easyai/command/setup.rb +524 -375
- 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 +190 -0
- data/lib/easyai/version.rb +1 -1
- data/lib/easyai.rb +29 -36
- metadata +23 -38
- 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/gemini.rb +0 -56
- 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,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require_relative 'ai_tool_base'
|
|
6
|
+
require_relative 'backup'
|
|
7
|
+
require_relative 'restore'
|
|
8
|
+
require_relative '../base/system_info'
|
|
9
|
+
|
|
10
|
+
module EasyAI
|
|
11
|
+
class Command
|
|
12
|
+
# v2.0 新增:替代 v1.x 的 `easyai gpt` 子命令,启动 OpenAI Codex CLI(codex 二进制)。
|
|
13
|
+
class Codex < AIToolBase
|
|
14
|
+
self.summary = '启动 Codex 命令行(多平台支持,v2.0 替代 easyai gpt)'
|
|
15
|
+
self.description = <<-DESC
|
|
16
|
+
启动 OpenAI Codex CLI(v2.0 起替代 v1.x 的 `easyai gpt`),从 ~/.easyai/config.json 读取平台配置,并将凭证 / 代理仅注入子进程。
|
|
17
|
+
|
|
18
|
+
使用示例:
|
|
19
|
+
|
|
20
|
+
$ easyai codex # 多平台时进入交互选择
|
|
21
|
+
$ easyai codex --platform=openai_official
|
|
22
|
+
$ easyai codex ./adhoc.json # 用一次性 JSON 覆盖(单平台扁平 schema)
|
|
23
|
+
$ easyai codex -- --help # 透传参数给 codex CLI
|
|
24
|
+
DESC
|
|
25
|
+
|
|
26
|
+
self.arguments = [
|
|
27
|
+
CLAide::Argument.new('CONFIG.json', false),
|
|
28
|
+
CLAide::Argument.new('ARGS', false, true)
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
def tool_name
|
|
32
|
+
'codex'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def exec_command
|
|
36
|
+
'codex'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def install_hint
|
|
40
|
+
'未找到 codex CLI。请安装:npm install -g @openai/codex'
|
|
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
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'etc'
|
|
5
|
+
|
|
6
|
+
module EasyAI
|
|
7
|
+
class Command
|
|
8
|
+
class Restore
|
|
9
|
+
class Claude < Restore
|
|
10
|
+
self.summary = '恢复 Claude Code 登录信息'
|
|
11
|
+
self.description = <<-DESC
|
|
12
|
+
读取 ~/.easyai/backup/.claude.json:取出 _easyai_keychain 字段写回 macOS Keychain,
|
|
13
|
+
剩余字段按字段级深度 merge 写回 ~/.claude.json。
|
|
14
|
+
|
|
15
|
+
特点:
|
|
16
|
+
|
|
17
|
+
* 深度递归 merge:嵌套对象内部字段一对一合并;备份字段覆盖/新增,目标独有保留
|
|
18
|
+
|
|
19
|
+
* Keychain 恢复:macOS 上把 _easyai_keychain.<label> 的值写回对应 Keychain 条目(如 "Claude Code-credentials");
|
|
20
|
+
_easyai_keychain 字段不写入 ~/.claude.json,保持纯净
|
|
21
|
+
|
|
22
|
+
* 目标独有保留:~/.claude.json 里的 projects 等运行时数据原样保留,不被清空
|
|
23
|
+
|
|
24
|
+
* 安全权限:恢复后的 ~/.claude.json 仍 chmod 600
|
|
25
|
+
|
|
26
|
+
使用示例:
|
|
27
|
+
|
|
28
|
+
$ easyai restore claude
|
|
29
|
+
DESC
|
|
30
|
+
|
|
31
|
+
BACKUP_PATH = File.expand_path('~/.easyai/backup/.claude.json').freeze
|
|
32
|
+
TARGET_PATH = File.expand_path('~/.claude.json').freeze
|
|
33
|
+
KEYCHAIN_BACKUP_KEY = '_easyai_keychain'.freeze
|
|
34
|
+
|
|
35
|
+
def validate!
|
|
36
|
+
super
|
|
37
|
+
help! "备份文件不存在: #{BACKUP_PATH}\n请先运行 easyai backup claude 创建备份。" unless File.exist?(BACKUP_PATH)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run
|
|
41
|
+
source = read_json(BACKUP_PATH)
|
|
42
|
+
|
|
43
|
+
keychain_results = restore_keychain(source.delete(KEYCHAIN_BACKUP_KEY))
|
|
44
|
+
|
|
45
|
+
target = File.exist?(TARGET_PATH) ? read_json(TARGET_PATH) : {}
|
|
46
|
+
before_keys = target.keys
|
|
47
|
+
|
|
48
|
+
merged = deep_merge(target, source)
|
|
49
|
+
|
|
50
|
+
File.write(TARGET_PATH, JSON.pretty_generate(merged))
|
|
51
|
+
File.chmod(0o600, TARGET_PATH) unless windows?
|
|
52
|
+
|
|
53
|
+
report(before_keys, source.keys, merged.keys, keychain_results)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def read_json(path)
|
|
59
|
+
JSON.parse(File.read(path))
|
|
60
|
+
rescue JSON::ParserError => e
|
|
61
|
+
raise "解析 JSON 失败 (#{path}): #{e.message}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# 深度递归 merge:嵌套 hash 逐层合并,叶子值由源覆盖目标
|
|
65
|
+
def deep_merge(target, source)
|
|
66
|
+
target.merge(source) do |_key, old_val, new_val|
|
|
67
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
68
|
+
deep_merge(old_val, new_val)
|
|
69
|
+
else
|
|
70
|
+
new_val
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# 把备份中的 _easyai_keychain hash 逐项写回 macOS Keychain。
|
|
76
|
+
# 返回 { label => :ok | :fail | :skip } 用于 report;
|
|
77
|
+
# 非 macOS / 备份无该字段时直接跳过,返回空 hash。
|
|
78
|
+
def restore_keychain(keychain_data)
|
|
79
|
+
return {} unless keychain_data.is_a?(Hash) && !keychain_data.empty?
|
|
80
|
+
|
|
81
|
+
unless macos?
|
|
82
|
+
warn " ⚠ 非 macOS 环境,跳过 Keychain 恢复(备份中含 #{keychain_data.size} 个 Keychain 条目)"
|
|
83
|
+
return keychain_data.keys.each_with_object({}) { |k, h| h[k] = :skip }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
account = ENV['USER'] || (Etc.getlogin rescue nil)
|
|
87
|
+
if account.nil? || account.empty?
|
|
88
|
+
warn " ⚠ 无法确定当前用户名,跳过 Keychain 恢复"
|
|
89
|
+
return keychain_data.keys.each_with_object({}) { |k, h| h[k] = :skip }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
keychain_data.each_with_object({}) do |(label, password), results|
|
|
93
|
+
results[label] = write_keychain_credential(label, password, account)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# 写入 Keychain(如已存在则更新)。security 命令的 -w <password> 在命令行参数中可见,
|
|
98
|
+
# 但本机用户跑本机命令的场景下风险可控;macOS 上 ps 默认不展示其他用户进程的完整 cmdline。
|
|
99
|
+
def write_keychain_credential(label, password, account)
|
|
100
|
+
unless password.is_a?(String) && !password.empty?
|
|
101
|
+
warn " ⚠ Keychain 条目「#{label}」备份值无效(非字符串或为空),跳过"
|
|
102
|
+
return :fail
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
args = ['security', 'add-generic-password',
|
|
106
|
+
'-a', account,
|
|
107
|
+
'-s', label,
|
|
108
|
+
'-l', label,
|
|
109
|
+
'-w', password,
|
|
110
|
+
'-U']
|
|
111
|
+
output, status = Open3.capture2e(*args)
|
|
112
|
+
if status.success?
|
|
113
|
+
:ok
|
|
114
|
+
else
|
|
115
|
+
warn " ⚠ 写入 Keychain 条目「#{label}」失败(exit=#{status.exitstatus}):#{output.strip}"
|
|
116
|
+
:fail
|
|
117
|
+
end
|
|
118
|
+
rescue StandardError => e
|
|
119
|
+
warn " ⚠ 写入 Keychain 条目「#{label}」异常:#{e.message}"
|
|
120
|
+
:fail
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def report(before_keys, source_keys, after_keys, keychain_results)
|
|
124
|
+
updated = source_keys & before_keys
|
|
125
|
+
added = source_keys - before_keys
|
|
126
|
+
kept = before_keys - source_keys
|
|
127
|
+
|
|
128
|
+
puts "✓ Claude 登录信息已恢复"
|
|
129
|
+
puts " 备份文件: #{BACKUP_PATH}"
|
|
130
|
+
puts " 目标路径: #{TARGET_PATH}"
|
|
131
|
+
puts " 字段更新: 新增 #{added.size},更新 #{updated.size},保留目标独有 #{kept.size}(合计 #{after_keys.size})"
|
|
132
|
+
if keychain_results.empty?
|
|
133
|
+
puts " Keychain: 备份中未含 #{KEYCHAIN_BACKUP_KEY} 字段,无需恢复"
|
|
134
|
+
else
|
|
135
|
+
ok = keychain_results.count { |_, v| v == :ok }
|
|
136
|
+
puts " Keychain: 恢复 #{ok}/#{keychain_results.size} 个条目(#{keychain_results.map { |k, v| "#{k}=#{v}" }.join(', ')})"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def windows?
|
|
141
|
+
Base::SystemInfo.windows?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def macos?
|
|
145
|
+
Base::SystemInfo.macos?
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
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
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module EasyAI
|
|
2
|
+
class Command
|
|
3
|
+
class Restore < Command
|
|
4
|
+
self.summary = '从 ~/.easyai/backup/ 恢复 AI CLI 登录信息'
|
|
5
|
+
self.description = <<-DESC
|
|
6
|
+
把 easyai backup 备份的登录信息深度合并回各家 AI CLI 的原始配置文件。
|
|
7
|
+
合并策略:备份字段覆盖/新增到目标,目标独有字段(如 Claude 的 projects)保留。
|
|
8
|
+
|
|
9
|
+
可用命令:
|
|
10
|
+
|
|
11
|
+
* claude - 恢复 Claude Code 登录信息
|
|
12
|
+
* codex - 恢复 Codex 登录信息
|
|
13
|
+
|
|
14
|
+
使用示例:
|
|
15
|
+
|
|
16
|
+
$ easyai restore claude # 把 ~/.easyai/backup/.claude.json 深度 merge 回 ~/.claude.json
|
|
17
|
+
$ easyai restore codex # 把 ~/.easyai/backup/.codex.json 合并回 ~/.codex/(保留本地 [projects.*])
|
|
18
|
+
DESC
|
|
19
|
+
|
|
20
|
+
self.abstract_command = true
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# 加载子命令
|
|
26
|
+
require_relative 'restore/claude'
|
|
27
|
+
require_relative 'restore/codex'
|