harveyz-skill 0.25.0 → 0.26.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 (73) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +35 -1
  3. package/bin/cli.js +79 -28
  4. package/bin/preview.mjs +3 -3
  5. package/lib/mcp-server.js +173 -0
  6. package/lib/targets.js +3 -1
  7. package/package.json +8 -4
  8. package/skills/coding/handoff/SKILL.md +65 -0
  9. package/skills/coding/handoff/assets/handoff-template.md +45 -0
  10. package/skills/coding/handoff/evals/evals.json +33 -0
  11. package/skills/coding/handoff/evals/fixtures/accept/handoff-slugify.md +30 -0
  12. package/skills/coding/handoff/evals/fixtures/accept/slugify.js +18 -0
  13. package/skills/coding/handoff/references/config-schema.md +23 -0
  14. package/skills/research/clip-url/SKILL.md +142 -0
  15. package/skills/research/clip-url/references/subagent-self-optimize-prompt.md +130 -0
  16. package/skills/research/clip-url/references/subagent1-fetch-prompt.md +67 -0
  17. package/skills/research/clip-url/references/subagent2-tag-translate-prompt.md +122 -0
  18. package/skills/research/clip-url/scripts/__pycache__/article_meta.cpython-314.pyc +0 -0
  19. package/skills/research/clip-url/scripts/__pycache__/dedup_check.cpython-314.pyc +0 -0
  20. package/skills/research/clip-url/scripts/__pycache__/mcp_debug_client.cpython-314.pyc +0 -0
  21. package/skills/research/clip-url/scripts/__pycache__/mcp_fetch_client.cpython-314.pyc +0 -0
  22. package/skills/research/clip-url/scripts/__pycache__/vault_config.cpython-314.pyc +0 -0
  23. package/skills/research/clip-url/scripts/__pycache__/write_meta_and_separate.cpython-314.pyc +0 -0
  24. package/skills/research/clip-url/scripts/article_meta.py +105 -0
  25. package/skills/research/clip-url/scripts/chrome_profile_config.py +80 -0
  26. package/skills/research/clip-url/scripts/dedup_check.py +38 -0
  27. package/skills/research/clip-url/scripts/detect_xcom_chrome_profile.py +84 -0
  28. package/skills/research/clip-url/scripts/mcp_debug_client.py +63 -0
  29. package/skills/research/clip-url/scripts/mcp_fetch_client.py +107 -0
  30. package/skills/research/clip-url/scripts/vault_config.py +49 -0
  31. package/skills/research/clip-url/scripts/write_meta_and_separate.py +50 -0
  32. package/skills/research/clip-url/tests/__pycache__/conftest.cpython-314-pytest-9.1.1.pyc +0 -0
  33. package/skills/research/clip-url/tests/__pycache__/test_article_meta.cpython-314-pytest-9.1.1.pyc +0 -0
  34. package/skills/research/clip-url/tests/__pycache__/test_chrome_profile_config.cpython-314-pytest-9.1.1.pyc +0 -0
  35. package/skills/research/clip-url/tests/__pycache__/test_dedup_check.cpython-314-pytest-9.1.1.pyc +0 -0
  36. package/skills/research/clip-url/tests/__pycache__/test_detect_xcom_chrome_profile.cpython-314-pytest-9.1.1.pyc +0 -0
  37. package/skills/research/clip-url/tests/__pycache__/test_mcp_debug_client.cpython-314-pytest-9.1.1.pyc +0 -0
  38. package/skills/research/clip-url/tests/__pycache__/test_mcp_fetch_client.cpython-314-pytest-9.1.1.pyc +0 -0
  39. package/skills/research/clip-url/tests/__pycache__/test_vault_config.cpython-314-pytest-9.1.1.pyc +0 -0
  40. package/skills/research/clip-url/tests/__pycache__/test_write_meta_and_separate.cpython-314-pytest-9.1.1.pyc +0 -0
  41. package/skills/research/clip-url/tests/conftest.py +20 -0
  42. package/skills/research/clip-url/tests/test_article_meta.py +106 -0
  43. package/skills/research/clip-url/tests/test_chrome_profile_config.py +46 -0
  44. package/skills/research/clip-url/tests/test_dedup_check.py +63 -0
  45. package/skills/research/clip-url/tests/test_detect_xcom_chrome_profile.py +78 -0
  46. package/skills/research/clip-url/tests/test_mcp_debug_client.py +38 -0
  47. package/skills/research/clip-url/tests/test_mcp_fetch_client.py +140 -0
  48. package/skills/research/clip-url/tests/test_vault_config.py +44 -0
  49. package/skills/research/clip-url/tests/test_write_meta_and_separate.py +88 -0
  50. package/skills/research/extract-url/SKILL.md +2 -1
  51. package/skills/research/extract-url/platforms/SKILL.pi.md +41 -0
  52. package/skills/research/extract-url/references/__pycache__/article_utils.cpython-314.pyc +0 -0
  53. package/skills/research/extract-url/references/subagent1-fetch-prompt.md +9 -0
  54. package/skills/research/extract-url/scripts/__pycache__/config.cpython-314.pyc +0 -0
  55. package/skills/research/extract-url/scripts/__pycache__/migrate_to_folder_structure.cpython-314.pyc +0 -0
  56. package/skills/research/extract-url/scripts/playwright_web_wechat.py +256 -0
  57. package/skills/research/extract-url/scripts/playwright_xcom.py +118 -0
  58. package/skills/research/extract-url/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
  59. package/skills/research/extract-url/tests/__pycache__/test_article_utils_meta.cpython-314-pytest-9.0.2.pyc +0 -0
  60. package/skills/research/extract-url/tests/__pycache__/test_config.cpython-314-pytest-9.0.2.pyc +0 -0
  61. package/skills/research/extract-url/tests/__pycache__/test_dedup_check.cpython-314-pytest-9.0.2.pyc +0 -0
  62. package/skills/research/extract-url/tests/__pycache__/test_migrate_to_folder_structure.cpython-314-pytest-9.0.2.pyc +0 -0
  63. package/skills/research/extract-url/tests/__pycache__/test_playwright_web.cpython-314-pytest-9.0.2.pyc +0 -0
  64. package/skills/research/extract-url/tests/__pycache__/test_playwright_web_arxiv.cpython-314-pytest-9.0.2.pyc +0 -0
  65. package/skills/research/extract-url/tests/__pycache__/test_playwright_web_wechat.cpython-314-pytest-9.0.2.pyc +0 -0
  66. package/skills/research/extract-url/tests/__pycache__/test_playwright_xcom.cpython-314-pytest-9.0.2.pyc +0 -0
  67. package/skills/research/extract-url/tests/__pycache__/test_subagent1_prompt.cpython-314-pytest-9.0.2.pyc +0 -0
  68. package/skills/research/extract-url/tests/__pycache__/test_subagent2_prompt.cpython-314-pytest-9.0.2.pyc +0 -0
  69. package/skills/research/extract-url/tests/__pycache__/test_validate_article.cpython-314-pytest-9.0.2.pyc +0 -0
  70. package/skills/research/extract-url/tests/test_playwright_web_wechat.py +191 -0
  71. package/skills/research/extract-url/tests/test_playwright_xcom.py +103 -1
  72. package/skills/research/pdf-math-translate/SKILL.md +1 -1
  73. package/skills-index.json +20 -6
