harveyz-skill 0.7.0 → 0.9.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-05-28
11
+
12
+ ### Added
13
+ - `hskill uninstall <tool>` 命令:清理 binary、Python 模块、tool.json、venv 及 zshrc snippet
14
+ - `hskill uninstall <tool> --yes`:跳过所有确认(含用户配置文件)
15
+ - `hskill uninstall <skill> --scope <s> --target <t>`:卸载已安装的 skill 目录
16
+ - `tool.json` 新增 `uninstallPaths[]` 和 `configPaths[]` 字段,tool 可声明 tool-specific 清理路径
17
+ - fzf 交互界面在选完 item 后新增 Action 选择步骤(安装 / 卸载),支持在同一界面卸载 tool / skill / hook
18
+ - p-launch:迁移至 Python + Textual,新增三栏 TUI 界面
19
+ - p-launch:push/pull 时检测 diverged 分支(本地和远端均有新提交),跳过操作并提示
20
+ - p-launch:首次运行自动创建隔离 venv 并安装 textual,无需手动配置依赖
21
+
22
+ ### Changed
23
+ - p-launch `tool.json` 新增 `uninstallPaths`(`p-launch-venv`)和 `configPaths`(`~/.config/p-launch`)
24
+
25
+ ## [0.8.1] - 2026-05-24
26
+
27
+ ### Fixed
28
+ - `contribute-skill`:SKILL.md description 字段内含 ASCII 双引号导致 YAML 解析失败
29
+
30
+ ## [0.8.0] - 2026-05-24
31
+
32
+ ### Added
33
+ - `contribute-skill`(`meta` bundle):在其他项目中将本地 skill 目录贡献到 harveyz-skill 仓库,自动完成 SKILL.md 格式规范化、skills-index.json 注册登记、双向目录同步、分支创建与 commit
34
+ - 新增 `meta` bundle,用于对 harveyz-skill 仓库本身的元操作
35
+
10
36
  ## [0.7.0] - 2026-05-24
11
37
 
12
38
  ### Added
package/bin/cli.js CHANGED
@@ -13,7 +13,7 @@ import {
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, installHooks, uninstallHook } from '../lib/installer.js'
16
+ import { installSkills, installTools, installHooks, installHooksForTarget, uninstallHook, uninstallTool, uninstallSkill } from '../lib/installer.js'
17
17
 
18
18
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
19
19
  const require = createRequire(import.meta.url)
