@casatwy/deyo 0.1.9 → 0.2.1

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,1346 @@
1
+ import { constants as fsConstants } from 'node:fs'
2
+ import {
3
+ access,
4
+ chmod,
5
+ lstat,
6
+ mkdir,
7
+ open,
8
+ readFile,
9
+ readdir,
10
+ readlink,
11
+ rename,
12
+ unlink,
13
+ } from 'node:fs/promises'
14
+ import { spawn } from 'node:child_process'
15
+ import { createHash, randomBytes } from 'node:crypto'
16
+ import { homedir } from 'node:os'
17
+ import { basename, dirname, isAbsolute, join, normalize, resolve } from 'node:path'
18
+ import process from 'node:process'
19
+
20
+ export const SKILL_PLATFORMS = ['codex', 'claude', 'gemini', 'openclaw']
21
+ export const SKILL_SCOPES = ['user', 'workspace', 'global']
22
+
23
+ const MARKETPLACE_NAME = 'deyo-official'
24
+ const PLUGIN_ID = `deyo@${MARKETPLACE_NAME}`
25
+ const GITHUB_REPOSITORY = 'casatwy/deyo-skill'
26
+ const GIT_SOURCE_URL = 'https://github.com/casatwy/deyo-skill.git'
27
+ const OPENCLAW_SKILL = '@casatwy/deyo'
28
+ const OPENCLAW_REGISTRY = 'https://clawhub.ai'
29
+ const STABLE_SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/
30
+
31
+ const DEFAULT_SCOPES = {
32
+ codex: 'user',
33
+ claude: 'user',
34
+ gemini: 'user',
35
+ openclaw: 'global',
36
+ }
37
+
38
+ const SUPPORTED_SCOPES = {
39
+ codex: new Set(['user']),
40
+ claude: new Set(['user', 'workspace']),
41
+ gemini: new Set(['user']),
42
+ openclaw: new Set(['workspace', 'global']),
43
+ }
44
+
45
+ // These hashes are generated with hashLegacyTree() from official deyo/ trees
46
+ // previously published in github.com/casatwy/deyo-skill. Directory entries,
47
+ // paths, file bytes, modes, and symlink targets all participate in the digest.
48
+ // Unknown trees are intentionally never migrated automatically.
49
+ const OFFICIAL_LEGACY_HASHES = new Map([
50
+ ['sha256:0dc4e4a2ac1a616d79e7deaf365dcbf4c26bf70ec5a4554806a5b68760c584ca', 'c98b981237aadae462019a822509b463ff2900cd'],
51
+ ['sha256:fe3fc055413da24e48ca4e3a3d24319fa7033f2d494df803243b175e121053ac', '6fe78e74728fbab6aba75f6acc92cee5c143af3d'],
52
+ ['sha256:38112030ae205c4c58b90e8ef7497a590b4ee3485311bd6aa1a777b4c7ed29f1', '91d001299aa82a1411e037bb267e463d171885c6'],
53
+ ['sha256:a19f75e873ff6d6e28555a08cc83388e3d7a5cace25d8d59c736d07c2764a8bf', '13b2e5d555f1596810b5e6835804bd3ba18dbe4a'],
54
+ ['sha256:0e8d8b3d418bd1be05799f35bab08e40d99cae553c706371675da26adf614e38', 'f1e25f0cbfb0740d7834cce2f25f27a5d00392b1'],
55
+ ['sha256:bb2349e66a15d6f63914a079556b440e6374e16d6091bf7cb6d667ff77b4cc88', '96d5e851e14e89b8318428f580d4b15660549570'],
56
+ ['sha256:6eae6dbb865422639c9d6ab79293c803712b78e7eb223401bc2421e46cfec301', 'da0039cee32a4756378770ece55577da43cac243'],
57
+ ['sha256:3c82cc30d4376618ad3c059411fb52ffd82f14523627fcb80c6eff6f3807c5dd', 'b5c884e03f047e5372720c9fe86f2127803d0c58'],
58
+ ['sha256:d2debbabc20d4efc1e054e2e46a05e2ab809fd8a84ad325fd38018d0a0c6469e', '0442925dd7a9a18be7adaefaa351dd5b79ace607'],
59
+ ['sha256:cb3958fcd77bdf674a85153c978bccfd658ab25292bca138c0a2846580b4ea9c', 'b497324d0091e45f6cd57612e1295ea593b0c2b4'],
60
+ ['sha256:6e556a96701f00baa4eaf3d9a10a38eb4d852e6b9c853aea474286cd35565d3b', '4fed0be6d69dd99179403f02b03bf4a5f5c3f334'],
61
+ ])
62
+
63
+ class SkillManagerError extends Error {
64
+ constructor(message, code = 'skill_error') {
65
+ super(message)
66
+ this.code = code
67
+ }
68
+ }
69
+
70
+ class SkillCommandReportedError extends Error {
71
+ constructor(exitCode = 1) {
72
+ super('Deyo Skill 命令已报告错误')
73
+ this.name = 'SkillCommandReportedError'
74
+ this.exitCode = exitCode
75
+ }
76
+ }
77
+
78
+ function expandConfiguredPath(name, value, fallback, expansionHome = homedir()) {
79
+ if (!value) return normalize(fallback)
80
+ let expanded = String(value)
81
+ if (expanded === '~') expanded = expansionHome
82
+ else if (/^~[\\/]/.test(expanded)) expanded = join(expansionHome, expanded.slice(2))
83
+ if (!isAbsolute(expanded)) {
84
+ throw new SkillManagerError(`${name} 必须是绝对路径`, 'invalid_config_path')
85
+ }
86
+ return normalize(expanded)
87
+ }
88
+
89
+ function getHomeDirectory() {
90
+ if (process.env.HOME) {
91
+ return expandConfiguredPath('HOME', process.env.HOME, homedir())
92
+ }
93
+ if (process.env.USERPROFILE) {
94
+ return expandConfiguredPath('USERPROFILE', process.env.USERPROFILE, homedir())
95
+ }
96
+ return normalize(homedir())
97
+ }
98
+
99
+ function getCodexHome() {
100
+ const home = getHomeDirectory()
101
+ return expandConfiguredPath('CODEX_HOME', process.env.CODEX_HOME, join(home, '.codex'), home)
102
+ }
103
+
104
+ function getClaudeHome() {
105
+ const home = getHomeDirectory()
106
+ return expandConfiguredPath(
107
+ 'CLAUDE_CONFIG_DIR',
108
+ process.env.CLAUDE_CONFIG_DIR,
109
+ join(home, '.claude'),
110
+ home,
111
+ )
112
+ }
113
+
114
+ function getGeminiHome() {
115
+ const home = getHomeDirectory()
116
+ const geminiRoot = expandConfiguredPath(
117
+ 'GEMINI_CLI_HOME',
118
+ process.env.GEMINI_CLI_HOME,
119
+ home,
120
+ home,
121
+ )
122
+ return join(geminiRoot, '.gemini')
123
+ }
124
+
125
+ function getOpenClawHome() {
126
+ const home = getHomeDirectory()
127
+ if (process.env.OPENCLAW_STATE_DIR) {
128
+ return expandConfiguredPath(
129
+ 'OPENCLAW_STATE_DIR',
130
+ process.env.OPENCLAW_STATE_DIR,
131
+ join(home, '.openclaw'),
132
+ home,
133
+ )
134
+ }
135
+ const openClawHome = expandConfiguredPath(
136
+ 'OPENCLAW_HOME',
137
+ process.env.OPENCLAW_HOME,
138
+ home,
139
+ home,
140
+ )
141
+ return join(openClawHome, '.openclaw')
142
+ }
143
+
144
+ function getDefaultScope(platform) {
145
+ return DEFAULT_SCOPES[platform]
146
+ }
147
+
148
+ function validatePlatform(platform) {
149
+ if (!SKILL_PLATFORMS.includes(platform)) {
150
+ throw new SkillManagerError(
151
+ `--platform 必须是 ${SKILL_PLATFORMS.join('、')} 之一`,
152
+ 'invalid_platform',
153
+ )
154
+ }
155
+ }
156
+
157
+ function validateScope(platform, scope) {
158
+ if (!SKILL_SCOPES.includes(scope)) {
159
+ throw new SkillManagerError(
160
+ `--scope 必须是 ${SKILL_SCOPES.join('、')} 之一`,
161
+ 'invalid_scope',
162
+ )
163
+ }
164
+
165
+ if (!SUPPORTED_SCOPES[platform].has(scope)) {
166
+ const supported = [...SUPPORTED_SCOPES[platform]].join('、')
167
+ throw new SkillManagerError(
168
+ `${platform} 的原生安装器不支持 ${scope} scope;可用 scope:${supported}`,
169
+ 'unsupported_scope',
170
+ )
171
+ }
172
+ }
173
+
174
+ function parseSha256(value) {
175
+ const normalized = String(value || '').trim().toLowerCase()
176
+ if (!/^sha256:[a-f0-9]{64}$/.test(normalized)) {
177
+ throw new SkillManagerError(
178
+ '--replace-legacy 必须使用 status 显示的完整 sha256:<64位摘要>',
179
+ 'invalid_legacy_hash',
180
+ )
181
+ }
182
+ return normalized
183
+ }
184
+
185
+ export function printSkillUsage(stream = process.stdout) {
186
+ stream.write(`用法:
187
+ deyo skill install --platform codex|claude|gemini|openclaw
188
+ [--scope user|workspace|global]
189
+ [--replace-legacy sha256:<observed-hash>]
190
+ [--json]
191
+ deyo skill status [--platform codex|claude|gemini|openclaw] [--json]
192
+
193
+ 默认 scope:
194
+ codex=user,claude=user,gemini=user,openclaw=global
195
+
196
+ 说明:
197
+ status 只读取本地配置和 manifest,不联网、不检查更新,也不修改 cache/lockfile。
198
+ install 只调用各平台原生管理器;不会用 --force、--all、固定 --ref 或 --consent。
199
+ legacy 目录只有在完整树哈希属于官方历史版本,且 --replace-legacy 精确匹配时才会备份迁移。
200
+ `)
201
+ }
202
+
203
+ function parseSkillCommandArgs(argv) {
204
+ const requestedAction = argv[1] || ''
205
+ const action = requestedAction === '-h' || requestedAction === '--help' ? '' : requestedAction
206
+ const result = {
207
+ action,
208
+ platform: '',
209
+ scope: '',
210
+ replaceLegacy: '',
211
+ json: false,
212
+ help: requestedAction === '-h' || requestedAction === '--help',
213
+ }
214
+ const seen = new Set()
215
+
216
+ function markSeen(option) {
217
+ if (seen.has(option)) {
218
+ throw new SkillManagerError(`skill 参数不能重复:${option}`, 'duplicate_argument')
219
+ }
220
+ seen.add(option)
221
+ }
222
+
223
+ function readOptionValue(option, index) {
224
+ const value = argv[index + 1]
225
+ if (!value || value.startsWith('-')) {
226
+ throw new SkillManagerError(`${option} 缺少参数值`, 'missing_argument_value')
227
+ }
228
+ return value
229
+ }
230
+
231
+ for (let index = 2; index < argv.length; index += 1) {
232
+ const current = argv[index]
233
+ if (current === '-h' || current === '--help') {
234
+ result.help = true
235
+ continue
236
+ }
237
+ if (current === '--json') {
238
+ markSeen(current)
239
+ result.json = true
240
+ continue
241
+ }
242
+ if (current === '--platform') {
243
+ markSeen(current)
244
+ result.platform = readOptionValue(current, index)
245
+ index += 1
246
+ continue
247
+ }
248
+ if (current === '--scope') {
249
+ markSeen(current)
250
+ result.scope = readOptionValue(current, index)
251
+ index += 1
252
+ continue
253
+ }
254
+ if (current === '--replace-legacy') {
255
+ markSeen(current)
256
+ result.replaceLegacy = readOptionValue(current, index)
257
+ index += 1
258
+ continue
259
+ }
260
+ throw new SkillManagerError(`未知 skill 参数:${current}`, 'unknown_argument')
261
+ }
262
+
263
+ if (!action && !result.help) {
264
+ throw new SkillManagerError('缺少 skill 子命令 install 或 status', 'missing_action')
265
+ }
266
+ if (!['install', 'status'].includes(action) && !result.help) {
267
+ throw new SkillManagerError(`不支持的 skill 子命令:${action}`, 'invalid_action')
268
+ }
269
+ if (action === 'install' && !result.platform && !result.help) {
270
+ throw new SkillManagerError('skill install 必须提供 --platform', 'missing_platform')
271
+ }
272
+ if (action === 'status' && result.scope) {
273
+ throw new SkillManagerError('skill status 不接受 --scope;它检查各平台默认生效 scope', 'status_scope')
274
+ }
275
+ if (action === 'status' && result.replaceLegacy) {
276
+ throw new SkillManagerError('skill status 不接受 --replace-legacy', 'status_replace_legacy')
277
+ }
278
+
279
+ return result
280
+ }
281
+
282
+ async function pathLstat(path) {
283
+ try {
284
+ return await lstat(path)
285
+ } catch (error) {
286
+ if (error?.code === 'ENOENT') return null
287
+ throw error
288
+ }
289
+ }
290
+
291
+ async function readUtf8IfExists(path) {
292
+ try {
293
+ return await readFile(path, 'utf8')
294
+ } catch (error) {
295
+ if (error?.code === 'ENOENT') return null
296
+ throw error
297
+ }
298
+ }
299
+
300
+ async function readJsonIfExists(path) {
301
+ const content = await readUtf8IfExists(path)
302
+ if (content === null) return null
303
+ try {
304
+ return JSON.parse(content)
305
+ } catch {
306
+ throw new SkillManagerError(`JSON 文件无法解析:${path}`, 'invalid_json')
307
+ }
308
+ }
309
+
310
+ async function inspectJsonMetadata(path) {
311
+ const stats = await pathLstat(path)
312
+ if (!stats) return { exists: false, safe: true, value: null }
313
+ if (stats.isSymbolicLink() || !stats.isFile()) {
314
+ return { exists: true, safe: false, value: null }
315
+ }
316
+ try {
317
+ return { exists: true, safe: true, value: JSON.parse(await readFile(path, 'utf8')) }
318
+ } catch {
319
+ return { exists: true, safe: false, value: null }
320
+ }
321
+ }
322
+
323
+ function updateLengthPrefixed(hash, value) {
324
+ const buffer = Buffer.isBuffer(value) ? value : Buffer.from(String(value), 'utf8')
325
+ const length = Buffer.alloc(8)
326
+ length.writeBigUInt64BE(BigInt(buffer.length))
327
+ hash.update(length)
328
+ hash.update(buffer)
329
+ }
330
+
331
+ function updateMode(hash, mode) {
332
+ const buffer = Buffer.alloc(4)
333
+ buffer.writeUInt32BE(mode & 0o7777)
334
+ hash.update(buffer)
335
+ }
336
+
337
+ function sortNames(names) {
338
+ return [...names].sort((first, second) => Buffer.compare(
339
+ Buffer.from(first, 'utf8'),
340
+ Buffer.from(second, 'utf8'),
341
+ ))
342
+ }
343
+
344
+ export async function hashLegacyTree(rootPath) {
345
+ const rootStats = await pathLstat(rootPath)
346
+ if (!rootStats) return null
347
+
348
+ const hash = createHash('sha256')
349
+ hash.update('deyo-legacy-tree-v1\0')
350
+
351
+ async function visit(path, relativePath) {
352
+ const entryStats = await lstat(path)
353
+ if (entryStats.isDirectory()) {
354
+ hash.update('D')
355
+ updateLengthPrefixed(hash, relativePath)
356
+ updateMode(hash, entryStats.mode)
357
+ const names = sortNames(await readdir(path))
358
+ for (const name of names) {
359
+ await visit(join(path, name), relativePath === '.' ? name : `${relativePath}/${name}`)
360
+ }
361
+ return
362
+ }
363
+
364
+ if (entryStats.isFile()) {
365
+ hash.update('F')
366
+ updateLengthPrefixed(hash, relativePath)
367
+ updateMode(hash, entryStats.mode)
368
+ updateLengthPrefixed(hash, await readFile(path))
369
+ return
370
+ }
371
+
372
+ if (entryStats.isSymbolicLink()) {
373
+ hash.update('L')
374
+ updateLengthPrefixed(hash, relativePath)
375
+ updateMode(hash, entryStats.mode)
376
+ updateLengthPrefixed(hash, await readlink(path))
377
+ return
378
+ }
379
+
380
+ throw new SkillManagerError(
381
+ `legacy 路径包含不支持的文件类型:${path}`,
382
+ 'unsupported_legacy_entry',
383
+ )
384
+ }
385
+
386
+ await visit(rootPath, '.')
387
+ return `sha256:${hash.digest('hex')}`
388
+ }
389
+
390
+ function getLegacyPaths(platform, scope) {
391
+ const home = getHomeDirectory()
392
+ if (platform === 'codex') {
393
+ return [
394
+ join(getCodexHome(), 'skills', 'deyo'),
395
+ join(home, '.agents', 'skills', 'deyo'),
396
+ ]
397
+ }
398
+ if (platform === 'claude') {
399
+ return scope === 'workspace'
400
+ ? [resolve('.claude', 'skills', 'deyo')]
401
+ : [join(getClaudeHome(), 'skills', 'deyo')]
402
+ }
403
+ if (platform === 'gemini') {
404
+ return scope === 'workspace'
405
+ ? [resolve('.gemini', 'skills', 'deyo')]
406
+ : [join(getGeminiHome(), 'skills', 'deyo')]
407
+ }
408
+ return scope === 'global'
409
+ ? [join(getOpenClawHome(), 'skills', 'deyo')]
410
+ : [resolve('skills', 'deyo')]
411
+ }
412
+
413
+ function getOpenClawOriginPath(skillPath) {
414
+ return join(skillPath, '.clawhub', 'origin.json')
415
+ }
416
+
417
+ async function inspectLegacy(platform, scope) {
418
+ const entries = []
419
+ for (const path of getLegacyPaths(platform, scope)) {
420
+ const stats = await pathLstat(path)
421
+ if (!stats) continue
422
+ if (platform === 'openclaw' && stats.isDirectory() && !stats.isSymbolicLink()) {
423
+ const originStats = await pathLstat(getOpenClawOriginPath(path))
424
+ if (originStats?.isFile() && !originStats.isSymbolicLink()) continue
425
+ }
426
+ const hash = await hashLegacyTree(path)
427
+ entries.push({
428
+ path,
429
+ hash,
430
+ recognized: OFFICIAL_LEGACY_HASHES.has(hash),
431
+ officialRevision: OFFICIAL_LEGACY_HASHES.get(hash) || null,
432
+ type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'other',
433
+ })
434
+ }
435
+ return entries
436
+ }
437
+
438
+ async function migrateLegacy(entries, expectedHash) {
439
+ if (!entries.length) {
440
+ if (expectedHash) {
441
+ throw new SkillManagerError(
442
+ '--replace-legacy 已提供,但当前 scope 没有发现 legacy Deyo Skill',
443
+ 'legacy_not_found',
444
+ )
445
+ }
446
+ return null
447
+ }
448
+ if (entries.length !== 1) {
449
+ throw new SkillManagerError(
450
+ `发现多个 legacy Deyo Skill,拒绝自动处理:${entries.map(entry => entry.path).join('、')}`,
451
+ 'multiple_legacy_installs',
452
+ )
453
+ }
454
+
455
+ const entry = entries[0]
456
+ if (!expectedHash) {
457
+ throw new SkillManagerError(
458
+ `发现 legacy Deyo Skill:${entry.path}(${entry.hash})。确认没有本地修改后,用 --replace-legacy ${entry.hash} 重试`,
459
+ 'legacy_confirmation_required',
460
+ )
461
+ }
462
+ if (entry.hash !== expectedHash) {
463
+ throw new SkillManagerError(
464
+ `legacy 哈希已变化:当前 ${entry.hash},参数为 ${expectedHash}`,
465
+ 'legacy_hash_changed',
466
+ )
467
+ }
468
+ if (!entry.recognized) {
469
+ throw new SkillManagerError(
470
+ `legacy 内容不是已知官方历史版本,拒绝覆盖:${entry.path}`,
471
+ 'unknown_legacy_content',
472
+ )
473
+ }
474
+
475
+ const recheckedHash = await hashLegacyTree(entry.path)
476
+ if (recheckedHash !== expectedHash) {
477
+ throw new SkillManagerError('legacy 内容在迁移前发生变化,已停止', 'legacy_race')
478
+ }
479
+
480
+ const backupPath = `${entry.path}.deyo-backup-${Date.now()}-${randomBytes(4).toString('hex')}`
481
+ await rename(entry.path, backupPath)
482
+ const backupHash = await hashLegacyTree(backupPath)
483
+ if (backupHash !== expectedHash) {
484
+ await rename(backupPath, entry.path).catch(() => {})
485
+ throw new SkillManagerError('legacy 备份复算哈希不一致,已停止并尝试恢复', 'legacy_backup_mismatch')
486
+ }
487
+
488
+ return {
489
+ originalPath: entry.path,
490
+ backupPath,
491
+ hash: expectedHash,
492
+ async restore() {
493
+ if (await pathLstat(entry.path)) {
494
+ const failedInstallPath = `${entry.path}.deyo-failed-install-${Date.now()}-${randomBytes(4).toString('hex')}`
495
+ await rename(entry.path, failedInstallPath)
496
+ }
497
+ await rename(backupPath, entry.path)
498
+ },
499
+ }
500
+ }
501
+
502
+ function normalizeRepositorySource(value) {
503
+ let normalized = String(value || '').trim().toLowerCase()
504
+ if (!normalized) return ''
505
+ normalized = normalized.replace(/^git\+/, '')
506
+ normalized = normalized.replace(/^git@github\.com:/, '')
507
+ normalized = normalized.replace(/^ssh:\/\/git@github\.com\//, '')
508
+ normalized = normalized.replace(/^https?:\/\/github\.com\//, '')
509
+ normalized = normalized.replace(/#.*$/, '')
510
+ normalized = normalized.replace(/\.git\/?$/, '')
511
+ normalized = normalized.replace(/^\/+|\/+$/g, '')
512
+ return normalized
513
+ }
514
+
515
+ function isPlainObject(value) {
516
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
517
+ }
518
+
519
+ function repositorySourceCandidates(value) {
520
+ if (typeof value === 'string') return [value]
521
+ if (!isPlainObject(value)) return []
522
+
523
+ if (isPlainObject(value.marketplaceSource)) {
524
+ const marketplaceSource = value.marketplaceSource
525
+ if (typeof marketplaceSource.source === 'string') return [marketplaceSource.source]
526
+ return repositorySourceCandidates(marketplaceSource)
527
+ }
528
+ if (isPlainObject(value.installSource)) {
529
+ const installSource = value.installSource
530
+ if (typeof installSource.source === 'string') return [installSource.source]
531
+ return repositorySourceCandidates(installSource)
532
+ }
533
+ if (isPlainObject(value.source)) {
534
+ return repositorySourceCandidates(value.source)
535
+ }
536
+ for (const key of ['repo', 'repository', 'url', 'gitUrl']) {
537
+ if (typeof value[key] === 'string') return [value[key]]
538
+ }
539
+ return typeof value.source === 'string' ? [value.source] : []
540
+ }
541
+
542
+ function hasOfficialRepositorySource(value) {
543
+ return repositorySourceCandidates(value)
544
+ .some(item => normalizeRepositorySource(item) === GITHUB_REPOSITORY)
545
+ }
546
+
547
+ function parseJsonOutput(result, commandLabel) {
548
+ try {
549
+ return JSON.parse(result.stdout || 'null')
550
+ } catch {
551
+ throw new SkillManagerError(`${commandLabel} 没有返回合法 JSON`, 'manager_invalid_json')
552
+ }
553
+ }
554
+
555
+ function flattenArray(value, preferredKeys = []) {
556
+ if (Array.isArray(value)) return value
557
+ if (!value || typeof value !== 'object') return []
558
+ for (const key of preferredKeys) {
559
+ if (Array.isArray(value[key])) return value[key]
560
+ }
561
+ return []
562
+ }
563
+
564
+ async function findExecutable(command) {
565
+ const pathValue = process.env.PATH || ''
566
+ const suffixes = process.platform === 'win32'
567
+ ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';')
568
+ : ['']
569
+ for (const directory of pathValue.split(process.platform === 'win32' ? ';' : ':')) {
570
+ if (!directory) continue
571
+ for (const suffix of suffixes) {
572
+ const candidate = resolve(directory, `${command}${suffix}`)
573
+ try {
574
+ await access(candidate, fsConstants.X_OK)
575
+ return candidate
576
+ } catch {}
577
+ }
578
+ }
579
+ return null
580
+ }
581
+
582
+ async function requireExecutable(command) {
583
+ const executable = await findExecutable(command)
584
+ if (!executable) {
585
+ throw new SkillManagerError(
586
+ `未找到 ${command}。请先安装对应平台 CLI,再重试`,
587
+ 'manager_not_found',
588
+ )
589
+ }
590
+ return executable
591
+ }
592
+
593
+ async function runManager(command, args, options = {}) {
594
+ return await new Promise((resolvePromise, rejectPromise) => {
595
+ const child = spawn(command, args, {
596
+ cwd: options.cwd || process.cwd(),
597
+ env: process.env,
598
+ stdio: options.interactive ? ['inherit', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
599
+ })
600
+ const stdout = []
601
+ const stderr = []
602
+
603
+ child.stdout.on('data', (chunk) => {
604
+ stdout.push(Buffer.from(chunk))
605
+ if (options.interactive) {
606
+ ;(options.jsonMode ? process.stderr : process.stdout).write(chunk)
607
+ }
608
+ })
609
+ child.stderr.on('data', (chunk) => {
610
+ stderr.push(Buffer.from(chunk))
611
+ if (options.interactive) process.stderr.write(chunk)
612
+ })
613
+ child.on('error', (error) => {
614
+ if (error?.code === 'ENOENT') {
615
+ rejectPromise(new SkillManagerError(`未找到 ${command}`, 'manager_not_found'))
616
+ return
617
+ }
618
+ rejectPromise(error)
619
+ })
620
+ child.on('close', (exitCode, signal) => {
621
+ const result = {
622
+ exitCode: exitCode ?? 1,
623
+ signal: signal || null,
624
+ stdout: Buffer.concat(stdout).toString('utf8'),
625
+ stderr: Buffer.concat(stderr).toString('utf8'),
626
+ }
627
+ if (result.exitCode !== 0 && !options.allowFailure) {
628
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`
629
+ rejectPromise(new SkillManagerError(
630
+ `${command} ${args.join(' ')} 失败:${detail}`,
631
+ 'manager_failed',
632
+ ))
633
+ return
634
+ }
635
+ resolvePromise(result)
636
+ })
637
+ })
638
+ }
639
+
640
+ function findMarketplace(list, name) {
641
+ return flattenArray(list, ['marketplaces']).find(item => item?.name === name) || null
642
+ }
643
+
644
+ function findPlugin(list, pluginId) {
645
+ const entries = [
646
+ ...flattenArray(list, ['installed', 'plugins']),
647
+ ...(Array.isArray(list?.available) ? list.available : []),
648
+ ]
649
+ return entries.find(item => (
650
+ item?.pluginId === pluginId
651
+ || item?.id === pluginId
652
+ || (item?.name === 'deyo' && item?.marketplaceName === MARKETPLACE_NAME)
653
+ ) && item?.installed !== false) || null
654
+ }
655
+
656
+ function findForeignDeyoPlugin(list) {
657
+ const entries = [
658
+ ...flattenArray(list, ['installed', 'plugins']),
659
+ ...(Array.isArray(list?.available) ? list.available : []),
660
+ ]
661
+ return entries.find(item => {
662
+ const id = item?.pluginId || item?.id || ''
663
+ const installed = item?.installed !== false
664
+ return installed && (
665
+ (item?.name === 'deyo' && item?.marketplaceName !== MARKETPLACE_NAME)
666
+ || (id.startsWith('deyo@') && id !== PLUGIN_ID)
667
+ )
668
+ }) || null
669
+ }
670
+
671
+ async function buildCodexInstallPlan(jsonMode) {
672
+ const codex = await requireExecutable('codex')
673
+ const marketplaceList = parseJsonOutput(
674
+ await runManager(codex, ['plugin', 'marketplace', 'list', '--json']),
675
+ 'codex plugin marketplace list',
676
+ )
677
+ const marketplace = findMarketplace(marketplaceList, MARKETPLACE_NAME)
678
+ if (marketplace && !hasOfficialRepositorySource(marketplace)) {
679
+ throw new SkillManagerError(
680
+ `Codex 已有同名 marketplace ${MARKETPLACE_NAME},但来源不是 ${GITHUB_REPOSITORY}`,
681
+ 'marketplace_source_conflict',
682
+ )
683
+ }
684
+
685
+ const pluginList = parseJsonOutput(
686
+ await runManager(codex, ['plugin', 'list', '--json']),
687
+ 'codex plugin list',
688
+ )
689
+ const foreign = findForeignDeyoPlugin(pluginList)
690
+ if (foreign) {
691
+ throw new SkillManagerError('Codex 已安装同名异源 Deyo plugin,拒绝覆盖', 'plugin_source_conflict')
692
+ }
693
+ const installed = findPlugin(pluginList, PLUGIN_ID)
694
+
695
+ return async () => {
696
+ const actions = []
697
+ if (!marketplace) {
698
+ await runManager(codex, ['plugin', 'marketplace', 'add', GITHUB_REPOSITORY], {
699
+ interactive: true,
700
+ jsonMode,
701
+ })
702
+ actions.push('marketplace_added')
703
+ }
704
+ if (!installed) {
705
+ await runManager(codex, ['plugin', 'add', PLUGIN_ID], {
706
+ interactive: true,
707
+ jsonMode,
708
+ })
709
+ actions.push('plugin_installed')
710
+ } else {
711
+ actions.push('already_installed')
712
+ }
713
+ return { actions, activation: 'new_session' }
714
+ }
715
+ }
716
+
717
+ function getClaudeSettingsPath(scope) {
718
+ return scope === 'workspace'
719
+ ? resolve('.claude', 'settings.json')
720
+ : join(getClaudeHome(), 'settings.json')
721
+ }
722
+
723
+ function claudeSourceMatches(entry) {
724
+ return hasOfficialRepositorySource(entry)
725
+ }
726
+
727
+ async function validateClaudeSettingsParent(path, create = false) {
728
+ const parent = dirname(path)
729
+ let stats = await pathLstat(parent)
730
+ if (!stats && create) {
731
+ await mkdir(parent, { recursive: true, mode: 0o700 })
732
+ stats = await pathLstat(parent)
733
+ }
734
+ if (stats?.isSymbolicLink()) {
735
+ throw new SkillManagerError(`拒绝通过符号链接设置目录写入:${parent}`, 'settings_symlink')
736
+ }
737
+ if (stats && !stats.isDirectory()) {
738
+ throw new SkillManagerError(`Claude 设置目录不是普通目录:${parent}`, 'settings_not_directory')
739
+ }
740
+ }
741
+
742
+ function validateClaudeSettingsShape(settings, path) {
743
+ if (!isPlainObject(settings)) {
744
+ throw new SkillManagerError(`Claude 设置必须是 JSON object:${path}`, 'settings_invalid_shape')
745
+ }
746
+ const marketplaces = settings.extraKnownMarketplaces
747
+ if (marketplaces !== undefined && !isPlainObject(marketplaces)) {
748
+ throw new SkillManagerError(
749
+ `Claude extraKnownMarketplaces 必须是 JSON object:${path}`,
750
+ 'settings_invalid_shape',
751
+ )
752
+ }
753
+ const existing = marketplaces?.[MARKETPLACE_NAME]
754
+ if (existing !== undefined && !isPlainObject(existing)) {
755
+ throw new SkillManagerError(
756
+ `Claude marketplace ${MARKETPLACE_NAME} 配置必须是 JSON object`,
757
+ 'settings_invalid_shape',
758
+ )
759
+ }
760
+ if (existing && !claudeSourceMatches(existing)) {
761
+ throw new SkillManagerError(
762
+ `Claude 设置中已有同名 marketplace ${MARKETPLACE_NAME},但来源不同`,
763
+ 'marketplace_source_conflict',
764
+ )
765
+ }
766
+ }
767
+
768
+ async function readClaudeSettingsSnapshot(scope) {
769
+ const path = getClaudeSettingsPath(scope)
770
+ await validateClaudeSettingsParent(path)
771
+ const stats = await pathLstat(path)
772
+ if (stats?.isSymbolicLink()) {
773
+ throw new SkillManagerError(`拒绝写入符号链接设置文件:${path}`, 'settings_symlink')
774
+ }
775
+ if (stats && !stats.isFile()) {
776
+ throw new SkillManagerError(`Claude 设置路径不是普通文件:${path}`, 'settings_not_file')
777
+ }
778
+ const content = stats ? await readFile(path, 'utf8') : null
779
+ let settings = {}
780
+ if (content !== null) {
781
+ try {
782
+ settings = JSON.parse(content)
783
+ } catch {
784
+ throw new SkillManagerError(`JSON 文件无法解析:${path}`, 'invalid_json')
785
+ }
786
+ }
787
+ validateClaudeSettingsShape(settings, path)
788
+ return { path, content, settings }
789
+ }
790
+
791
+ async function validateClaudeSettings(scope) {
792
+ return (await readClaudeSettingsSnapshot(scope)).path
793
+ }
794
+
795
+ async function readAtomicTarget(path) {
796
+ const stats = await pathLstat(path)
797
+ if (stats?.isSymbolicLink()) {
798
+ throw new SkillManagerError(`拒绝写入符号链接设置文件:${path}`, 'settings_symlink')
799
+ }
800
+ if (stats && !stats.isFile()) {
801
+ throw new SkillManagerError(`Claude 设置路径不是普通文件:${path}`, 'settings_not_file')
802
+ }
803
+ return {
804
+ content: stats ? await readFile(path, 'utf8') : null,
805
+ mode: stats ? stats.mode & 0o777 : 0o600,
806
+ }
807
+ }
808
+
809
+ async function writeJsonAtomic(path, value, expectedContent) {
810
+ await validateClaudeSettingsParent(path, true)
811
+ const existingStats = await pathLstat(path)
812
+ if (existingStats?.isSymbolicLink()) {
813
+ throw new SkillManagerError(`拒绝写入符号链接设置文件:${path}`, 'settings_symlink')
814
+ }
815
+ if (existingStats && !existingStats.isFile()) {
816
+ throw new SkillManagerError(`Claude 设置路径不是普通文件:${path}`, 'settings_not_file')
817
+ }
818
+ const mode = existingStats ? existingStats.mode & 0o777 : 0o600
819
+ const temporaryPath = join(
820
+ dirname(path),
821
+ `.${basename(path)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`,
822
+ )
823
+ const handle = await open(temporaryPath, 'wx', mode)
824
+ let temporaryExists = true
825
+ try {
826
+ try {
827
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8')
828
+ await handle.sync()
829
+ } finally {
830
+ await handle.close()
831
+ }
832
+ await chmod(temporaryPath, mode)
833
+ const current = await readAtomicTarget(path)
834
+ if (current.content !== expectedContent) {
835
+ throw new SkillManagerError(
836
+ `Claude 设置在写入期间发生变化,已停止以避免覆盖:${path}`,
837
+ 'settings_changed',
838
+ )
839
+ }
840
+ await rename(temporaryPath, path)
841
+ temporaryExists = false
842
+ } finally {
843
+ if (temporaryExists) await unlink(temporaryPath).catch(() => {})
844
+ }
845
+ }
846
+
847
+ async function withClaudeSettingsLock(path, callback) {
848
+ await validateClaudeSettingsParent(path, true)
849
+ const lockPath = join(dirname(path), `.${basename(path)}.deyo.lock`)
850
+ let handle
851
+ try {
852
+ handle = await open(lockPath, 'wx', 0o600)
853
+ } catch (error) {
854
+ if (error?.code === 'EEXIST') {
855
+ throw new SkillManagerError(
856
+ `Claude 设置正被另一个 Deyo 安装进程处理:${path}`,
857
+ 'settings_busy',
858
+ )
859
+ }
860
+ throw error
861
+ }
862
+ try {
863
+ return await callback()
864
+ } finally {
865
+ await handle.close().catch(() => {})
866
+ await unlink(lockPath).catch(() => {})
867
+ }
868
+ }
869
+
870
+ async function enableClaudeAutoUpdate(scope) {
871
+ const path = getClaudeSettingsPath(scope)
872
+ return await withClaudeSettingsLock(path, async () => {
873
+ const snapshot = await readClaudeSettingsSnapshot(scope)
874
+ const settings = snapshot.settings
875
+ settings.extraKnownMarketplaces ||= {}
876
+ const existing = settings.extraKnownMarketplaces[MARKETPLACE_NAME] || {}
877
+ settings.extraKnownMarketplaces[MARKETPLACE_NAME] = {
878
+ ...existing,
879
+ source: {
880
+ source: 'github',
881
+ repo: GITHUB_REPOSITORY,
882
+ },
883
+ autoUpdate: true,
884
+ }
885
+ await writeJsonAtomic(path, settings, snapshot.content)
886
+ return path
887
+ })
888
+ }
889
+
890
+ async function buildClaudeInstallPlan(scope, jsonMode) {
891
+ const claude = await requireExecutable('claude')
892
+ await validateClaudeSettings(scope)
893
+ const nativeScope = scope === 'workspace' ? 'project' : 'user'
894
+ const marketplaces = parseJsonOutput(
895
+ await runManager(claude, ['plugin', 'marketplace', 'list', '--json']),
896
+ 'claude plugin marketplace list',
897
+ )
898
+ const marketplace = findMarketplace(marketplaces, MARKETPLACE_NAME)
899
+ if (marketplace && !hasOfficialRepositorySource(marketplace)) {
900
+ throw new SkillManagerError(
901
+ `Claude 已有同名 marketplace ${MARKETPLACE_NAME},但来源不是 ${GITHUB_REPOSITORY}`,
902
+ 'marketplace_source_conflict',
903
+ )
904
+ }
905
+
906
+ const plugins = parseJsonOutput(
907
+ await runManager(claude, ['plugin', 'list', '--json']),
908
+ 'claude plugin list',
909
+ )
910
+ const foreign = findForeignDeyoPlugin(plugins)
911
+ if (foreign) {
912
+ throw new SkillManagerError('Claude 已安装同名异源 Deyo plugin,拒绝覆盖', 'plugin_source_conflict')
913
+ }
914
+ const installed = flattenArray(plugins, ['plugins', 'installed']).find(item => (
915
+ (item?.id === PLUGIN_ID || item?.pluginId === PLUGIN_ID)
916
+ && (!item?.scope || item.scope === nativeScope)
917
+ )) || null
918
+
919
+ return async () => {
920
+ const actions = []
921
+ if (!marketplace) {
922
+ await runManager(claude, [
923
+ 'plugin', 'marketplace', 'add', GITHUB_REPOSITORY, '--scope', nativeScope,
924
+ ], { interactive: true, jsonMode })
925
+ actions.push('marketplace_added')
926
+ }
927
+ const settingsPath = await enableClaudeAutoUpdate(scope)
928
+ actions.push('auto_update_enabled')
929
+ if (!installed) {
930
+ await runManager(claude, [
931
+ 'plugin', 'install', PLUGIN_ID, '--scope', nativeScope,
932
+ ], { interactive: true, jsonMode })
933
+ actions.push('plugin_installed')
934
+ } else {
935
+ actions.push('already_installed')
936
+ }
937
+ return { actions, activation: 'reload_plugins_or_new_session', settingsPath }
938
+ }
939
+ }
940
+
941
+ function getGeminiExtensionPath() {
942
+ return join(getGeminiHome(), 'extensions', 'deyo')
943
+ }
944
+
945
+ async function inspectGeminiExtension() {
946
+ const path = getGeminiExtensionPath()
947
+ const stats = await pathLstat(path)
948
+ if (!stats) {
949
+ return { installed: false, path, manifest: null, install: null }
950
+ }
951
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
952
+ return {
953
+ installed: true,
954
+ path,
955
+ manifest: null,
956
+ install: null,
957
+ version: null,
958
+ officialSource: false,
959
+ autoUpdate: false,
960
+ safe: false,
961
+ }
962
+ }
963
+ const manifestMetadata = await inspectJsonMetadata(join(path, 'gemini-extension.json'))
964
+ const installMetadata = await inspectJsonMetadata(join(path, '.gemini-extension-install.json'))
965
+ const manifest = manifestMetadata.value
966
+ const install = installMetadata.value
967
+ const safe = manifestMetadata.safe && installMetadata.safe
968
+ return {
969
+ installed: true,
970
+ path,
971
+ manifest,
972
+ install,
973
+ version: manifest?.version || install?.version || null,
974
+ officialSource: safe && hasOfficialRepositorySource(install),
975
+ autoUpdate: safe && (install?.autoUpdate === true || install?.auto_update === true),
976
+ safe,
977
+ }
978
+ }
979
+
980
+ async function buildGeminiInstallPlan(jsonMode) {
981
+ const gemini = await requireExecutable('gemini')
982
+ const extension = await inspectGeminiExtension()
983
+ if (extension.installed && !extension.officialSource) {
984
+ throw new SkillManagerError(
985
+ `Gemini 已存在 deyo extension,但安装来源不是 ${GITHUB_REPOSITORY} 或无法确认`,
986
+ 'extension_source_conflict',
987
+ )
988
+ }
989
+ if (extension.installed && !extension.autoUpdate) {
990
+ throw new SkillManagerError(
991
+ 'Gemini 的现有 Deyo extension 未启用 auto-update;原生 CLI 没有安全的原地切换命令,请先用 Gemini 原生管理器卸载后重试',
992
+ 'gemini_auto_update_disabled',
993
+ )
994
+ }
995
+
996
+ return async () => {
997
+ if (extension.installed) {
998
+ return { actions: ['already_installed'], activation: 'restart' }
999
+ }
1000
+ await runManager(gemini, [
1001
+ 'extensions', 'install', GIT_SOURCE_URL, '--auto-update',
1002
+ ], { interactive: true, jsonMode })
1003
+ return { actions: ['extension_installed'], activation: 'restart' }
1004
+ }
1005
+ }
1006
+
1007
+ function getOpenClawSkillPath(scope) {
1008
+ return scope === 'global'
1009
+ ? join(getOpenClawHome(), 'skills', 'deyo')
1010
+ : resolve('skills', 'deyo')
1011
+ }
1012
+
1013
+ function isOfficialOpenClawOrigin(origin) {
1014
+ if (!isPlainObject(origin)) return false
1015
+ const registry = typeof origin.registry === 'string' ? origin.registry.replace(/\/+$/, '') : ''
1016
+ return origin.version === 1
1017
+ && registry === OPENCLAW_REGISTRY
1018
+ && String(origin.ownerHandle || '').replace(/^@/, '').toLowerCase() === 'casatwy'
1019
+ && String(origin.slug || '').replace(/^@casatwy\//, '').toLowerCase() === 'deyo'
1020
+ && typeof origin.installedVersion === 'string'
1021
+ && STABLE_SEMVER.test(origin.installedVersion)
1022
+ && Number.isFinite(origin.installedAt)
1023
+ && /^[a-f0-9]{64}$/.test(origin.fingerprint || '')
1024
+ }
1025
+
1026
+ async function inspectOpenClawSkill(scope) {
1027
+ const path = getOpenClawSkillPath(scope)
1028
+ const stats = await pathLstat(path)
1029
+ if (!stats) {
1030
+ return {
1031
+ path,
1032
+ installed: false,
1033
+ managed: false,
1034
+ officialSource: false,
1035
+ origin: null,
1036
+ version: null,
1037
+ safe: true,
1038
+ }
1039
+ }
1040
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
1041
+ return {
1042
+ path,
1043
+ installed: true,
1044
+ managed: false,
1045
+ officialSource: false,
1046
+ origin: null,
1047
+ version: null,
1048
+ safe: false,
1049
+ }
1050
+ }
1051
+ const originPath = getOpenClawOriginPath(path)
1052
+ const metadata = await inspectJsonMetadata(originPath)
1053
+ const origin = metadata.value
1054
+ return {
1055
+ path,
1056
+ installed: true,
1057
+ managed: metadata.exists,
1058
+ officialSource: metadata.safe && isOfficialOpenClawOrigin(origin),
1059
+ origin,
1060
+ version: isOfficialOpenClawOrigin(origin) ? origin.installedVersion : null,
1061
+ safe: metadata.safe,
1062
+ }
1063
+ }
1064
+
1065
+ async function buildOpenClawInstallPlan(scope, jsonMode) {
1066
+ const openclaw = await requireExecutable('openclaw')
1067
+ const skill = await inspectOpenClawSkill(scope)
1068
+ if (skill.managed && !skill.officialSource) {
1069
+ throw new SkillManagerError(
1070
+ `OpenClaw 当前 scope 已存在异源 deyo skill:${skill.path}`,
1071
+ 'openclaw_source_conflict',
1072
+ )
1073
+ }
1074
+
1075
+ return async () => {
1076
+ const scopeArgs = scope === 'global' ? ['--global'] : []
1077
+ if (skill.managed) {
1078
+ await runManager(openclaw, [
1079
+ 'skills', 'update', OPENCLAW_SKILL, ...scopeArgs,
1080
+ ], { interactive: true, jsonMode })
1081
+ return { actions: ['skill_updated'], activation: 'invoke_again_if_updated' }
1082
+ }
1083
+ await runManager(openclaw, [
1084
+ 'skills', 'install', OPENCLAW_SKILL, ...scopeArgs,
1085
+ ], { interactive: true, jsonMode })
1086
+ return { actions: ['skill_installed'], activation: 'next_invocation' }
1087
+ }
1088
+ }
1089
+
1090
+ async function buildPlatformInstallPlan(platform, scope, jsonMode) {
1091
+ if (platform === 'codex') return await buildCodexInstallPlan(jsonMode)
1092
+ if (platform === 'claude') return await buildClaudeInstallPlan(scope, jsonMode)
1093
+ if (platform === 'gemini') return await buildGeminiInstallPlan(jsonMode)
1094
+ return await buildOpenClawInstallPlan(scope, jsonMode)
1095
+ }
1096
+
1097
+ function extractTomlSection(content, sectionName) {
1098
+ if (!content) return ''
1099
+ const lines = content.split(/\r?\n/)
1100
+ const exactHeader = `[${sectionName}]`
1101
+ const start = lines.findIndex(line => line.trim() === exactHeader)
1102
+ if (start < 0) return ''
1103
+ const section = []
1104
+ for (let index = start + 1; index < lines.length; index += 1) {
1105
+ if (/^\s*\[/.test(lines[index])) break
1106
+ section.push(lines[index])
1107
+ }
1108
+ return section.join('\n')
1109
+ }
1110
+
1111
+ function readTomlString(section, key) {
1112
+ const match = section.match(new RegExp(`^\\s*${key}\\s*=\\s*["']([^"']*)["']`, 'm'))
1113
+ return match?.[1] || ''
1114
+ }
1115
+
1116
+ function readTomlBoolean(section, key) {
1117
+ const match = section.match(new RegExp(`^\\s*${key}\\s*=\\s*(true|false)\\s*$`, 'm'))
1118
+ return match ? match[1] === 'true' : null
1119
+ }
1120
+
1121
+ async function inspectCodexManaged() {
1122
+ const configPath = join(getCodexHome(), 'config.toml')
1123
+ const content = await readUtf8IfExists(configPath) || ''
1124
+ const pluginSection = extractTomlSection(content, `plugins."${PLUGIN_ID}"`)
1125
+ const marketplaceSection = extractTomlSection(content, `marketplaces.${MARKETPLACE_NAME}`)
1126
+ const source = readTomlString(marketplaceSection, 'source')
1127
+ let version = null
1128
+ const pluginCache = join(getCodexHome(), 'plugins', 'cache', MARKETPLACE_NAME, 'deyo')
1129
+ try {
1130
+ const versions = (await readdir(pluginCache))
1131
+ .filter(item => /^\d+\.\d+\.\d+$/.test(item))
1132
+ .sort((first, second) => {
1133
+ const firstParts = first.split('.').map(Number)
1134
+ const secondParts = second.split('.').map(Number)
1135
+ return firstParts[0] - secondParts[0]
1136
+ || firstParts[1] - secondParts[1]
1137
+ || firstParts[2] - secondParts[2]
1138
+ })
1139
+ version = versions.at(-1) || null
1140
+ } catch (error) {
1141
+ if (error?.code !== 'ENOENT') throw error
1142
+ }
1143
+ return {
1144
+ installed: Boolean(pluginSection),
1145
+ enabled: readTomlBoolean(pluginSection, 'enabled'),
1146
+ version,
1147
+ source: source || null,
1148
+ officialSource: normalizeRepositorySource(source) === GITHUB_REPOSITORY,
1149
+ autoUpdate: 'host_startup_refresh',
1150
+ configPath,
1151
+ }
1152
+ }
1153
+
1154
+ async function inspectClaudeManaged(scope) {
1155
+ const settingsPath = getClaudeSettingsPath(scope)
1156
+ const settings = await readJsonIfExists(settingsPath) || {}
1157
+ const marketplace = settings.extraKnownMarketplaces?.[MARKETPLACE_NAME] || null
1158
+ const installedIndexPath = join(getClaudeHome(), 'plugins', 'installed_plugins.json')
1159
+ const installedIndex = await readJsonIfExists(installedIndexPath) || {}
1160
+ const nativeScope = scope === 'workspace' ? 'project' : 'user'
1161
+ const records = installedIndex.plugins?.[PLUGIN_ID] || []
1162
+ const record = (Array.isArray(records) ? records : [records]).find(item => item?.scope === nativeScope) || null
1163
+ return {
1164
+ installed: Boolean(record || settings.enabledPlugins?.[PLUGIN_ID] === true),
1165
+ enabled: settings.enabledPlugins?.[PLUGIN_ID] ?? null,
1166
+ version: record?.version || null,
1167
+ source: marketplace?.source || null,
1168
+ officialSource: marketplace ? hasOfficialRepositorySource(marketplace) : false,
1169
+ autoUpdate: marketplace?.autoUpdate === true,
1170
+ settingsPath,
1171
+ installPath: record?.installPath || null,
1172
+ }
1173
+ }
1174
+
1175
+ async function inspectPlatformManaged(platform, scope) {
1176
+ if (platform === 'codex') return await inspectCodexManaged()
1177
+ if (platform === 'claude') return await inspectClaudeManaged(scope)
1178
+ if (platform === 'gemini') {
1179
+ const extension = await inspectGeminiExtension()
1180
+ return {
1181
+ installed: extension.installed,
1182
+ version: extension.version || null,
1183
+ source: extension.install || null,
1184
+ officialSource: extension.officialSource || false,
1185
+ autoUpdate: extension.autoUpdate || false,
1186
+ installPath: extension.path,
1187
+ }
1188
+ }
1189
+ const skill = await inspectOpenClawSkill(scope)
1190
+ return {
1191
+ installed: skill.installed,
1192
+ version: skill.version,
1193
+ source: skill.origin,
1194
+ officialSource: skill.officialSource,
1195
+ autoUpdate: 'throttled_on_invocation',
1196
+ installPath: skill.path,
1197
+ }
1198
+ }
1199
+
1200
+ async function getSkillStatus(platform) {
1201
+ const scope = getDefaultScope(platform)
1202
+ const managed = await inspectPlatformManaged(platform, scope)
1203
+ const legacy = await inspectLegacy(platform, scope)
1204
+ const command = platform === 'openclaw' ? 'openclaw' : platform
1205
+ return {
1206
+ platform,
1207
+ scope,
1208
+ managerAvailable: Boolean(await findExecutable(command)),
1209
+ installed: Boolean(managed.installed),
1210
+ version: managed.version || null,
1211
+ officialSource: Boolean(managed.officialSource),
1212
+ autoUpdate: managed.autoUpdate ?? null,
1213
+ managed,
1214
+ legacy,
1215
+ healthy: Boolean(managed.installed && managed.officialSource && legacy.length === 0),
1216
+ }
1217
+ }
1218
+
1219
+ async function installSkill(options) {
1220
+ validatePlatform(options.platform)
1221
+ const scope = options.scope || getDefaultScope(options.platform)
1222
+ validateScope(options.platform, scope)
1223
+ const expectedHash = options.replaceLegacy ? parseSha256(options.replaceLegacy) : ''
1224
+
1225
+ // All source-conflict and manager checks happen before the legacy directory
1226
+ // is moved or any provider configuration is mutated.
1227
+ const execute = await buildPlatformInstallPlan(options.platform, scope, options.json)
1228
+ const legacy = await inspectLegacy(options.platform, scope)
1229
+ let migration = null
1230
+ try {
1231
+ migration = await migrateLegacy(legacy, expectedHash)
1232
+ const provider = await execute()
1233
+ return {
1234
+ command: 'skill.install',
1235
+ platform: options.platform,
1236
+ scope,
1237
+ provider,
1238
+ legacyMigration: migration ? {
1239
+ originalPath: migration.originalPath,
1240
+ backupPath: migration.backupPath,
1241
+ hash: migration.hash,
1242
+ } : null,
1243
+ }
1244
+ } catch (error) {
1245
+ if (migration) {
1246
+ try {
1247
+ await migration.restore()
1248
+ } catch (restoreError) {
1249
+ throw new SkillManagerError(
1250
+ `${error instanceof Error ? error.message : String(error)};legacy 自动恢复失败:${restoreError instanceof Error ? restoreError.message : String(restoreError)}`,
1251
+ 'legacy_restore_failed',
1252
+ )
1253
+ }
1254
+ }
1255
+ throw error
1256
+ }
1257
+ }
1258
+
1259
+ function printInstallResult(result) {
1260
+ process.stdout.write(`Deyo Skill 已处理:${result.platform}(scope: ${result.scope})\n`)
1261
+ for (const action of result.provider.actions) {
1262
+ process.stdout.write(`- ${action}\n`)
1263
+ }
1264
+ if (result.legacyMigration) {
1265
+ process.stdout.write(`legacy 备份:${result.legacyMigration.backupPath}\n`)
1266
+ }
1267
+ process.stdout.write(`生效方式:${result.provider.activation}\n`)
1268
+ }
1269
+
1270
+ function formatAutoUpdate(value) {
1271
+ if (value === true) return 'enabled'
1272
+ if (value === false) return 'disabled'
1273
+ return value || 'unknown'
1274
+ }
1275
+
1276
+ function printStatusResult(result) {
1277
+ for (const item of result.platforms) {
1278
+ const state = item.installed ? `installed${item.version ? ` v${item.version}` : ''}` : 'not installed'
1279
+ process.stdout.write(`${item.platform} (${item.scope}): ${state}\n`)
1280
+ process.stdout.write(` manager: ${item.managerAvailable ? 'available' : 'missing'}\n`)
1281
+ process.stdout.write(` official source: ${item.officialSource ? 'yes' : 'no'}\n`)
1282
+ process.stdout.write(` auto update: ${formatAutoUpdate(item.autoUpdate)}\n`)
1283
+ for (const legacy of item.legacy) {
1284
+ process.stdout.write(` legacy: ${legacy.path}\n`)
1285
+ process.stdout.write(` hash: ${legacy.hash}\n`)
1286
+ process.stdout.write(` official historical tree: ${legacy.recognized ? 'yes' : 'no'}\n`)
1287
+ }
1288
+ }
1289
+ }
1290
+
1291
+ export async function handleSkillCommand(argv, cliVersion) {
1292
+ const wantsJson = argv.includes('--json')
1293
+ try {
1294
+ const args = parseSkillCommandArgs(argv)
1295
+ if (args.help) {
1296
+ printSkillUsage()
1297
+ return
1298
+ }
1299
+ if (args.platform) validatePlatform(args.platform)
1300
+
1301
+ if (args.action === 'install') {
1302
+ const result = await installSkill(args)
1303
+ const output = { cliVersion, ...result }
1304
+ if (args.json) process.stdout.write(`${JSON.stringify(output)}\n`)
1305
+ else printInstallResult(output)
1306
+ return
1307
+ }
1308
+
1309
+ const platforms = args.platform ? [args.platform] : SKILL_PLATFORMS
1310
+ const result = {
1311
+ command: 'skill.status',
1312
+ cliVersion,
1313
+ offline: true,
1314
+ platforms: [],
1315
+ }
1316
+ for (const platform of platforms) {
1317
+ result.platforms.push(await getSkillStatus(platform))
1318
+ }
1319
+ if (args.json) process.stdout.write(`${JSON.stringify(result)}\n`)
1320
+ else printStatusResult(result)
1321
+ } catch (error) {
1322
+ const payload = {
1323
+ command: 'skill.error',
1324
+ error: error?.code || 'skill_error',
1325
+ message: error instanceof Error ? error.message : String(error),
1326
+ }
1327
+ if (wantsJson) process.stdout.write(`${JSON.stringify(payload)}\n`)
1328
+ else process.stderr.write(`Deyo Skill 操作失败:${payload.message}\n`)
1329
+ throw new SkillCommandReportedError(1)
1330
+ }
1331
+ }
1332
+
1333
+ export const __test = {
1334
+ GITHUB_REPOSITORY,
1335
+ GIT_SOURCE_URL,
1336
+ MARKETPLACE_NAME,
1337
+ OFFICIAL_LEGACY_HASHES,
1338
+ OPENCLAW_SKILL,
1339
+ PLUGIN_ID,
1340
+ getGeminiHome,
1341
+ getLegacyPaths,
1342
+ getOpenClawHome,
1343
+ inspectLegacy,
1344
+ normalizeRepositorySource,
1345
+ writeJsonAtomic,
1346
+ }