package/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.26.0] - 2026-08-12
11
+
12
+ ### Added
13
+ - `hskill mcp`:新增 MCP(Model Context Protocol)服务器子命令,以 stdio 方式暴露 8 个工具(`hskill_list`/`hskill_status`/`hskill_outdated`/`hskill_info`/`hskill_install`/`hskill_uninstall`/`hskill_hooks`/`hskill_update`),供 MCP 兼容的 agent host(Claude Code、Claude Desktop、Cursor 等)直接调用 hskill 的操作
14
+
15
+ ### Changed
16
+ - `publish-skill` 审计:修正 `pdf-math-translate` description 语言违规(改为英文),刷新 `capture-vocab`/`extract-url`/`pdf-math-translate` 三个 skill 的 contentHash 记录
17
+
18
+ ## [0.25.1] - 2026-07-22
19
+
20
+ ### Fixed
21
+ - `extract-url`:新增微信公众号文章抓取脚本(`playwright_web_wechat.py`),修复隐藏正文、懒加载图片、发布日期提取失败问题
22
+ - `extract-url`:修复 X.com Article 正文中加粗文字被错误拆分为独立段落、加粗样式丢失的问题
23
+
10
24
  ## [0.25.0] - 2026-07-20
11
25
 
12
26
  ### Added
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # harveyz-skill
2
2
 
3
- Harvey 的个人 Claude Code / Cursor / Codex 技能管理器,当前版本 **v0.6.2**。
3
+ Harvey 的个人 Claude Code / Cursor / Codex / OpenClaw / Hermes / OpenCode / Pi 技能管理器,当前版本 **v0.6.2**。
4
4
 
5
5
  通过 `hskill` 命令将技能安装到 `~/.claude/skills/`,按 bundle 分组管理。
6
6
 
@@ -134,6 +134,40 @@ hskill hooks uninstall check-similar-branch --scope project # 从当前项目
134
134
 
135
135
  ---
136
136
 
137
+ ## MCP Server
138
+
139
+ `hskill mcp` 会启动一个基于 stdio 的 MCP(Model Context Protocol)服务器,让 MCP 兼容的 agent host(Claude Code、Claude Desktop、Cursor 等)可以直接调用 hskill 的操作,而不需要人工手动执行 CLI 命令。
140
+
141
+ 暴露的 8 个工具:
142
+
143
+ | 工具 | 说明 |
144
+ |------|------|
145
+ | **hskill_list** | 列出所有可用的 skill 和 bundle |
146
+ | **hskill_status** | 显示所有 skill、工具、hook 在各个 target 上的安装状态 |
147
+ | **hskill_outdated** | 列出有可用更新的 skill 和工具 |
148
+ | **hskill_info** | 显示指定 skill 或工具的安装详情 |
149
+ | **hskill_install** | 安装一个 bundle、指定 skill 或 shell 工具 |
150
+ | **hskill_uninstall** | 卸载指定的 skill 或 shell 工具 |
151
+ | **hskill_hooks** | 查看、安装或卸载 hskill git hooks |
152
+ | **hskill_update** | 将 hskill 自身更新到最新版本 |
153
+
154
+ 在 MCP host 的配置中注册(假设已通过 `npm install -g harveyz-skill` 全局安装):
155
+
156
+ ```json
157
+ {
158
+ "mcpServers": {
159
+ "hskill": {
160
+ "command": "hskill",
161
+ "args": ["mcp"]
162
+ }
163
+ }
164
+ }
165
+ ```
166
+
167
+ 本地源码开发时,可将 `command` 换成 `node`,`args` 换成 `["bin/cli.js", "mcp"]`。
168
+
169
+ ---
170
+
137
171
  ## Skill 开发指南