@@ -46,6 +46,9 @@ function printHelp() {
46
46
  hskill hooks install [--project <path>] target project dir (for project scope)
47
47
  hskill hooks install [--force] overwrite existing
48
48
  hskill hooks uninstall <name> [--scope <s>] remove hook
49
+ hskill uninstall <tool> uninstall a shell tool and clean up all files
50
+ hskill uninstall <tool> --yes skip all confirmations (incl. config files)
51
+ hskill uninstall <skill> --scope <s> --target <t> uninstall a skill
49
52
  hskill update update hskill to the latest version
50
53
  hskill version show version
51
54
  hskill --help show this help
@@ -109,6 +112,16 @@ if (args[0] === '--help' || args[0] === '-h') {
109
112
  args: ['<name>'],
110
113
  flags: [{ name: '--json', description: 'Machine-readable output' }],
111
114
  },
115
+ {
116
+ name: 'uninstall',
117
+ description: 'Uninstall a shell tool or skill',
118
+ args: ['<name>'],
119
+ flags: [
120
+ { name: '--yes', description: 'Skip all confirmations including config file removal' },
121
+ { name: '--scope', arg: '<scope>', description: 'Skill scope: user or project', enum: ['user','project'] },
122
+ { name: '--target', arg: '<target>', description: 'Skill target: claude, cursor, codex, etc.' },
123
+ ],
124
+ },
112
125
  {
113
126
  name: 'update',
114
127
  description: 'Update hskill to the latest version via npm',
@@ -388,6 +401,54 @@ if (subcommand === 'info') {
388
401
  process.exit(0)
389
402
  }
390
403
 
404
+ // ── Uninstall ─────────────────────────────────────────────────────────────────
405
+ if (subcommand === 'uninstall') {
406
+ const nameToRemove = args[1]
407
+ if (!nameToRemove || nameToRemove.startsWith('--')) {
408
+ console.error(chalk.red(' ✗ Usage: hskill uninstall <tool-or-skill-name> [--yes] [--scope user|project] [--target claude|...]'))
409
+ process.exit(1)
410
+ }
411
+
412
+ const yesFlag = args.includes('--yes')
413
+ const scopeIdx2 = args.indexOf('--scope')
414
+ const targetIdx2 = args.indexOf('--target')
415
+ const scopeArg2 = scopeIdx2 !== -1 ? args[scopeIdx2 + 1] : 'user'
416
+ const targetArg2 = targetIdx2 !== -1 ? args[targetIdx2 + 1] : undefined
417
+
418
+ const toolItems2 = getAllToolItems()
419
+ const skillItems2 = getAllSkillItems()
420
+ const isTool = toolItems2.some(t => t.toolName === nameToRemove)
421
+ const isSkill = skillItems2.some(s => s.skillName === nameToRemove)
422
+
423
+ if (!isTool && !isSkill) {
424
+ console.error(chalk.red(` ✗ Unknown tool or skill: "${nameToRemove}"`))
425
+ process.exit(1)
426
+ }
427
+
428
+ if (isTool) {
429
+ const { removed, failed } = await uninstallTool(nameToRemove, { yes: yesFlag })
430
+ if (removed.length > 0) console.error(chalk.green.bold(`✔ ${nameToRemove} uninstalled`))
431
+ process.exit(failed.length ? 1 : 0)
432
+ }
433
+
434
+ // Skill uninstall
435
+ const scope = scopeArg2
436
+ const selectedTargets = targetArg2
437
+ ? [targetArg2]
438
+ : ['claude', 'cursor', 'codex', 'openclaw', 'hermes']
439
+ const targets = resolveTargets(selectedTargets, scope)
440
+
441
+ let anyRemoved = false
442
+ let anyFailed = false
443
+ for (const { dir } of targets) {
444
+ const { removed, failed } = await uninstallSkill(nameToRemove, dir)
445
+ if (removed.length) anyRemoved = true
446
+ if (failed.length) anyFailed = true
447
+ }
448
+ if (anyRemoved) console.error(chalk.green.bold(`✔ ${nameToRemove} uninstalled`))
449
+ process.exit(anyFailed ? 1 : 0)
450
+ }
451
+
391
452
  // ── Hooks ─────────────────────────────────────────────────────────────────────
392
453
  if (subcommand === 'hooks') {
393
454
  const hooksSubcmd = args[1]
@@ -396,10 +457,12 @@ if (subcommand === 'hooks') {
396
457
  const hookNameIdx = hookArgs.indexOf('--name')
397
458
  const hookScopeIdx = hookArgs.indexOf('--scope')
398
459
  const hookProjectIdx = hookArgs.indexOf('--project')
460
+ const hookTargetIdx = hookArgs.indexOf('--target')
399
461
  const hookForce = hookArgs.includes('--force')
400
462
  const hookNameArg = hookNameIdx !== -1 ? hookArgs[hookNameIdx + 1] : undefined
401
463
  const hookScopeArg = hookScopeIdx !== -1 ? hookArgs[hookScopeIdx + 1] : 'user'
402
464
  const hookProjectArg = hookProjectIdx !== -1 ? hookArgs[hookProjectIdx + 1] : process.cwd()
465
+ const hookTargetArg = hookTargetIdx !== -1 ? hookArgs[hookTargetIdx + 1] : 'claude'
403
466
 
404
467
  // Validate scope for install/uninstall commands
405
468
  if (!['user', 'project'].includes(hookScopeArg) && ['install', 'uninstall'].includes(hooksSubcmd)) {
@@ -414,7 +477,16 @@ if (subcommand === 'hooks') {
414
477
  const out = hookItems.map(h => {
415
478
  const inst = checkHookInstalled(h.name)
416
479
  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 }
480
+ return {
481
+ name: h.name,
482
+ version: ver,
483
+ description: h.description,
484
+ event: h.event,
485
+ user: inst.user,
486
+ project: inst.project,
487
+ claude: inst.claude,
488
+ codex: inst.codex,
489
+ }
418
490
  })
419
491
  console.log(JSON.stringify({ hooks: out }, null, 2))
420
492
  process.exit(0)
@@ -427,15 +499,22 @@ if (subcommand === 'hooks') {
427
499
  const nameWidth = Math.max(...hookItems.map(h => h.name.length), 4)
428
500
  const verWidth = 7
429
501
  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))
502
+ console.log(' ' + chalk.bold('NAME'.padEnd(nameWidth)) + ' ' + chalk.bold('VER'.padEnd(verWidth)) + ' U P CX ' + chalk.bold('DESCRIPTION'))
503
+ console.log(' ' + '─'.repeat(nameWidth) + ' ' + '─'.repeat(verWidth) + ' ─ ─ ── ' + '─'.repeat(20))
432
504
  for (const h of hookItems) {
433
505
  const inst = checkHookInstalled(h.name)
434
506
  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)
507
+ console.log(
508
+ ' ' + h.name.padEnd(nameWidth) +
509
+ ' ' + chalk.dim(ver.padEnd(verWidth)) +
510
+ ' ' + hookIcon(inst.user.status) +
511
+ ' ' + hookIcon(inst.project.status) +
512
+ ' ' + hookIcon(inst.codex.user.status) +
513
+ ' ' + h.description
514
+ )
436
515
  }
437
516
  console.log('')
438
- console.log(chalk.dim(` U=user P=project ${chalk.green('✓')}=installed ${chalk.yellow('~')}=partial ${chalk.dim('—')}=none`))
517
+ console.log(chalk.dim(` U=claude-user P=claude-project CX=codex-user ${chalk.green('✓')}=installed ${chalk.yellow('~')}=partial ${chalk.dim('—')}=none`))
439
518
  console.log('')
440
519
  process.exit(0)
441
520
  }
@@ -467,7 +546,7 @@ if (subcommand === 'hooks') {
467
546
  toInstall = selected
468
547
  }
469
548
 
470
- const { installed, skipped, failed } = await installHooks(toInstall, hookScopeArg, hookProjectArg, hookForce)
549
+ const { installed, skipped, failed } = await installHooksForTarget(toInstall, hookTargetArg, hookScopeArg, hookProjectArg, hookForce)
471
550
 
472
551
  if (hookJsonFlag) {
473
552
  console.log(JSON.stringify({ installed, skipped, failed }, null, 2))
@@ -534,6 +613,7 @@ function fzfSelect() {
534
613
  requireFzf()
535
614
  const skillItems = getAllSkillItems()
536
615
  const toolItems = getAllToolItems()
616
+ const hookItems = getAllHookItems()
537
617
  const previewPath = path.join(__dirname, 'preview.mjs')
538
618
 
539
619
  // 构建 fzf 输入:每行 "NAME\tVERSION\tBUNDLE\tKIND\tSRCPATH"
@@ -543,6 +623,7 @@ function fzfSelect() {
543
623
  return `${s.skillName}\t${s.version ?? '—'}\t${bundle}\tskill\t${s.srcPath}`
544
624
  }),
545
625
  ...toolItems.map(t => `${t.toolName}\t${t.version ?? '—'}\tshell-tool\ttool\t${t.srcPath}`),
626
+ ...hookItems.map(h => `${h.name}\t${h.version ?? '—'}\thook\thook\t${h.srcPath}`),
546
627
  ]
547
628
 
548
629
  const nameWidth = Math.max(...lines.map(l => l.split('\t')[0].length))
@@ -565,6 +646,12 @@ function fzfSelect() {
565
646
  pIcon = colorIcon(scopeSummary(installed.project))
566
647
  } else if (kind === 'tool') {
567
648
  uIcon = colorIcon(checkToolInstalled(name, srcPath).status)
649
+ } else if (kind === 'hook') {
650
+ const inst = checkHookInstalled(name)
651
+ uIcon = colorIcon(inst.user.status === 'installed' ? 'up-to-date'
652
+ : inst.user.status === 'partial' ? 'update' : '')
653
+ pIcon = colorIcon(inst.project.status === 'installed' ? 'up-to-date'
654
+ : inst.project.status === 'partial' ? 'update' : '')
568
655
  }
569
656
  return `${name.padEnd(nameWidth)} ${ver.padEnd(versionWidth)} U:${uIcon} P:${pIcon} ${bundle}`
570
657
  })
