harveyz-skill 0.6.2 → 0.8.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.
package/bin/cli.js CHANGED
@@ -7,13 +7,13 @@ import os from 'os'
7
7
  import path from 'path'
8
8
  import { fileURLToPath } from 'url'
9
9
  import {
10
- getAllSkillItems, getAllToolItems,
10
+ getAllSkillItems, getAllToolItems, getAllHookItems, checkHookInstalled,
11
11
  checkInstalled, checkToolInstalled, scopeSummary,
12
12
  resolveSkills, resolveSkillsByName, resolveTools, resolveToolsByName,
13
13
  TOOL_BUNDLE_CHOICES,
14
14
  } from '../lib/bundles.js'
15
15
  import { buildTargetChoices, resolveTargets, TARGETS } from '../lib/targets.js'
16
- import { installSkills, installTools } from '../lib/installer.js'
16
+ import { installSkills, installTools, installHooks, uninstallHook } from '../lib/installer.js'
17
17
 
18
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
19
19
  const require = createRequire(import.meta.url)
@@ -21,25 +21,31 @@ const { version } = require('../package.json')
21
21
 
22
22
  const args = process.argv.slice(2)
23
23
  const subcommand = args[0]
24
+ const jsonFlag = args.includes('--json')
24
25
 
25
26
  // ── Help ─────────────────────────────────────────────────────────────────────
26
27
  function printHelp() {
27
28
  console.log(`
28
- ${chalk.bold('hskill')} — skill manager for Claude Code, Cursor, and Codex v${version}
29
+ ${chalk.bold('hskill')} — skill manager for Claude Code, Cursor, Codex, OpenClaw, and Hermes v${version}
29
30
 
30
31
  ${chalk.cyan('Usage:')}
31
- hskill interactive install
32
+ hskill interactive install (requires TTY + fzf)
32
33
  hskill install interactive install (explicit)
33
34
  hskill install --bundle <b> install a skill bundle
34
35
  hskill install --skill <s> install specific skill(s)
35
36
  hskill install --tool <t> install shell tool(s)
36
- hskill install --target <t> set target (claude/cursor/codex/all)
37
+ hskill install --target <t> set target (claude/cursor/codex/openclaw/hermes/all)
37
38
  hskill install --scope <s> set scope: user (default) or project
38
39
  hskill install --force overwrite existing installs
39
- hskill list list available skills and bundles
40
- hskill status show install status for all skills and tools
41
- hskill outdated list skills and tools with available updates
42
- hskill info <name> show install detail for a skill or tool
40
+ hskill list [--json] list available skills and bundles
41
+ hskill status [--json] show install status for all skills and tools
42
+ hskill outdated [--json] list skills and tools with available updates
43
+ hskill info <name> [--json] show install detail for a skill or tool
44
+ hskill hooks list [--json] list hooks and install status
45
+ hskill hooks install [--name <n>] [--scope <s>] install hook (scope: user|project)
46
+ hskill hooks install [--project <path>] target project dir (for project scope)
47
+ hskill hooks install [--force] overwrite existing
48
+ hskill hooks uninstall <name> [--scope <s>] remove hook
43
49
  hskill update update hskill to the latest version
44
50
  hskill version show version
45
51
  hskill --help show this help
@@ -49,12 +55,72 @@ function printHelp() {
49
55
  hskill install --skill git-workflow-init --target claude --scope project
50
56
  hskill install --tool p-launch
51
57
  hskill status
58
+ hskill status --json
52
59
  hskill outdated
53
60
  hskill info git-workflow-init
61
+
62
+ ${chalk.cyan('Agent / CI usage:')}
63
+ Use --json for machine-readable output on status, list, outdated, info.
64
+ Set NO_COLOR=1 to suppress ANSI color codes in all output.
65
+ Interactive mode (no flags) requires a TTY; use --bundle/--skill/--tool in scripts.
54
66
  `)
55
67
  }
56
68
 