138
172
 
139
173
  ### 目录结构
package/bin/cli.js CHANGED
@@ -27,7 +27,7 @@ const jsonFlag = args.includes('--json')
27
27
  // ── Help ─────────────────────────────────────────────────────────────────────
28
28
  function printHelp() {
29
29
  console.log(`
30
- ${chalk.bold('hskill')} — skill manager for Claude Code, Cursor, Codex, OpenClaw, Hermes, and OpenCode v${version}
30
+ ${chalk.bold('hskill')} — skill manager for Claude Code, Cursor, Codex, OpenClaw, Hermes, OpenCode, and Pi v${version}
31
31
 
32
32
  ${chalk.cyan('Usage:')}
33
33
  hskill interactive install (requires TTY + fzf)
@@ -35,7 +35,7 @@ function printHelp() {
35
35
  hskill install --bundle <b> install a skill bundle
36
36
  hskill install --skill <s> install specific skill(s)
37
37
  hskill install --tool <t> install shell tool(s)
38
- hskill install --target <t> set target (claude/cursor/codex/openclaw/hermes/opencode/all)
38
+ hskill install --target <t> set target (${SKILL_TARGETS.join('/')}/all)
39
39
  hskill install --scope <s> set scope: user (default) or project
40
40
  hskill install --force overwrite existing installs
41
41
  hskill list [--json] list available skills and bundles
@@ -51,6 +51,7 @@ function printHelp() {
51
51
  hskill uninstall <tool> --yes skip all confirmations (incl. config files)
52
52
  hskill uninstall <skill> --scope <s> --target <t> uninstall a skill
53
53
  hskill update update hskill to the latest version
54
+ hskill mcp start an MCP server (stdio) exposing hskill's tools to MCP-capable agent hosts
54
55
  hskill version show version
55
56
  hskill --help show this help
56
57
 
@@ -75,7 +76,7 @@ if (args[0] === '--help' || args[0] === '-h') {
75
76
  console.log(JSON.stringify({
76
77
  name: 'hskill',
77
78
  version,
78
- description: 'Skill manager for Claude Code, Cursor, Codex, OpenClaw, and Hermes',
79
+ description: 'Skill manager for Claude Code, Cursor, Codex, OpenClaw, Hermes, OpenCode, and Pi',
79
80
  agent_notes: 'Interactive mode requires TTY. Use --json for machine-readable output. Set NO_COLOR=1 to suppress ANSI codes.',
80
81
  commands: [
81
82
  {
@@ -87,7 +88,7 @@ if (args[0] === '--help' || args[0] === '-h') {
87
88
  { name: '--bundle', arg: '<name>', description: 'Install a skill bundle (comma-separated)' },
88
89
  { name: '--skill', arg: '<name>', description: 'Install specific skill(s) (comma-separated)' },
89
90
  { name: '--tool', arg: '<name>', description: 'Install shell tool(s) (comma-separated)' },
90
- { name: '--target', arg: '<target>', description: 'Install target', enum: ['claude','cursor','codex','openclaw','hermes','opencode','all'] },
91
+ { name: '--target', arg: '<target>', description: 'Install target', enum: [...SKILL_TARGETS,'all'] },
91
92
  { name: '--scope', arg: '<scope>', description: 'Install scope', enum: ['user','project'], default: 'user' },
92
93
  { name: '--force', description: 'Overwrite existing installs' },
93
94
  ],
@@ -129,7 +130,7 @@ if (args[0] === '--help' || args[0] === '-h') {
129
130
  note: 'Only upgrades skills already installed on the given target. Never installs new ones.',
130
131
  flags: [
131
132
  { name: '--skill', arg: '<name>', description: 'Upgrade a specific skill (default: all installed)' },
132
- { name: '--target', arg: '<target>', description: 'Limit to one target', enum: ['claude','cursor','codex','openclaw','hermes','opencode'] },
133
+ { name: '--target', arg: '<target>', description: 'Limit to one target', enum: SKILL_TARGETS },
133
134
  { name: '--scope', arg: '<scope>', description: 'Install scope', enum: ['user','project'], default: 'user' },
134
135
  { name: '--json', description: 'Machine-readable output' },
135
136
  ],
@@ -138,6 +139,10 @@ if (args[0] === '--help' || args[0] === '-h') {
138
139
  name: 'update',
139
140
  description: 'Update hskill to the latest version via npm',
140
141
  },
142
+ {
143
+ name: 'mcp',
144
+ description: "Start an MCP server (stdio) exposing hskill's tools to MCP-capable agent hosts",
145
+ },
141
146
  {
142
147
  name: 'version',
143
148
  description: 'Print version and exit',
@@ -189,6 +194,20 @@ if (subcommand === 'update') {
189
194
  process.exit(0)
190
195
  }
191
196
 
197
+ // ── MCP server ───────────────────────────────────────────────────────────────
198
+ if (subcommand === 'mcp') {
199
+ const { startServer } = await import('../lib/mcp-server.js')
200
+ await startServer()
201
+ // StdioServerTransport keeps stdin/stdout open for JSON-RPC. bin/cli.js is a
202
+ // flat top-level script with no early-return mechanism (this is a real ES
203
+ // module, so a bare top-level `return` is a syntax error) — every other
204
+ // subcommand block ends itself with process.exit(), which we can't do here
205
+ // without killing the server we just started. Blocking on a promise that
206
+ // never resolves is what stops execution from falling through into the
207
+ // generic install-arg parsing near the end of this file.
208
+ await new Promise(() => {})
209
+ }
210
+
192
211
  // ── List ─────────────────────────────────────────────────────────────────────
193
212
  if (subcommand === 'list') {
194
213
  const { skills, tools = [] } = require('../skills-index.json')
@@ -285,7 +304,7 @@ if (subcommand === 'status' || subcommand === 'outdated') {
285
304
  })
286
305
 
287
306
  if (jsonFlag) {
288
- const targets = ['claude', 'cursor', 'codex', 'openclaw', 'hermes', 'opencode']
307
+ const targets = SKILL_TARGETS
289
308
  const jsonSkills = skillRows.map(r => ({
290
309
  name: r.name,
291
310
  version: r.version,
@@ -317,7 +336,7 @@ if (subcommand === 'status' || subcommand === 'outdated') {
317
336
  process.exit(0)
318
337
  }
319
338
 
320
- const targets = ['claude', 'cursor', 'codex', 'openclaw', 'hermes', 'opencode']
339
+ const targets = SKILL_TARGETS
321
340
 
322
341
  if (outdatedSkills.length) {
323
342
  console.log('\n ' + chalk.bold('SKILLS WITH UPDATES'))
@@ -420,7 +439,7 @@ if (subcommand === 'info') {
420
439
  process.exit(1)
421
440
  }
422
441
 
423
- const targets = ['claude', 'cursor', 'codex']
442
+ const targets = SKILL_TARGETS
424
443
 
425
444
  function statusLabel(status) {
426
445
  if (status === 'up-to-date') return chalk.green('✓ up to date')
@@ -476,7 +495,9 @@ if (subcommand === 'info') {
476
495
  if (subcommand === 'uninstall') {
477
496
  const nameToRemove = args[1]
478
497
  if (!nameToRemove || nameToRemove.startsWith('--')) {
479
- console.error(chalk.red('Usage: hskill uninstall <tool-or-skill-name> [--yes] [--scope user|project] [--target claude|...]'))
498
+ const msg = 'Usage: hskill uninstall <tool-or-skill-name> [--yes] [--scope user|project] [--target claude|...]'
499
+ if (jsonFlag) process.stderr.write(JSON.stringify({ error: true, message: msg }) + '\n')
500
+ else console.error(chalk.red(' ✗ ' + msg))
480
501
  process.exit(1)
481
502
  }
482
503
 
@@ -492,13 +513,24 @@ if (subcommand === 'uninstall') {
492
513
  const isSkill = skillItems2.some(s => s.skillName === nameToRemove)
493
514
 
494
515
  if (!isTool && !isSkill) {
495
- console.error(chalk.red(`Unknown tool or skill: "${nameToRemove}"`))
516
+ const msg = `Unknown tool or skill: "${nameToRemove}"`
517
+ if (jsonFlag) process.stderr.write(JSON.stringify({ error: true, message: msg }) + '\n')
518
+ else console.error(chalk.red(' ✗ ' + msg))
496
519
  process.exit(1)
497
520
  }
498
521
 
499
522
  if (isTool) {
523
+ // uninstallTool logs unconditional chalk status lines via console.error;
524
+ // suppress them in --json mode so they don't pollute stderr JSON output.
525
+ const originalError = jsonFlag ? console.error : null
526
+ if (jsonFlag) console.error = () => {}
500
527
  const { removed, failed } = await uninstallTool(nameToRemove, { yes: yesFlag })
501
- if (removed.length > 0) console.error(chalk.green.bold(`✔ ${nameToRemove} uninstalled`))
528
+ if (jsonFlag) {
529
+ console.error = originalError
530
+ console.log(JSON.stringify({ removed: removed.length > 0, failed: failed.length > 0 }, null, 2))
531
+ } else if (removed.length > 0) {
532
+ console.error(chalk.green.bold(`✔ ${nameToRemove} uninstalled`))
533
+ }
502
534
  process.exit(failed.length ? 1 : 0)
503
535
  }
504
536
 
@@ -506,17 +538,26 @@ if (subcommand === 'uninstall') {
506
538
  const scope = scopeArg2
507
539
  const selectedTargets = targetArg2
508
540
  ? [targetArg2]
509
- : ['claude', 'cursor', 'codex', 'openclaw', 'hermes', 'opencode']
541
+ : SKILL_TARGETS
510
542
  const targets = resolveTargets(selectedTargets, scope)
511
543
 
512
544
  let anyRemoved = false
513
545
  let anyFailed = false
546
+ // uninstallSkill logs unconditional chalk status lines via console.error;
547
+ // suppress them in --json mode so they don't pollute stderr JSON output.
548
+ const originalError2 = jsonFlag ? console.error : null
549
+ if (jsonFlag) console.error = () => {}
514
550
  for (const { dir } of targets) {
515
551
  const { removed, failed } = await uninstallSkill(nameToRemove, dir)
516
552
  if (removed.length) anyRemoved = true
517
553
  if (failed.length) anyFailed = true
518
554
  }
519
- if (anyRemoved) console.error(chalk.green.bold(`✔ ${nameToRemove} uninstalled`))
555
+ if (jsonFlag) {
556
+ console.error = originalError2
557
+ console.log(JSON.stringify({ removed: anyRemoved, failed: anyFailed }, null, 2))
558
+ } else if (anyRemoved) {
559
+ console.error(chalk.green.bold(`✔ ${nameToRemove} uninstalled`))
560
+ }
520
561
  process.exit(anyFailed ? 1 : 0)
521
562
  }
522
563
 
@@ -635,11 +676,17 @@ if (subcommand === 'hooks') {
635
676
  if (hooksSubcmd === 'uninstall') {
636
677
  const nameToRemove = args[2]
637
678
  if (!nameToRemove || nameToRemove.startsWith('--')) {
638
- console.error(chalk.red('Usage: hskill hooks uninstall <name> [--scope user|project]'))
679
+ const msg = 'Usage: hskill hooks uninstall <name> [--scope user|project]'
680
+ if (hookJsonFlag) process.stderr.write(JSON.stringify({ error: true, message: msg }) + '\n')
681
+ else console.error(chalk.red(' ✗ ' + msg))
639
682
  process.exit(1)
640
683
  }
641
684
  const { removed } = await uninstallHook(nameToRemove, hookScopeArg, hookProjectArg)
642
- if (!removed) console.log(chalk.dim(` · ${nameToRemove} was not installed in ${hookScopeArg} scope`))
685
+ if (hookJsonFlag) {
686
+ console.log(JSON.stringify({ removed }, null, 2))
687
+ } else if (!removed) {
688
+ console.log(chalk.dim(` · ${nameToRemove} was not installed in ${hookScopeArg} scope`))
689
+ }
643
690
  process.exit(0)
644
691
  }
645
692
 
@@ -721,20 +768,24 @@ const scopeArg = scopeIdx !== -1 ? installArgs[scopeIdx + 1] : undefined
721
768
  const TOOL_BUNDLE_VALUES = new Set(TOOL_BUNDLE_CHOICES.map(c => c.value))
722
769
 
723
770
  // ── Two-step target→scope selector ───────────────────────────────────────────
724
- const ALL_SKILL_TARGETS = ['claude', 'cursor', 'codex', 'openclaw', 'hermes', 'opencode']
771
+ // Paths are derived from SKILL_TARGETS/userSkillDir (lib/targets.js) the single
772
+ // source of truth — so adding a target there is enough to update every view here.
773
+ const TARGET_DIRS = SKILL_TARGETS.map(name => {
774
+ const real = userSkillDir(name)
775
+ return { name, real, disp: real.replace(os.homedir(), '~') }
776
+ })
725
777
 
726
778
  function selectTargetThenScope() {
727
779
  // Step 1: platform (target)
728
780
  const targetInput = [
729
- 'claude ~/.claude/skills/',
730
- 'cursor ~/.cursor/skills/',
731
- 'codex ~/.codex/skills/',
732
- 'openclaw ~/.openclaw/skills/',
733
- 'hermes ~/.hermes/skills/',
734
- 'opencode ~/.config/opencode/skills/',
735
- 'all all 6 targets',
781
+ ...TARGET_DIRS.map(({ name, disp }) => `${name.padEnd(8)} ${disp}/`),
782
+ `all all ${TARGET_DIRS.length} targets`,
736
783
  ].join('\n')
737
784
 
785
+ const allEchoLines = TARGET_DIRS.map(({ disp }) => `echo " ${disp}/";`).join(' ')
786
+ const caseArms = TARGET_DIRS.map(({ name, disp, real }) => `${name}) disp="${disp}/"; real="${real}/";;`).join(' ')
787
+ const previewScript = `t=$(echo {} | awk '{print $1}'); if [ "$t" = "all" ]; then echo ""; echo " 安装到所有平台:"; echo ""; ${allEchoLines} else case "$t" in ${caseArms} esac; echo ""; echo " 安装路径:"; echo ""; printf " \\033[36m%s\\033[0m\\n" "$disp"; echo ""; if [ -d "$real" ]; then echo " ✓ 目录已存在"; else echo " · 将自动新建"; fi; fi`
788
+
738
789
  const targetResult = spawnSync('fzf', [
739
790
  '--multi',
740
791
  '--prompt= › ',
@@ -742,7 +793,7 @@ function selectTargetThenScope() {
742
793
  '--layout=reverse',
743
794
  '--border=rounded',
744
795
  '--color=header:italic:dim,prompt:cyan,pointer:cyan,hl:cyan,hl+:cyan:bold',
745
- '--preview=t=$(echo {} | awk \'{print $1}\'); if [ "$t" = "all" ]; then echo ""; echo " 安装到所有平台:"; echo ""; for p in claude cursor codex openclaw hermes opencode; do printf " ~/.%s/skills/\\n" "$p"; done; else p="${HOME}/.${t}/skills/"; echo ""; echo " 安装路径:"; echo ""; printf " \\033[36m%s\\033[0m\\n" "$p"; echo ""; if [ -d "$p" ]; then echo " ✓ 目录已存在"; else echo " · 将自动新建"; fi; fi',
796
+ `--preview=${previewScript}`,
746
797
  '--preview-window=right:45%:wrap',
747
798
  ], {
748
799
  input: targetInput,
@@ -754,7 +805,7 @@ function selectTargetThenScope() {
754
805
 
755
806
  const rawTargets = targetResult.stdout.trim().split('\n')
756
807
  .map(l => l.trim().split(/\s+/)[0])
757
- const expandedTargets = rawTargets.includes('all') ? ALL_SKILL_TARGETS : rawTargets
808
+ const expandedTargets = rawTargets.includes('all') ? SKILL_TARGETS : rawTargets
758
809
 
759
810
  // Step 2: scope (user/project) — skip if all selected targets are user-only
760
811
  const allUserOnly = expandedTargets.every(t => USER_ONLY_TARGETS.has(t))
@@ -1258,7 +1309,7 @@ try {
1258
1309
  '--header= 从哪里卸载 · tab 多选 · enter 确认 · esc 取消',
1259
1310
  '--layout=reverse', '--border=rounded',
1260
1311
  '--color=header:italic:dim,prompt:cyan,pointer:cyan,hl:cyan,hl+:cyan:bold',
1261
- '--preview=t=$(echo {} | awk \'{print $1}\'); if [ "$t" = "all" ]; then echo ""; echo " 从所有平台卸载:"; echo ""; for p in claude cursor codex openclaw hermes opencode; do printf " ~/.%s/skills/\\n" "$p"; done; else echo ""; printf " ~/.%s/skills/\\n" "$t"; fi',
1312
+ `--preview=t=$(echo {} | awk '{print $1}'); if [ "$t" = "all" ]; then echo ""; echo " 从所有平台卸载:"; echo ""; ${TARGET_DIRS.map(({ disp }) => `echo " ${disp}/";`).join(' ')} else case "$t" in ${TARGET_DIRS.map(({ name, disp }) => `${name}) echo ""; echo " ${disp}/";;`).join(' ')} esac; fi`,
1262
1313
  '--preview-window=right:45%:wrap',
1263
1314
  ], {
1264
1315
  input: targetChoices2.map(c => c.name).join('\n') + '\nall — all targets',
@@ -1349,14 +1400,14 @@ try {
1349
1400
  // When only --skill is given and no --target, use the combined selector.
1350
1401
  if (targetArg) {
1351
1402
  const scope = scopeArg ?? 'user'
1352
- const selectedTargets = targetArg === 'all' ? ['claude', 'cursor', 'codex', 'openclaw', 'hermes', 'opencode'] : [targetArg]
1403
+ const selectedTargets = targetArg === 'all' ? SKILL_TARGETS : [targetArg]
1353
1404
  const targets = resolveTargets(selectedTargets, scope)
1354
1405
  console.log('')
1355
1406
  skillSummary = await installSkills(skillItems, targets, forceFlag)
1356
1407
  console.log('')
1357
1408
  } else {
1358
1409
  if (!process.stdout.isTTY) {
1359
- console.error(chalk.red(' ✗ Interactive target selection requires a TTY. Use --target claude|cursor|codex|openclaw|hermes|all.'))
1410
+ console.error(chalk.red(` ✗ Interactive target selection requires a TTY. Use --target ${SKILL_TARGETS.join('|')}|all.`))
1360
1411
  process.exit(1)
1361
1412
  }
1362
1413
  const selectedST = selectTargetThenScope()
package/bin/preview.mjs CHANGED
@@ -3,7 +3,7 @@ import fs from 'fs'
3
3
  import os from 'os'
4
4
  import path from 'path'
5
5
  import { fileURLToPath } from 'url'
6
- import { SKILL_TARGETS, USER_ONLY_TARGETS } from '../lib/targets.js'
6
+ import { SKILL_TARGETS, USER_ONLY_TARGETS, userSkillDir } from '../lib/targets.js'
7
7
 
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
9
 
@@ -89,7 +89,7 @@ function checkScope(targets, dirFn) {
89
89
  // 单平台视图:只展示 ctrl-t 当前所在平台的状态,不展示全平台矩阵
90
90
  if (platformKey && SKILL_TARGETS.includes(platformKey)) {
91
91
  const cwd = process.cwd()
92
- const userDir = path.join(home, `.${platformKey}`, 'skills')
92
+ const userDir = userSkillDir(platformKey)
93
93
  const projectDir = path.join(cwd, `.${platformKey}`, 'skills')
94
94
  const [userDetail] = checkScope([platformKey], () => userDir)
95
95
  const projectDetail = USER_ONLY_TARGETS.has(platformKey) || cwd === home
@@ -119,7 +119,7 @@ const userTargets = SKILL_TARGETS
119
119
  const projectTargets = SKILL_TARGETS.filter(t => !USER_ONLY_TARGETS.has(t))
120
120
 
121
121
  const cwd = process.cwd()
122
- const userDetails = checkScope(userTargets, t => path.join(home, `.${t}`, 'skills'))
122
+ const userDetails = checkScope(userTargets, t => userSkillDir(t))
123
123
  const projectDetails = cwd === home
124
124
  ? projectTargets.map(t => ({ tool: t, version: '—', status: 'none' }))
125
125
  : checkScope(projectTargets, t => path.join(cwd, `.${t}`, 'skills'))
@@ -0,0 +1,173 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
+ import { spawn } from 'child_process'
4
+ import path from 'path'
5
+ import { fileURLToPath } from 'url'
6
+ import { z } from 'zod'
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
+ const CLI_PATH = path.join(__dirname, '..', 'bin', 'cli.js')
10
+
11
+ // Spawns `node bin/cli.js <argv>` and collects its full stdout/stderr/exit code.
12
+ // Never rejects — callers branch on `code`.
13
+ export function runCli(argv) {
14
+ return new Promise((resolve) => {
15
+ const child = spawn(process.execPath, [CLI_PATH, ...argv], { stdio: ['ignore', 'pipe', 'pipe'] })
16
+ child.stdout.setEncoding('utf8')
17
+ child.stderr.setEncoding('utf8')
18
+ let stdout = ''
19
+ let stderr = ''
20
+ child.stdout.on('data', (d) => { stdout += d })
21
+ child.stderr.on('data', (d) => { stderr += d })
22
+ child.on('close', (code) => resolve({ code, stdout, stderr }))
23
+ })
24
+ }
25
+
26
+ // Turns a runCli() result into an MCP CallToolResult. On success, the CLI's
27
+ // stdout JSON is forwarded as-is. On failure, stderr is parsed as the CLI's
28
+ // existing {error:true,message} shape; if that parse fails, the raw stderr
29
+ // text is used so a handler never throws on an unexpected crash.
30
+ export function toToolResult({ code, stdout, stderr }) {
31
+ if (code === 0) {
32
+ return { content: [{ type: 'text', text: stdout.trim() || '{}' }] }
33
+ }
34
+ // Some CLI commands write valid structured JSON to stdout even on a
35
+ // non-zero exit (e.g. uninstall's "found but removal failed" case).
36
+ // Forward that real payload instead of falling back to a generic message.
37
+ const trimmedStdout = stdout.trim()
38
+ if (trimmedStdout) {
39
+ try {
40
+ JSON.parse(trimmedStdout)
41
+ return { content: [{ type: 'text', text: trimmedStdout }], isError: true }
42
+ } catch {
43
+ // stdout wasn't JSON — fall through to the stderr-based message below
44
+ }
45
+ }
46
+ let message = stderr.trim() || `hskill exited with code ${code}`
47
+ try {
48
+ const parsed = JSON.parse(stderr.trim())
49
+ if (parsed && parsed.error) message = parsed.message
50
+ } catch {
51
+ const lastLine = stderr.trim().split('\n').pop()
52
+ try {
53
+ const parsed = JSON.parse(lastLine)
54
+ if (parsed && parsed.error) message = parsed.message
55
+ } catch {
56
+ // stderr wasn't JSON — fall back to the raw text already assigned above
57
+ }
58
+ }
59
+ return { content: [{ type: 'text', text: message }], isError: true }
60
+ }
61
+
62
+ export function createServer() {
63
+ const server = new McpServer({ name: 'hskill', version: '1.0.0' })
64
+
65
+ server.registerTool('hskill_list', {
66
+ description: 'List all available hskill skills and bundles.',
67
+ inputSchema: {},
68
+ }, async () => toToolResult(await runCli(['list', '--json'])))
69
+
70
+ server.registerTool('hskill_status', {
71
+ description: 'Show install status for all skills, tools, and hooks across every target.',
72
+ inputSchema: {},
73
+ }, async () => toToolResult(await runCli(['status', '--json'])))
74
+
75
+ server.registerTool('hskill_outdated', {
76
+ description: 'List only the skills and tools that have an available update.',
77
+ inputSchema: {},
78
+ }, async () => toToolResult(await runCli(['outdated', '--json'])))
79
+
80
+ server.registerTool('hskill_info', {
81
+ description: 'Show install detail for one named skill or tool, as returned by hskill_list.',
82
+ inputSchema: {
83
+ name: z.string().describe('Skill or tool name, exactly as shown by hskill_list'),
84
+ },
85
+ }, async ({ name }) => toToolResult(await runCli(['info', name, '--json'])))
86
+
87
+ server.registerTool('hskill_install', {
88
+ description: 'Install a skill bundle, a specific skill, or a shell tool. Writes files under ~/.claude/skills (or the equivalent dir for --target) or a project directory. Provide exactly one of bundle, skill, or tool.',
89
+ inputSchema: {
90
+ bundle: z.string().optional().describe('Bundle name(s) to install, comma-separated'),
91
+ skill: z.string().optional().describe('Specific skill name(s) to install, comma-separated'),
92
+ tool: z.string().optional().describe('Specific shell tool name(s) to install, comma-separated'),
93
+ target: z.string().optional().describe('Target host: claude, cursor, codex, opencode, etc., or "all"'),
94
+ scope: z.enum(['user', 'project']).optional().describe('Install scope, defaults to user'),
95
+ force: z.boolean().optional().describe('Overwrite an existing install'),
96
+ },
97
+ }, async ({ bundle, skill, tool, target, scope, force }) => {
98
+ const argv = ['install']
99
+ if (bundle) argv.push('--bundle', bundle)
100
+ if (skill) argv.push('--skill', skill)
101
+ if (tool) argv.push('--tool', tool)
102
+ if (target) argv.push('--target', target)
103
+ if (scope) argv.push('--scope', scope)
104
+ if (force) argv.push('--force')
105
+ argv.push('--json')
106
+ return toToolResult(await runCli(argv))
107
+ })
108
+
109
+ server.registerTool('hskill_uninstall', {
110
+ description: 'Uninstall a named skill or shell tool, removing its files from disk. If target is omitted, the skill is removed from every install target (claude, cursor, codex, opencode, etc.), not just one.',
111
+ inputSchema: {
112
+ name: z.string().describe('Skill or tool name to uninstall, as shown by hskill_list'),
113
+ scope: z.enum(['user', 'project']).optional().describe('Scope to uninstall from, defaults to user'),
114
+ target: z.string().optional().describe('Target host to uninstall from'),
115
+ yes: z.boolean().optional().describe('Skip confirmation prompts'),
116
+ },
117
+ }, async ({ name, scope, target, yes }) => {
118
+ const argv = ['uninstall', name]
119
+ if (scope) argv.push('--scope', scope)
120
+ if (target) argv.push('--target', target)
121
+ if (yes) argv.push('--yes')
122
+ argv.push('--json')
123
+ return toToolResult(await runCli(argv))
124
+ })
125
+
126
+ server.registerTool('hskill_hooks', {
127
+ description: 'List, install, or uninstall hskill git hooks. Install/uninstall write files under ~/.claude/hooks (or the project dir for --scope project) and register them in settings.json.',
128
+ inputSchema: {
129
+ action: z.enum(['list', 'install', 'uninstall']),
130
+ name: z.string().optional().describe('Hook name — required for install/uninstall'),
131
+ scope: z.enum(['user', 'project']).optional().describe('Scope, defaults to user'),
132
+ project: z.string().optional().describe('Project directory, used when scope is project'),
133
+ force: z.boolean().optional().describe('Overwrite an existing hook install'),
134
+ },
135
+ }, async ({ action, name, scope, project, force }) => {
136
+ if (action !== 'list' && !name) {
137
+ return { content: [{ type: 'text', text: 'name is required for install/uninstall actions' }], isError: true }
138
+ }
139
+ const argv = ['hooks', action]
140
+ if (action === 'uninstall' && name) {
141
+ // uninstall expects hook name as positional argument
142
+ argv.push(name)
143
+ } else if (name) {
144
+ // install expects --name flag
145
+ argv.push('--name', name)
146
+ }
147
+ if (scope) argv.push('--scope', scope)
148
+ if (project) argv.push('--project', project)
149
+ if (force) argv.push('--force')
150
+ argv.push('--json')
151
+ return toToolResult(await runCli(argv))
152
+ })
153
+
154
+ server.registerTool('hskill_update', {
155
+ description: 'Update hskill itself to the latest version via `npm install -g harveyz-skill@latest`. This replaces the globally installed hskill binary and is irreversible except by manually reinstalling a specific version.',
156
+ inputSchema: {},
157
+ }, async () => {
158
+ const { code, stdout, stderr } = await runCli(['update'])
159
+ if (code === 0) {
160
+ return { content: [{ type: 'text', text: 'hskill updated to the latest version.' }] }
161
+ }
162
+ const message = stderr.trim() || stdout.trim() || `update failed with exit code ${code}`
163
+ return { content: [{ type: 'text', text: message }], isError: true }
164
+ })
165
+
166
+ return server
167
+ }
168
+
169
+ export async function startServer() {
170
+ const server = createServer()
171
+ const transport = new StdioServerTransport()
172
+ await server.connect(transport)
173
+ }
package/lib/targets.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import os from 'os'
2
2
  import path from 'path'
3
3
 
4
- export const SKILL_TARGETS = ['claude', 'cursor', 'codex', 'openclaw', 'hermes', 'opencode']
4
+ export const SKILL_TARGETS = ['claude', 'cursor', 'codex', 'openclaw', 'hermes', 'opencode', 'pi']
5
5
 
6
6
  export const USER_ONLY_TARGETS = new Set(['openclaw', 'hermes'])
7
7
 
8
8
  // Targets whose user-level skill dir doesn't follow ~/.{name}/skills
9
9
  const USER_DIR_OVERRIDES = {
10
10
  opencode: path.join(os.homedir(), '.config', 'opencode', 'skills'),
11
+ pi: path.join(os.homedir(), '.pi', 'agent', 'skills'),
11
12
  }
12
13
 
13
14
  export function userSkillDir(name) {
@@ -27,6 +28,7 @@ export const TARGETS = {
27
28
  openclaw: path.join(os.homedir(), '.openclaw', 'skills'),
28
29
  hermes: path.join(os.homedir(), '.hermes', 'skills'),
29
30
  opencode: path.join(os.homedir(), '.config', 'opencode', 'skills'),
31
+ pi: path.join(os.homedir(), '.pi', 'agent', 'skills'),
30
32
  shell: path.join(os.homedir(), '.local', 'bin'),
31
33
  }
32
34
 
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "harveyz-skill",
3
- "version": "0.25.0",
4
- "description": "Skill manager for Claude Code, Cursor, and Codex",
3
+ "version": "0.26.0",
4
+ "description": "Skill manager for Claude Code, Cursor, Codex, OpenClaw, Hermes, OpenCode, and Pi",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "hskill": "bin/cli.js"
8
8
  },
9
9
  "scripts": {
10
10
  "prepack": "node scripts/generate-npmignore.js",
11
- "test": "bats tests/ && bash scripts/run-skill-tests.sh"
11
+ "test": "bats tests/ && bash scripts/run-skill-tests.sh && node --test tests/mcp.test.mjs"
12
12
  },
13
13
  "files": [
14
14
  "bin/",
@@ -25,6 +25,7 @@
25
25
  "skills/research/extract-cognition/",
26
26
  "skills/research/probe-session/",
27
27
  "skills/research/pdf-math-translate/",
28
+ "skills/research/clip-url/",
28
29
  "skills/creative/capture-todo/",
29
30
  "skills/creative/capture-insight/",
30
31
  "skills/coding/init-workflow/",
@@ -53,6 +54,7 @@
53
54
  "skills/meta/release-project/",
54
55
  "skills/meta/sync-hotfix/",
55
56
  "skills/meta/sync-agent/",
57
+ "skills/coding/handoff/",
56
58
  "tools/hub/",
57
59
  "tools/sync-agent/",
58
60
  "hooks/check-similar-branch/"
@@ -62,8 +64,10 @@
62
64
  },
63
65
  "dependencies": {
64
66
  "@inquirer/prompts": "^8.4.2",
67
+ "@modelcontextprotocol/sdk": "^1.30.0",
65
68
  "chalk": "^5.3.0",
66
- "fs-extra": "^11.2.0"
69
+ "fs-extra": "^11.2.0",
70
+ "zod": "^4.4.3"
67
71
  },
68
72
  "author": "harveyzhang",
69
73
  "repository": {