@@ -598,12 +685,13 @@ function fzfSelect() {
598
685
  const parts = line.split('\t')
599
686
  const [, name, ver, bundle, kind, srcPath] = parts
600
687
  if (kind === 'skill') return { kind: 'skill', skillName: name, srcPath, version: ver }
688
+ if (kind === 'hook') return { kind: 'hook', name, srcPath, version: ver }
601
689
  return { kind: 'tool', toolName: name, srcPath, version: ver }
602
690
  })
603
691
  }
604
692
 
605
693
  // ── Print install summary (shared between interactive loop and non-interactive) ──
606
- function printSummary(skillSummary, toolSummary) {
694
+ function printSummary(skillSummary, toolSummary, hookSummary = null) {
607
695
  if (skillSummary !== null) {
608
696
  const anyInstalled = Object.values(skillSummary).some(r => r.installed.length > 0)
609
697
  if (!anyInstalled) {
@@ -650,6 +738,26 @@ function printSummary(skillSummary, toolSummary) {
650
738
  }
651
739
  }
652
740
  }
741
+ if (hookSummary !== null) {
742
+ const anyInstalled = Object.values(hookSummary).some(r => r.installed.length > 0)
743
+ if (!anyInstalled) {
744
+ console.log(chalk.dim(' · No hooks installed'))
745
+ } else {
746
+ console.log(chalk.green.bold('✔ Hooks installed:'))
747
+ for (const [target, { installed }] of Object.entries(hookSummary)) {
748
+ if (installed.length > 0)
749
+ console.log(` ${chalk.bold(target)} ← ${installed.join(', ')}`)
750
+ }
751
+ }
752
+ for (const { skipped } of Object.values(hookSummary)) {
753
+ for (const s of skipped) {
754
+ const detail = s.reason === 'outdated'
755
+ ? `outdated ${s.installed} → ${s.available}, use --force`
756
+ : s.reason
757
+ console.log(chalk.dim(` · ${s.name} skipped (${detail})`))
758
+ }
759
+ }
760
+ }
653
761
  }
654
762
 
655
763
  try {
@@ -671,7 +779,14 @@ try {
671
779
  }
672
780
 
673
781
  while (true) {
674
- const selected = fzfSelect()
782
+ // ── Test shortcut: HSKILL_TEST_ACTION bypasses fzf entirely ───────────
783
+ let selected
784
+ if (process.env.HSKILL_TEST_ACTION && process.env.HSKILL_TEST_TOOL) {
785
+ const testTool = getAllToolItems().find(t => t.toolName === process.env.HSKILL_TEST_TOOL)
786
+ selected = testTool ? [{ kind: 'tool', ...testTool }] : []
787
+ } else {
788
+ selected = fzfSelect()
789
+ }
675
790
 
676
791
  if (!selected.length) {
677
792
  console.log(chalk.dim(' · Nothing selected, exiting'))
@@ -679,76 +794,222 @@ try {
679
794
  }
680
795
 
681
796
  const toolItems = selected.filter(s => s.kind === 'tool')
797
+ const hookItems = selected.filter(s => s.kind === 'hook')
682
798
  const seen = new Set()
683
799
  const skillItems = selected.filter(s => s.kind === 'skill').filter(s => {
684
800
  if (seen.has(s.skillName)) return false
685
801
  seen.add(s.skillName); return true
686
802
  })
687
803
 
688
- if (!skillItems.length && !toolItems.length) continue
804
+ if (!skillItems.length && !toolItems.length && !hookItems.length) continue
689
805
 
690
- let skillSummary = null
691
- if (skillItems.length > 0) {
692
- // Scope selection
693
- const scopeResult = spawnSync('fzf', [
806
+ // ── Action selection (install / uninstall) ─────────────────────────────
807
+ let action = 'install'
808
+ if (process.env.HSKILL_TEST_ACTION) {
809
+ action = process.env.HSKILL_TEST_ACTION
810
+ } else {
811
+ const actionResult = spawnSync('fzf', [
694
812
  '--prompt= › ',
695
- '--header= Scope · enter 确认 · esc 取消',
813
+ '--header= Action · enter 确认 · esc 取消',
696
814
  '--layout=reverse',
697
815
  '--border=rounded',
698
816
  '--color=header:italic:dim,prompt:cyan,pointer:cyan,hl:cyan,hl+:cyan:bold',
699
817
  ], {
700
- input: `user — ~/.claude/skills/ (所有项目共享)\nproject — .claude/skills/ (仅当前项目)`,
818
+ input: `install 安装 / 重新安装\nuninstall 卸载并清理文件`,
701
819
  encoding: 'utf8',
702
820
  stdio: ['pipe', 'pipe', 'inherit'],
703
821
  })
704
- if (!scopeResult.stdout.trim()) {
822
+ if (!actionResult.stdout.trim()) {
705
823
  console.log(chalk.dim(' · Cancelled'))
706
824
  break
707
825
  }
708
- const scope = scopeResult.stdout.trim().startsWith('project') ? 'project' : 'user'
826
+ action = actionResult.stdout.trim().startsWith('uninstall') ? 'uninstall' : 'install'
827
+ }
709
828
 
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
829
+ if (action === 'install') {
830
+ let skillSummary = null
831
+ if (skillItems.length > 0) {
832
+ // Scope selection
833
+ const scopeResult = spawnSync('fzf', [
834
+ '--prompt= › ',
835
+ '--header= Scope · enter 确认 · esc 取消',
836
+ '--layout=reverse',
837
+ '--border=rounded',
838
+ '--color=header:italic:dim,prompt:cyan,pointer:cyan,hl:cyan,hl+:cyan:bold',
839
+ ], {
840
+ input: `user — ~/.claude/skills/ (所有项目共享)\nproject — .claude/skills/ (仅当前项目)`,
841
+ encoding: 'utf8',
842
+ stdio: ['pipe', 'pipe', 'inherit'],
843
+ })
844
+ if (!scopeResult.stdout.trim()) {
845
+ console.log(chalk.dim(' · Cancelled'))
846
+ break
847
+ }
848
+ const scope = scopeResult.stdout.trim().startsWith('project') ? 'project' : 'user'
849
+
850
+ // Target selection
851
+ const targetChoices = buildTargetChoices(scope)
852
+ const targetInput = targetChoices.map(c => c.name).join('\n') + '\nall — all tools'
853
+ const targetResult = spawnSync('fzf', [
854
+ '--multi',
855
+ '--prompt= › ',
856
+ '--header= Install to · tab 多选 · enter 确认 · esc 取消',
857
+ '--layout=reverse',
858
+ '--border=rounded',
859
+ '--color=header:italic:dim,prompt:cyan,pointer:cyan,hl:cyan,hl+:cyan:bold',
860
+ ], {
861
+ input: targetInput,
862
+ encoding: 'utf8',
863
+ stdio: ['pipe', 'pipe', 'inherit'],
864
+ })
865
+ if (!targetResult.stdout.trim()) {
866
+ console.log(chalk.dim(' · Cancelled'))
867
+ break
868
+ }
869
+ const selectedTargets = targetResult.stdout.trim().split('\n')
870
+ .map(l => l.trim().split(/\s+/)[0])
871
+
872
+ if (selectedTargets.length > 0) {
873
+ const targets = resolveTargets(selectedTargets, scope)
874
+ console.log('')
875
+ skillSummary = await installSkills(skillItems, targets, forceFlag)
876
+ console.log('')
877
+ }
728
878
  }
729
- const selectedTargets = targetResult.stdout.trim().split('\n')
730
- .map(l => l.trim().split(/\s+/)[0])
731
879
 
732
- if (selectedTargets.length > 0) {
733
- const targets = resolveTargets(selectedTargets, scope)
880
+ let toolSummary = null
881
+ if (toolItems.length > 0) {
734
882
  console.log('')
735
- skillSummary = await installSkills(skillItems, targets, forceFlag)
883
+ toolSummary = await installTools(
884
+ toolItems.map(t => ({ toolName: t.toolName, srcPath: t.srcPath })),
885
+ TARGETS.shell,
886
+ forceFlag,
887
+ )
736
888
  console.log('')
737
889
  }
738
- }
739
890
 
740
- let toolSummary = null
741
- if (toolItems.length > 0) {
891
+ // ── Hook install ────────────────────────────────────────────────────────
892
+ let hookSummary = null
893
+ if (hookItems.length > 0) {
894
+ // Scope selection
895
+ const hookScopeResult = spawnSync('fzf', [
896
+ '--prompt= › ',
897
+ '--header= Hook scope · enter 确认 · esc 取消',
898
+ '--layout=reverse',
899
+ '--border=rounded',
900
+ '--color=header:italic:dim,prompt:cyan,pointer:cyan,hl:cyan,hl+:cyan:bold',
901
+ ], {
902
+ input: `user — ~/.{claude,codex}/hooks/ (所有项目共享)\nproject — .{claude,codex}/hooks/ (仅当前项目)`,
903
+ encoding: 'utf8',
904
+ stdio: ['pipe', 'pipe', 'inherit'],
905
+ })
906
+ if (!hookScopeResult.stdout.trim()) {
907
+ console.log(chalk.dim(' · Cancelled'))
908
+ break
909
+ }
910
+ const hookScope = hookScopeResult.stdout.trim().startsWith('project') ? 'project' : 'user'
911
+
912
+ // Target selection (claude / codex / all)
913
+ const hookTargetResult = spawnSync('fzf', [
914
+ '--multi',
915
+ '--prompt= › ',
916
+ '--header= Install hook to · tab 多选 · enter 确认 · esc 取消',
917
+ '--layout=reverse',
918
+ '--border=rounded',
919
+ '--color=header:italic:dim,prompt:cyan,pointer:cyan,hl:cyan,hl+:cyan:bold',
920
+ ], {
921
+ input: `claude — ~/.claude/hooks/\ncodex — ~/.codex/hooks/\nall — claude + codex`,
922
+ encoding: 'utf8',
923
+ stdio: ['pipe', 'pipe', 'inherit'],
924
+ })
925
+ if (!hookTargetResult.stdout.trim()) {
926
+ console.log(chalk.dim(' · Cancelled'))
927
+ break
928
+ }
929
+
930
+ const selectedHookTargets = hookTargetResult.stdout.trim().split('\n')
931
+ .map(l => l.trim().split(/\s+/)[0])
932
+ const resolvedHookTargets = selectedHookTargets.includes('all')
933
+ ? ['claude', 'codex']
934
+ : selectedHookTargets.filter(t => ['claude', 'codex'].includes(t))
935
+
936
+ hookSummary = {}
937
+ console.log('')
938
+ for (const target of resolvedHookTargets) {
939
+ const result = await installHooksForTarget(hookItems, target, hookScope, process.cwd(), forceFlag)
940
+ hookSummary[target] = result
941
+ }
942
+ console.log('')
943
+ }
944
+
945
+ printSummary(skillSummary, toolSummary, hookSummary)
946
+ } else {
947
+ // ── Uninstall ───────────────────────────────────────────────────────
948
+ const yesFlag2 = !!process.env.HSKILL_TEST_YES
742
949
  console.log('')
743
- toolSummary = await installTools(
744
- toolItems.map(t => ({ toolName: t.toolName, srcPath: t.srcPath })),
745
- TARGETS.shell,
746
- forceFlag,
747
- )
950
+
951
+ // Re-filter after possible HSKILL_TEST_TOOL injection
952
+ const uToolItems = selected.filter(s => s.kind === 'tool')
953
+ const uSkillItems = selected.filter(s => s.kind === 'skill')
954
+ const uHookItems = selected.filter(s => s.kind === 'hook')
955
+
956
+ for (const item of uToolItems) {
957
+ const { removed, failed } = await uninstallTool(item.toolName, { yes: yesFlag2 })
958
+ if (removed.length) console.error(chalk.green.bold(`✔ ${item.toolName} uninstalled`))
959
+ if (failed.length) console.error(chalk.red(` ✗ ${item.toolName}: some files could not be removed`))
960
+ }
961
+
962
+ for (const item of uSkillItems) {
963
+ const scopeRes = spawnSync('fzf', [
964
+ '--prompt= › ',
965
+ '--header= Uninstall from scope · enter 确认 · esc 取消',
966
+ '--layout=reverse', '--border=rounded',
967
+ '--color=header:italic:dim,prompt:cyan,pointer:cyan,hl:cyan,hl+:cyan:bold',
968
+ ], {
969
+ input: `user — ~/.claude/skills/\nproject — .claude/skills/`,
970
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'inherit'],
971
+ })
972
+ if (!scopeRes.stdout.trim()) { console.log(chalk.dim(' · Cancelled')); break }
973
+ const scope2 = scopeRes.stdout.trim().startsWith('project') ? 'project' : 'user'
974
+
975
+ const targetChoices2 = buildTargetChoices(scope2)
976
+ const targetRes = spawnSync('fzf', [
977
+ '--multi', '--prompt= › ',
978
+ '--header= Uninstall from · tab 多选 · enter 确认',
979
+ '--layout=reverse', '--border=rounded',
980
+ '--color=header:italic:dim,prompt:cyan,pointer:cyan,hl:cyan,hl+:cyan:bold',
981
+ ], {
982
+ input: targetChoices2.map(c => c.name).join('\n') + '\nall — all tools',
983
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'inherit'],
984
+ })
985
+ if (!targetRes.stdout.trim()) { console.log(chalk.dim(' · Cancelled')); break }
986
+ const selTargets2 = targetRes.stdout.trim().split('\n').map(l => l.trim().split(/\s+/)[0])
987
+ const targets2 = resolveTargets(selTargets2, scope2)
988
+ for (const { dir } of targets2) {
989
+ await uninstallSkill(item.skillName, dir)
990
+ }
991
+ }
992
+
993
+ for (const item of uHookItems) {
994
+ const hookScopeRes = spawnSync('fzf', [
995
+ '--prompt= › ',
996
+ '--header= Hook scope · enter 确认',
997
+ '--layout=reverse', '--border=rounded',
998
+ '--color=header:italic:dim,prompt:cyan,pointer:cyan,hl:cyan,hl+:cyan:bold',
999
+ ], {
1000
+ input: `user — ~/.claude/hooks/\nproject — .claude/hooks/`,
1001
+ encoding: 'utf8', stdio: ['pipe', 'pipe', 'inherit'],
1002
+ })
1003
+ if (!hookScopeRes.stdout.trim()) { console.log(chalk.dim(' · Cancelled')); break }
1004
+ const hookScope2 = hookScopeRes.stdout.trim().startsWith('project') ? 'project' : 'user'
1005
+ await uninstallHook(item.name, hookScope2, process.cwd())
1006
+ }
1007
+
748
1008
  console.log('')
749
1009
  }
750
1010
 
751
- printSummary(skillSummary, toolSummary)
1011
+ // In test mode with HSKILL_TEST_ACTION, run once and exit
1012
+ if (process.env.HSKILL_TEST_ACTION) break
752
1013
 
753
1014
  // Loop back to skill selector automatically
754
1015
  }
package/lib/bundles.js CHANGED
@@ -193,9 +193,9 @@ export function checkHookInstalled(hookName) {
193
193
  const home = os.homedir()
194
194
  const cwd = process.cwd()
195
195
 
196
- function checkScope(hooksDir, settingsPath) {
197
- const scriptPath = path.join(hooksDir, `${hookName}.sh`)
198
- const scriptExists = fs.existsSync(scriptPath)
196
+ function checkClaudeScope(hooksDir, settingsPath) {
197
+ const scriptPath = path.join(hooksDir, `${hookName}.sh`)
198
+ const scriptExists = fs.existsSync(scriptPath)
199
199
  const installedVersion = scriptExists ? readHookVersion(scriptPath) : '—'
200
200
  let registered = false
201
201
  try {
@@ -208,23 +208,55 @@ export function checkHookInstalled(hookName) {
208
208
  )
209
209
  )
210
210
  } catch { /* settings.json 不存在或解析失败 */ }
211
+ const status = scriptExists && registered ? 'installed'
212
+ : scriptExists || registered ? 'partial'
213
+ : 'none'
214
+ return { status, version: installedVersion }
215
+ }
211
216
 
217
+ function checkCodexScope(hooksDir, hooksJsonPath) {
218
+ const scriptPath = path.join(hooksDir, `${hookName}.sh`)
219
+ const scriptExists = fs.existsSync(scriptPath)
220
+ const installedVersion = scriptExists ? readHookVersion(scriptPath) : '—'
221
+ let registered = false
222
+ try {
223
+ const data = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf-8'))
224
+ registered = Object.values(data.hooks ?? {}).some(entries =>
225
+ Array.isArray(entries) && entries.some(e =>
226
+ Array.isArray(e.hooks) && e.hooks.some(h =>
227
+ typeof h.command === 'string' && h.command.includes(`${hookName}.sh`)
228
+ )
229
+ )
230
+ )
231
+ } catch { /* hooks.json 不存在或解析失败 */ }
212
232
  const status = scriptExists && registered ? 'installed'
213
233
  : scriptExists || registered ? 'partial'
214
234
  : 'none'
215
235
  return { status, version: installedVersion }
216
236
  }
217
237
 
218
- const userHooksDir = path.join(home, '.claude', 'hooks')
219
- const userSettings = path.join(home, '.claude', 'settings.json')
220
- const projectHooksDir = path.join(cwd, '.claude', 'hooks')
221
- const projectSettings = path.join(cwd, '.claude', 'settings.json')
238
+ const userClaudeHooks = path.join(home, '.claude', 'hooks')
239
+ const userClaudeSettings = path.join(home, '.claude', 'settings.json')
240
+ const projClaudeHooks = path.join(cwd, '.claude', 'hooks')
241
+ const projClaudeSettings = path.join(cwd, '.claude', 'settings.json')
242
+
243
+ const userCodexHooks = path.join(home, '.codex', 'hooks')
244
+ const userCodexHooksJson = path.join(home, '.codex', 'hooks.json')
245
+ const projCodexHooks = path.join(cwd, '.codex', 'hooks')
246
+ const projCodexHooksJson = path.join(cwd, '.codex', 'hooks.json')
247
+
248
+ const claudeUser = checkClaudeScope(userClaudeHooks, userClaudeSettings)
249
+ const claudeProject = cwd === home ? { status: 'none', version: '—' } : checkClaudeScope(projClaudeHooks, projClaudeSettings)
250
+ const codexUser = checkCodexScope(userCodexHooks, userCodexHooksJson)
251
+ const codexProject = cwd === home ? { status: 'none', version: '—' } : checkCodexScope(projCodexHooks, projCodexHooksJson)
222
252
 
223
253
  return {
224
- user: checkScope(userHooksDir, userSettings),
225
- project: cwd === home
226
- ? { status: 'none', version: '—' }
227
- : checkScope(projectHooksDir, projectSettings),
254
+ // Legacy flat fields (backward compat — used by hooks list text output)
255
+ user: claudeUser,
256
+ project: claudeProject,
257
+ // Per-target
258
+ claude: { user: claudeUser, project: claudeProject },
259
+ codex: { user: codexUser, project: codexProject },
228
260
  }
229
261
  }
230
262