57
69
  if (args[0] === '--help' || args[0] === '-h') {
70
+ if (jsonFlag || args.includes('--json')) {
71
+ console.log(JSON.stringify({
72
+ name: 'hskill',
73
+ version,
74
+ description: 'Skill manager for Claude Code, Cursor, Codex, OpenClaw, and Hermes',
75
+ agent_notes: 'Interactive mode requires TTY. Use --json for machine-readable output. Set NO_COLOR=1 to suppress ANSI codes.',
76
+ commands: [
77
+ {
78
+ name: 'install',
79
+ description: 'Install skills or shell tools',
80
+ interactive_fallback: 'Requires TTY + fzf when no flags given',
81
+ note: '--skill and --tool are mutually exclusive; use --bundle to install both',
82
+ flags: [
83
+ { name: '--bundle', arg: '<name>', description: 'Install a skill bundle (comma-separated)' },
84
+ { name: '--skill', arg: '<name>', description: 'Install specific skill(s) (comma-separated)' },
85
+ { name: '--tool', arg: '<name>', description: 'Install shell tool(s) (comma-separated)' },
86
+ { name: '--target', arg: '<target>', description: 'Install target', enum: ['claude','cursor','codex','openclaw','hermes','all'] },
87
+ { name: '--scope', arg: '<scope>', description: 'Install scope', enum: ['user','project'], default: 'user' },
88
+ { name: '--force', description: 'Overwrite existing installs' },
89
+ ],
90
+ },
91
+ {
92
+ name: 'list',
93
+ description: 'List available skills and bundles',
94
+ flags: [{ name: '--json', description: 'Machine-readable output' }],
95
+ },
96
+ {
97
+ name: 'status',
98
+ description: 'Show install status for all skills and tools',
99
+ flags: [{ name: '--json', description: 'Machine-readable output' }],
100
+ },
101
+ {
102
+ name: 'outdated',
103
+ description: 'List skills and tools with available updates',
104
+ flags: [{ name: '--json', description: 'Machine-readable output' }],
105
+ },
106
+ {
107
+ name: 'info',
108
+ description: 'Show install detail for a skill or tool',
109
+ args: ['<name>'],
110
+ flags: [{ name: '--json', description: 'Machine-readable output' }],
111
+ },
112
+ {
113
+ name: 'update',
114
+ description: 'Update hskill to the latest version via npm',
115
+ },
116
+ {
117
+ name: 'version',
118
+ description: 'Print version and exit',
119
+ },
120
+ ],
121
+ }, null, 2))
122
+ process.exit(0)
123
+ }
58
124
  printHelp()
59
125
  process.exit(0)
60
126
  }
@@ -85,6 +151,14 @@ if (subcommand === 'list') {
85
151
  if (!byBundle[s.bundle]) byBundle[s.bundle] = []
86
152
  byBundle[s.bundle].push(s.path)
87
153
  }
