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