154
+ if (jsonFlag) {
155
+ const bundles = {}
156
+ for (const [name, paths] of Object.entries(byBundle)) {
157
+ bundles[name] = { description: bundleMeta[name] ?? name, skills: paths }
158
+ }
159
+ console.log(JSON.stringify({ bundles, tools: tools.map(t => t.name) }, null, 2))
160
+ process.exit(0)
161
+ }
88
162
  for (const [name, paths] of Object.entries(byBundle)) {
89
163
  console.log(chalk.bold(name) + ' — ' + (bundleMeta[name] ?? name))
90
164
  for (const p of paths) console.log(' ' + p)
@@ -97,11 +171,20 @@ if (subcommand === 'list') {
97
171
  process.exit(0)
98
172
  }
99
173
 
174
+ // ── Helper: resolve displayed version for a hook ──────────────────────────────
175
+ // Priority: user-installed → project-installed → source version
176
+ function resolveHookDisplayVersion(inst, sourceVersion) {
177
+ if (inst.user.version !== '—') return inst.user.version
178
+ if (inst.project.version !== '—') return inst.project.version
179
+ return sourceVersion ?? '—'
180
+ }
181
+
100
182
  // ── Status / Outdated ─────────────────────────────────────────────────────────
101
183
  if (subcommand === 'status' || subcommand === 'outdated') {
102
184
  const outdatedOnly = subcommand === 'outdated'
103
185
  const skillItems = getAllSkillItems()
104
186
  const toolItems = getAllToolItems()
187
+ const hookItems = getAllHookItems()
105
188
 
106
189
  function icon(status) {
107
190
  if (status === 'up-to-date') return chalk.green('✓')
@@ -122,6 +205,30 @@ if (subcommand === 'status' || subcommand === 'outdated') {
122
205
  return { name: t.toolName, version: t.version ?? '—', ...inst }
123
206
  })
124
207
 
208
+ if (jsonFlag) {
209
+ const targets = ['claude', 'cursor', 'codex', 'openclaw', 'hermes']
210
+ const jsonSkills = skillRows.map(r => ({
211
+ name: r.name,
212
+ version: r.version,
213
+ user: Object.fromEntries(targets.map(t => [t, r.userDetail[t]])),
214
+ project: Object.fromEntries(targets.map(t => [t, r.projectDetail[t]])),
215
+ }))
216
+ const jsonTools = toolRows.map(r => ({ name: r.name, version: r.version, status: r.status }))
217
+ const jsonHooks = hookItems.map(h => {
218
+ const inst = checkHookInstalled(h.name)
219
+ return { name: h.name, description: h.description, user: inst.user, project: inst.project }
220
+ })
221
+ if (outdatedOnly) {
222
+ console.log(JSON.stringify({
223
+ skills: jsonSkills.filter(s => Object.values(s.user).some(v => v.status === 'update') || Object.values(s.project).some(v => v.status === 'update')),
224
+ tools: jsonTools.filter(t => t.status === 'update'),
225
+ }, null, 2))
226
+ } else {
227
+ console.log(JSON.stringify({ skills: jsonSkills, tools: jsonTools, hooks: jsonHooks }, null, 2))
228
+ }
229
+ process.exit(0)
230
+ }
231
+
125
232
  if (outdatedOnly) {
126
233
  const outdatedSkills = skillRows.filter(r => r.userStatus === 'update' || r.projectStatus === 'update')
127
234
  const outdatedTools = toolRows.filter(r => r.status === 'update')
@@ -131,7 +238,7 @@ if (subcommand === 'status' || subcommand === 'outdated') {
131
238
  process.exit(0)
132
239
  }
133
240
 
134
- const targets = ['claude', 'cursor', 'codex']
241
+ const targets = ['claude', 'cursor', 'codex', 'openclaw', 'hermes']
135
242
 
136
243
  if (outdatedSkills.length) {
137
244
  console.log('\n ' + chalk.bold('SKILLS WITH UPDATES'))
@@ -161,7 +268,7 @@ if (subcommand === 'status' || subcommand === 'outdated') {
161
268
  }
162
269
 
163
270
  // Full status table
164
- const allNames = [...skillRows, ...toolRows].map(r => r.name)
271
+ const allNames = [...skillRows.map(r => r.name), ...toolRows.map(r => r.name), ...hookItems.map(h => h.name)]
165
272
  const allVers = [...skillRows, ...toolRows].map(r => r.version)
166
273
  const nw = Math.max(...allNames.map(n => n.length), 4)
167
274
  const vw = Math.max(...allVers.map(v => v.length), 7)
@@ -183,6 +290,23 @@ if (subcommand === 'status' || subcommand === 'outdated') {
183
290
  console.log(' ' + r.name.padEnd(nw) + ' ' + chalk.dim(r.version.padEnd(vw)) + ' ' + icon(r.status))
184
291
  }
185
292
 
293
+ // ── hooks ──
294
+ if (hookItems.length > 0) {
295
+ console.log('')
296
+ console.log(' ' + chalk.bold('HOOKS') + chalk.dim(` — ${hookItems.length} available`))
297
+ console.log(sep)
298
+ function hIcon(s) {
299
+ if (s === 'installed') return chalk.green('✓')
300
+ if (s === 'partial') return chalk.yellow('~')
301
+ return chalk.dim('—')
302
+ }
303
+ for (const h of hookItems) {
304
+ const inst = checkHookInstalled(h.name)
305
+ const ver = resolveHookDisplayVersion(inst, h.version)
306
+ console.log(' ' + h.name.padEnd(nw) + ' ' + chalk.dim(ver.padEnd(vw)) + ' ' + hIcon(inst.user.status) + ' ' + hIcon(inst.project.status) + ' ' + chalk.dim(h.description))
307
+ }
308
+ }
309
+
186
310
  const installedSkills = skillRows.filter(r => r.userStatus !== 'none' || r.projectStatus !== 'none').length
187
311
  const installedTools = toolRows.filter(r => r.status !== 'none').length
188
312
  const outdatedCount = skillRows.filter(r => r.userStatus === 'update' || r.projectStatus === 'update').length
@@ -220,6 +344,24 @@ if (subcommand === 'info') {
220
344
  return chalk.dim('— not installed')
221
345
  }
222
346
 
347
+ if (jsonFlag) {
348
+ if (skill) {
349
+ const inst = checkInstalled(skill.skillName, skill.version ?? '—')
350
+ console.log(JSON.stringify({
351
+ name: skill.skillName, type: 'skill', version: skill.version ?? '—',
352
+ user: Object.fromEntries(targets.map(t => [t, inst.user[t]])),
353
+ project: Object.fromEntries(targets.map(t => [t, inst.project[t]])),
354
+ }, null, 2))
355
+ } else {
356
+ const inst = checkToolInstalled(tool.toolName, tool.srcPath)
357
+ console.log(JSON.stringify({
358
+ name: tool.toolName, type: 'tool', version: tool.version ?? '—',
359
+ installed: inst,
360
+ }, null, 2))
361
+ }
362
+ process.exit(0)
363
+ }
364
+
223
365
  console.log('')
224
366
  if (skill) {
225
367
  const inst = checkInstalled(skill.skillName, skill.version ?? '—')
@@ -246,6 +388,115 @@ if (subcommand === 'info') {
246
388
  process.exit(0)
247
389
  }
248
390
 
391
+ // ── Hooks ─────────────────────────────────────────────────────────────────────
392
+ if (subcommand === 'hooks') {
393
+ const hooksSubcmd = args[1]
394
+ const hookArgs = args.slice(2)
395
+ const hookJsonFlag = hooksSubcmd === '--json' || hookArgs.includes('--json')
396
+ const hookNameIdx = hookArgs.indexOf('--name')
397
+ const hookScopeIdx = hookArgs.indexOf('--scope')
398
+ const hookProjectIdx = hookArgs.indexOf('--project')
399
+ const hookForce = hookArgs.includes('--force')
400
+ const hookNameArg = hookNameIdx !== -1 ? hookArgs[hookNameIdx + 1] : undefined
401
+ const hookScopeArg = hookScopeIdx !== -1 ? hookArgs[hookScopeIdx + 1] : 'user'
402
+ const hookProjectArg = hookProjectIdx !== -1 ? hookArgs[hookProjectIdx + 1] : process.cwd()
403
+
404
+ // Validate scope for install/uninstall commands
405
+ if (!['user', 'project'].includes(hookScopeArg) && ['install', 'uninstall'].includes(hooksSubcmd)) {
406
+ console.error(chalk.red(` ✗ Invalid scope: "${hookScopeArg}". Use user or project.`))
407
+ process.exit(1)
408
+ }
409
+
410
+ // ── hooks list ──────────────────────────────────────────────────────────────
411
+ if (hooksSubcmd === 'list' || !hooksSubcmd || hooksSubcmd.startsWith('--')) {
412
+ const hookItems = getAllHookItems()
413
+ if (hookJsonFlag) {
414
+ const out = hookItems.map(h => {
415
+ const inst = checkHookInstalled(h.name)
416
+ const ver = resolveHookDisplayVersion(inst, h.version)
417
+ return { name: h.name, version: ver, description: h.description, event: h.event, user: inst.user, project: inst.project }
418
+ })
419
+ console.log(JSON.stringify({ hooks: out }, null, 2))
420
+ process.exit(0)
421
+ }
422
+ function hookIcon(s) {
423
+ if (s === 'installed') return chalk.green('✓')
424
+ if (s === 'partial') return chalk.yellow('~')
425
+ return chalk.dim('—')
426
+ }
427
+ const nameWidth = Math.max(...hookItems.map(h => h.name.length), 4)
428
+ const verWidth = 7
429
+ console.log('')
430
+ console.log(' ' + chalk.bold('NAME'.padEnd(nameWidth)) + ' ' + chalk.bold('VER'.padEnd(verWidth)) + ' U P ' + chalk.bold('DESCRIPTION'))
431
+ console.log(' ' + '─'.repeat(nameWidth) + ' ' + '─'.repeat(verWidth) + ' ─ ─ ' + '─'.repeat(20))
432
+ for (const h of hookItems) {
433
+ const inst = checkHookInstalled(h.name)
434
+ const ver = resolveHookDisplayVersion(inst, h.version)
435
+ console.log(' ' + h.name.padEnd(nameWidth) + ' ' + chalk.dim(ver.padEnd(verWidth)) + ' ' + hookIcon(inst.user.status) + ' ' + hookIcon(inst.project.status) + ' ' + h.description)
436
+ }
437
+ console.log('')
438
+ console.log(chalk.dim(` U=user P=project ${chalk.green('✓')}=installed ${chalk.yellow('~')}=partial ${chalk.dim('—')}=none`))
439
+ console.log('')
440
+ process.exit(0)
441
+ }
442
+
443
+ // ── hooks install ────────────────────────────────────────────────────────────
444
+ if (hooksSubcmd === 'install') {
445
+ const hookItems = getAllHookItems()
446
+ let toInstall
447
+
448
+ if (hookNameArg) {
449
+ const found = hookItems.find(h => h.name === hookNameArg)
450
+ if (!found) {
451
+ console.error(chalk.red(` ✗ Unknown hook: "${hookNameArg}"`))
452
+ process.exit(1)
453
+ }
454
+ toInstall = [found]
455
+ } else if (!process.stdout.isTTY) {
456
+ toInstall = hookItems
457
+ } else {
458
+ const { checkbox } = await import('@inquirer/prompts')
459
+ const selected = await checkbox({
460
+ message: 'Select hooks to install:',
461
+ choices: hookItems.map(h => ({ name: `${h.name.padEnd(32)} ${h.description}`, value: h })),
462
+ })
463
+ if (!selected.length) {
464
+ console.log(chalk.dim(' · Nothing selected'))
465
+ process.exit(0)
466
+ }
467
+ toInstall = selected
468
+ }
469
+
470
+ const { installed, skipped, failed } = await installHooks(toInstall, hookScopeArg, hookProjectArg, hookForce)
471
+
472
+ if (hookJsonFlag) {
473
+ console.log(JSON.stringify({ installed, skipped, failed }, null, 2))
474
+ process.exit(failed.length ? 1 : 0)
475
+ } else {
476
+ if (installed.length) console.error(chalk.green.bold(`✔ Hooks installed (${hookScopeArg}):`), installed.join(', '))
477
+ for (const s of skipped) console.error(chalk.dim(` · ${s.name} skipped (${s.reason})`))
478
+
479
+ for (const f of failed) console.error(chalk.red(` ✗ ${f.name} failed: ${f.reason}${f.detail ? ` — ${f.detail}` : ''}`))
480
+ process.exit(failed.length ? 1 : 0)
481
+ }
482
+ }
483
+
484
+ // ── hooks uninstall ──────────────────────────────────────────────────────────
485
+ if (hooksSubcmd === 'uninstall') {
486
+ const nameToRemove = args[2]
487
+ if (!nameToRemove || nameToRemove.startsWith('--')) {
488
+ console.error(chalk.red(' ✗ Usage: hskill hooks uninstall <name> [--scope user|project]'))
489
+ process.exit(1)
490
+ }
491
+ const { removed } = await uninstallHook(nameToRemove, hookScopeArg, hookProjectArg)
492
+ if (!removed) console.log(chalk.dim(` · ${nameToRemove} was not installed in ${hookScopeArg} scope`))
493
+ process.exit(0)
494
+ }
495
+
496
+ console.error(chalk.red(` ✗ Unknown hooks subcommand: "${hooksSubcmd}". Use list, install, or uninstall.`))
497
+ process.exit(1)
498
+ }
499
+
249
500
  // ── Install ───────────────────────────────────────────────────────────────────
250
501
  // subcommand is 'install' or omitted (default behavior)
251
502
  const installArgs = subcommand === 'install' ? args.slice(1) : args
@@ -351,7 +602,161 @@ function fzfSelect() {
351
602
  })
352
603
  }
353
604
 
605
+ // ── Print install summary (shared between interactive loop and non-interactive) ──
606
+ function printSummary(skillSummary, toolSummary) {
607
+ if (skillSummary !== null) {
608
+ const anyInstalled = Object.values(skillSummary).some(r => r.installed.length > 0)
609
+ if (!anyInstalled) {
610
+ console.log(chalk.dim(' · No skills installed'))
611
+ } else {
612
+ console.log(chalk.green.bold('✔ Skills installed:'))
613
+ for (const [target, { installed }] of Object.entries(skillSummary)) {
614
+ if (installed.length > 0)
615
+ console.log(` ${chalk.bold(target)} ← ${installed.join(', ')}`)
616
+ }
617
+ }
618
+ for (const [target, { skipped, failed }] of Object.entries(skillSummary)) {
619
+ for (const s of skipped) {
620
+ const detail = s.reason === 'up-to-date'
621
+ ? `up-to-date ${s.version}`
622
+ : `outdated ${s.installed} → ${s.available}, use --force`
623
+ console.log(chalk.dim(` · ${target}/${s.name} skipped (${detail})`))
624
+ }
625
+ for (const f of failed) {
626
+ console.log(chalk.red(` ✗ ${target}/${f.name} failed: ${f.reason}${f.detail ? ` — ${f.detail}` : ''}`))
627
+ }
628
+ }
629
+ }
630
+ if (toolSummary !== null) {
631
+ if (toolSummary.installed.length === 0 && !toolSummary.skipped.length && !toolSummary.failed.length) {
632
+ console.log(chalk.dim(' · No shell tools installed'))
633
+ } else {
634
+ if (toolSummary.installed.length > 0) {
635
+ console.log(chalk.green.bold('✔ Shell tools installed:'))
636
+ for (const name of toolSummary.installed) {
637
+ console.log(` ${chalk.bold('~/.local/bin')} ← ${name}`)
638
+ }
639
+ console.log('')
640
+ console.log(chalk.yellow.bold(' ⚡ Reload your shell to apply changes:'))
641
+ console.log('')
642
+ console.log(` ${chalk.bold.cyan('source ~/.zshrc')}`)
643
+ console.log('')
644
+ }
645
+ for (const s of toolSummary.skipped) {
646
+ console.log(chalk.dim(` · ${s.name} skipped (${s.reason === 'already_exists' ? 'already exists — use --force to overwrite' : s.reason})`))
647
+ }
648
+ for (const f of toolSummary.failed) {
649
+ console.log(chalk.red(` ✗ ${f.name} failed: ${f.reason}${f.detail ? ` — ${f.detail}` : ''}`))
650
+ }
651
+ }
652
+ }
653
+ }
654
+
354
655
  try {
656
+ if (toolArg && skillArg) {
657
+ const msg = '--tool and --skill cannot be combined; use --bundle to install both'
658
+ if (jsonFlag) process.stderr.write(JSON.stringify({ error: true, message: msg }) + '\n')
659
+ else console.error(chalk.red(' ✗ ' + msg))
660
+ process.exit(1)
661
+ }
662
+
663
+ const isInteractive = !toolArg && !skillArg && !bundleArg
664
+
665
+ // ── Interactive mode: loop back to skill selector after each install ────────
666
+ if (isInteractive) {
667
+ if (!process.stdout.isTTY && !process.env.HSKILL_TEST_INTERACTIVE) {
668
+ console.error(chalk.red(' ✗ Interactive mode requires a TTY. Use --bundle, --skill, or --tool flags for non-interactive install.'))
669
+ console.error(chalk.dim(' Example: hskill install --bundle dev --target claude'))
670
+ process.exit(1)
671
+ }
672
+
673
+ while (true) {
674
+ const selected = fzfSelect()
675
+
676
+ if (!selected.length) {
677
+ console.log(chalk.dim(' · Nothing selected, exiting'))
678
+ break
679
+ }
680
+
681
+ const toolItems = selected.filter(s => s.kind === 'tool')
682
+ const seen = new Set()
683
+ const skillItems = selected.filter(s => s.kind === 'skill').filter(s => {
684
+ if (seen.has(s.skillName)) return false
685
+ seen.add(s.skillName); return true
686
+ })
687
+
688
+ if (!skillItems.length && !toolItems.length) continue
689
+
690
+ let skillSummary = null
691
+ if (skillItems.length > 0) {
692
+ // Scope selection
693
+ const scopeResult = spawnSync('fzf', [
694
+ '--prompt= › ',
695
+ '--header= Scope · enter 确认 · esc 取消',
696
+ '--layout=reverse',
697
+ '--border=rounded',
698
+ '--color=header:italic:dim,prompt:cyan,pointer:cyan,hl:cyan,hl+:cyan:bold',
699
+ ], {
700
+ input: `user — ~/.claude/skills/ (所有项目共享)\nproject — .claude/skills/ (仅当前项目)`,
701
+ encoding: 'utf8',
702
+ stdio: ['pipe', 'pipe', 'inherit'],
703
+ })
704
+ if (!scopeResult.stdout.trim()) {
705
+ console.log(chalk.dim(' · Cancelled'))
706
+ break
707
+ }
708
+ const scope = scopeResult.stdout.trim().startsWith('project') ? 'project' : 'user'
709
+
710
+ // Target selection
711
+ const targetChoices = buildTargetChoices(scope)
712
+ const targetInput = targetChoices.map(c => c.name).join('\n') + '\nall — all tools'
713
+ const targetResult = spawnSync('fzf', [
714
+ '--multi',
715
+ '--prompt= › ',
716
+ '--header= Install to · tab 多选 · enter 确认 · esc 取消',
717
+ '--layout=reverse',
718
+ '--border=rounded',
719
+ '--color=header:italic:dim,prompt:cyan,pointer:cyan,hl:cyan,hl+:cyan:bold',
720
+ ], {
721
+ input: targetInput,
722
+ encoding: 'utf8',
723
+ stdio: ['pipe', 'pipe', 'inherit'],
724
+ })
725
+ if (!targetResult.stdout.trim()) {
726
+ console.log(chalk.dim(' · Cancelled'))
727
+ break
728
+ }
729
+ const selectedTargets = targetResult.stdout.trim().split('\n')
730
+ .map(l => l.trim().split(/\s+/)[0])
731
+
732
+ if (selectedTargets.length > 0) {
733
+ const targets = resolveTargets(selectedTargets, scope)
734
+ console.log('')
735
+ skillSummary = await installSkills(skillItems, targets, forceFlag)
736
+ console.log('')
737
+ }
738
+ }
739
+
740
+ let toolSummary = null
741
+ if (toolItems.length > 0) {
742
+ console.log('')
743
+ toolSummary = await installTools(
744
+ toolItems.map(t => ({ toolName: t.toolName, srcPath: t.srcPath })),
745
+ TARGETS.shell,
746
+ forceFlag,
747
+ )
748
+ console.log('')
749
+ }
750
+
751
+ printSummary(skillSummary, toolSummary)
752
+
753
+ // Loop back to skill selector automatically
754
+ }
755
+
756
+ process.exit(0)
757
+ }
758
+
759
+ // ── Non-interactive mode: resolve items from flags, install once ────────────
355
760
  let skillItems = []
356
761
  let toolItems = []
357
762
 
@@ -367,21 +772,6 @@ try {
367
772
  const toolBundles = bundles.filter(b => TOOL_BUNDLE_VALUES.has(b))
368
773
  if (skillBundles.length) skillItems = resolveSkills(skillBundles).map(s => ({ kind: 'skill', ...s }))
369
774
  if (toolBundles.length) toolItems = resolveTools(toolBundles).map(t => ({ kind: 'tool', ...t }))
370
- } else {
371
- const selected = fzfSelect()
372
-
373
- if (!selected.length) {
374
- console.log(chalk.dim(' · Nothing selected, exiting'))
375
- process.exit(0)
376
- }
377
-
378
- toolItems = selected.filter(s => s.kind === 'tool')
379
-
380
- const seen = new Set()
381
- skillItems = selected.filter(s => s.kind === 'skill').filter(s => {
382
- if (seen.has(s.skillName)) return false
383
- seen.add(s.skillName); return true
384
- })
385
775
  }
386
776
 
387
777
  if (!skillItems.length && !toolItems.length) {
@@ -390,10 +780,15 @@ try {
390
780
  }
391
781
 
392
782
  // ── Install skills ──────────────────────────────────────────────────────────
783
+ let skillSummary = null
393
784
  if (skillItems.length > 0) {
394
785
  // Resolve scope
395
786
  let scope = scopeArg ?? 'user'
396
787
  if (!scopeArg && !targetArg) {
788
+ if (!process.stdout.isTTY) {
789
+ console.error(chalk.red(' ✗ Interactive scope selection requires a TTY. Use --scope user|project.'))
790
+ process.exit(1)
791
+ }
397
792
  const scopeResult = spawnSync('fzf', [
398
793
  '--prompt= › ',
399
794
  '--header= Scope · enter 确认 · esc 取消',
@@ -415,8 +810,12 @@ try {
415
810
  // Resolve target
416
811
  let selectedTargets
417
812
  if (targetArg) {
418
- selectedTargets = targetArg === 'all' ? ['claude', 'cursor', 'codex'] : [targetArg]
813
+ selectedTargets = targetArg === 'all' ? ['claude', 'cursor', 'codex', 'openclaw', 'hermes'] : [targetArg]
419
814
  } else {
815
+ if (!process.stdout.isTTY) {
816
+ console.error(chalk.red(' ✗ Interactive target selection requires a TTY. Use --target claude|cursor|codex|openclaw|hermes|all.'))
817
+ process.exit(1)
818
+ }
420
819
  const targetChoices = buildTargetChoices(scope)
421
820
  const targetInput = targetChoices.map(c => c.name).join('\n') + '\nall — all tools'
422
821
  const targetResult = spawnSync('fzf', [
@@ -442,43 +841,37 @@ try {
442
841
  if (selectedTargets.length > 0) {
443
842
  const targets = resolveTargets(selectedTargets, scope)
444
843
  console.log('')
445
- const summary = await installSkills(skillItems, targets, forceFlag)
844
+ skillSummary = await installSkills(skillItems, targets, forceFlag)
446
845
  console.log('')
447
- if (Object.keys(summary).length === 0) {
448
- console.log(chalk.dim(' · No skills installed'))
449
- } else {
450
- console.log(chalk.green.bold('✔ Skills installed:'))
451
- for (const [target, names] of Object.entries(summary)) {
452
- console.log(` ${chalk.bold(target)} ← ${names.join(', ')}`)
453
- }
454
- }
455
846
  }
456
847
  }
457
848
 
458
849
  // ── Install shell tools ─────────────────────────────────────────────────────
850
+ let toolSummary = null
459
851
  if (toolItems.length > 0) {
460
852
  console.log('')
461
- const installed = await installTools(
853
+ toolSummary = await installTools(
462
854
  toolItems.map(t => ({ toolName: t.toolName, srcPath: t.srcPath })),
463
855
  TARGETS.shell,
464
856
  forceFlag,
465
857
  )
466
858
  console.log('')
467
- if (installed.length === 0) {
468
- console.log(chalk.dim(' · No shell tools installed'))
469
- } else {
470
- console.log(chalk.green.bold('✔ Shell tools installed:'))
471
- for (const name of installed) {
472
- console.log(` ${chalk.bold('~/.local/bin')} ${name}`)
473
- }
474
- console.log('')
475
- console.log(chalk.yellow.bold(' Reload your shell to apply changes:'))
476
- console.log('')
477
- console.log(` ${chalk.bold.cyan('source ~/.zshrc')}`)
478
- console.log('')
479
- }
859
+ }
860
+
861
+ // ── Output ──────────────────────────────────────────────────────────────────
862
+ if (jsonFlag) {
863
+ const out = {}
864
+ if (skillSummary !== null) out.skills = skillSummary
865
+ if (toolSummary !== null) out.tools = toolSummary
866
+ console.log(JSON.stringify(out, null, 2))
867
+ } else {
868
+ printSummary(skillSummary, toolSummary)
480
869
  }
481
870
  } catch (err) {
482
- console.error(chalk.red(' ✗ ' + err.message))
871
+ if (jsonFlag) {
872
+ process.stderr.write(JSON.stringify({ error: true, message: err.message }) + '\n')
873
+ } else {
874
+ console.error(chalk.red(' ✗ ' + err.message))
875
+ }
483
876
  process.exit(1)
484
877
  }