harveyz-skill 0.33.0 → 0.35.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 (77) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +31 -2
  3. package/bin/cli.js +219 -47
  4. package/lib/bundles.js +12 -4
  5. package/lib/install-source.js +46 -0
  6. package/lib/mcp-server.js +2 -2
  7. package/lib/version-check.js +2 -2
  8. package/package.json +14 -3
  9. package/skills/agent-canvas/agent-canvas-control/SKILL.md +236 -0
  10. package/skills/agent-canvas/capture-requirement/SKILL.md +52 -0
  11. package/skills/agent-canvas/close-node/SKILL.md +118 -0
  12. package/skills/agent-canvas/describe-node/SKILL.md +52 -0
  13. package/skills/agent-canvas/relate-node/SKILL.md +64 -0
  14. package/skills/agent-canvas/relation-review/SKILL.md +120 -0
  15. package/skills/coding/analyze-claudemd/SKILL.md +114 -0
  16. package/skills/coding/analyze-claudemd/references/criteria.md +142 -0
  17. package/skills/coding/capture-vocab/SKILL.md +20 -74
  18. package/skills/coding/capture-vocab/references/manage.md +115 -0
  19. package/skills/coding/capture-vocab/references/project-setup.md +31 -0
  20. package/skills/coding/capture-vocab/scripts/__pycache__/vocab.cpython-314.pyc +0 -0
  21. package/skills/coding/capture-vocab/scripts/vocab.py +207 -0
  22. package/skills/coding/capture-vocab/tests/__pycache__/test_vocab.cpython-314-pytest-9.1.1.pyc +0 -0
  23. package/skills/coding/capture-vocab/tests/fixtures/.hskill/capture-vocab/vocab.md +245 -0
  24. package/skills/coding/capture-vocab/tests/test_vocab.py +159 -0
  25. package/skills/coding/init-project/SKILL.md +157 -0
  26. package/skills/coding/init-project/assets/skeleton/CLAUDE.md +17 -0
  27. package/skills/coding/init-project/assets/skeleton/README.md +12 -0
  28. package/skills/coding/init-project/assets/skeleton/TODO.md +9 -0
  29. package/skills/coding/init-project/assets/skeleton/gitignore-code +19 -0
  30. package/skills/coding/init-project/assets/templates/code.yml +37 -0
  31. package/skills/coding/init-project/references/check-report.md +55 -0
  32. package/skills/coding/init-project/references/template-schema.md +63 -0
  33. package/skills/feed/manage-creators/SKILL.md +10 -2
  34. package/skills/feed/sync-website/scripts/__pycache__/store_config.cpython-314.pyc +0 -0
  35. package/skills/feed/sync-website/tests/__pycache__/test_store_config.cpython-314-pytest-9.1.1.pyc +0 -0
  36. package/skills/feed/sync-xtimeline/scripts/__pycache__/store_config.cpython-314.pyc +0 -0
  37. package/skills/feed/sync-xtimeline/tests/__pycache__/test_store_config.cpython-314-pytest-9.1.1.pyc +0 -0
  38. package/skills/feed/sync-ytchannel/scripts/__pycache__/store_config.cpython-314.pyc +0 -0
  39. package/skills/feed/sync-ytchannel/tests/__pycache__/test_store_config.cpython-314-pytest-9.1.1.pyc +0 -0
  40. package/skills/research/clip-url/scripts/__pycache__/store_config.cpython-314.pyc +0 -0
  41. package/skills/research/clip-url/tests/__pycache__/test_store_config.cpython-314-pytest-9.1.1.pyc +0 -0
  42. package/skills/research/learn-video/SKILL.md +14 -7
  43. package/skills/research/learn-video/scripts/__pycache__/archive.cpython-314.pyc +0 -0
  44. package/skills/research/learn-video/scripts/__pycache__/build_creator_index.cpython-314.pyc +0 -0
  45. package/skills/research/learn-video/scripts/__pycache__/store_config.cpython-314.pyc +0 -0
  46. package/skills/research/learn-video/scripts/archive.py +21 -57
  47. package/skills/research/learn-video/scripts/build_creator_index.py +136 -0
  48. package/skills/research/learn-video/tests/__pycache__/test_archive.cpython-314-pytest-9.1.1.pyc +0 -0
  49. package/skills/research/learn-video/tests/__pycache__/test_build_creator_index.cpython-314-pytest-9.1.1.pyc +0 -0
  50. package/skills/research/learn-video/tests/__pycache__/test_store_config.cpython-314-pytest-9.1.1.pyc +0 -0
  51. package/skills/research/learn-video/tests/test_archive.py +61 -108
  52. package/skills/research/learn-video/tests/test_build_creator_index.py +192 -0
  53. package/skills-index.json +57 -8
  54. package/tools/roster/roster/__init__.py +1 -1
  55. package/tools/roster/roster/__main__.py +33 -3
  56. package/tools/roster/roster/__pycache__/__init__.cpython-314.pyc +0 -0
  57. package/tools/roster/roster/__pycache__/__main__.cpython-314.pyc +0 -0
  58. package/tools/roster/roster/__pycache__/migrate_schema.cpython-314.pyc +0 -0
  59. package/tools/roster/roster/__pycache__/registry.cpython-314.pyc +0 -0
  60. package/tools/roster/roster/__pycache__/urls.cpython-314.pyc +0 -0
  61. package/tools/roster/roster/migrate_schema.py +52 -0
  62. package/tools/roster/roster/registry.py +7 -3
  63. package/tools/roster/roster/urls.py +5 -1
  64. package/tools/roster/tests/__pycache__/test_cli.cpython-314-pytest-9.1.1.pyc +0 -0
  65. package/tools/roster/tests/__pycache__/test_config.cpython-314-pytest-9.1.1.pyc +0 -0
  66. package/tools/roster/tests/__pycache__/test_migrate_schema.cpython-314-pytest-9.1.1.pyc +0 -0
  67. package/tools/roster/tests/__pycache__/test_registry.cpython-314-pytest-9.1.1.pyc +0 -0
  68. package/tools/roster/tests/__pycache__/test_registry_merge.cpython-314-pytest-9.1.1.pyc +0 -0
  69. package/tools/roster/tests/__pycache__/test_state.cpython-314-pytest-9.1.1.pyc +0 -0
  70. package/tools/roster/tests/__pycache__/test_urls.cpython-314-pytest-9.1.1.pyc +0 -0
  71. package/tools/roster/tests/test_cli.py +28 -1
  72. package/tools/roster/tests/test_config.py +2 -2
  73. package/tools/roster/tests/test_migrate_schema.py +157 -0
  74. package/tools/roster/tests/test_registry.py +33 -2
  75. package/tools/roster/tests/test_registry_merge.py +11 -0
  76. package/tools/roster/tests/test_state.py +26 -0
  77. package/tools/roster/tests/test_urls.py +15 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.35.0] - 2026-09-21
11
+
12
+ ### Added
13
+ - `learn-video`:新增 `build_creator_index.py`(`build`/`check` 两个子命令)。扫描 vdl 写出的全部 `meta.json`,按归一化后的 YouTube handle(去 `@`、转小写)分组,产出 `<knowledgeRoot>/videos/creators.json`。不读、不写 `registry.json`——"是不是在关注这个人"是判断,判断不落盘在事实旁边,完全交给下游(scholia)现查决定,索引本身永远不含 `watched`/`creator_id`。原子写(临时文件 + rename),构建失败旧索引原样保留;单个 `meta.json` 损坏时归入 `unresolved` 而不是从统计里静默消失;`work` 目录不存在(vdl 的 `WORK_ROOT` 漂了)时报错退出,不会把一个正常的索引悄悄替换成空索引
14
+
15
+ ### Changed
16
+ - **破坏性变更** `learn-video`:`archive.py` 从"写 `meta.json`"降级为"校验 `meta.json`"。vdl(Video-Learner 仓库)现在自己把 `uploader_id`/`channel_id`/`uploader_url` 三个新字段与统一存储契约的三个必填字段(`source_url`/`title`/`fetched_at`)全部写好,`archive.py` 只剩读一遍 `meta.json` 核对必填字段是否齐全,不再从 vdl 的 sqlite 捞取任何展示字段,也不再写文件。CLI 入参从 `TASK_ID`/`SOURCE_URL`/`TITLE`/`FETCHED_AT` 四个环境变量精简到只剩 `TASK_ID`。version 1.8.2 → 1.9.0
17
+ - **升级路径**:这是 vdl 那一侧先落地的改动的下游收尾。若本机 vdl 版本落后(写出的 `meta.json` 还没有新增字段/契约字段),归档步骤会直接报错停下并列出缺的字段名,不会再像过去那样悄悄补一份缺字段的 `meta.json`。看到这个报错,先确认 Video-Learner 是否需要升级
18
+
19
+ ## [0.34.0] - 2026-09-16
20
+
21
+ ### Added
22
+ - `hskill` 双来源安装与粘性更新:`hskill update --local <路径>` 从本地仓库 `npm pack` 出 tarball 再全局安装(真实拷贝,走 `prepack` 与 `files[]` 白名单,等于在发 npm 前预演一次真实发布),`hskill update --npm` 切回 registry,裸 `hskill update` 沿用当前来源不跨轨道。此前 `update` 是一行硬编码的 `npm install -g harveyz-skill@latest`,手工装的本地版会被它静默替换掉且事后无从察觉。来源痕迹(`.hskill-source.json` + 版本号后缀 `+local`)只写在全局安装目录内,因而任何一次 `npm install -g`——包括绕过 hskill 的手工安装——都会自动抹掉它,"记录的来源"与"实际的来源"不可能分叉。`hskill version` 展示来源与 branch/commit,`version --check` 在本地来源下比对 commit 而非版本号(开发分支上版本号常常不动)。设计见 `docs/superpowers/specs/2026-09-15-hskill-install-source-design.md`
23
+ - `agent-canvas` bundle:新增 `agent-canvas-control`、`capture-requirement`、`close-node`、`describe-node`、`relate-node`、`relation-review` 六个 skill,在 Agent Canvas 画布节点里操控节点实体、摆放画布、梳理节点间语义关系
24
+
25
+ ### Fixed
26
+ - `hskill <任意子命令> --json` 在输出走管道时被截断:Node 写管道是异步的,而每个子命令块以 `process.exit()` 结尾,不等缓冲区排空。`status --json` 的输出刚随 agent-canvas 六个 skill 涨到 66,739 字节、越过 64KiB 管道缓冲区,于是 `hskill status --json | jq` 拿到退出码 0 和一段从字符串中间断掉的 JSON——**静默失败**,退出码还宣称成功。16 处 `--json` 出口统一改走等待写入落地的 `emitJson()`;回归测试逐字节比对管道输出与直接重定向输出,且在负载跌回缓冲区以下时显式 skip 而非假绿
27
+ - `hskill version` / `hskill --version` 在 npm 不在 PATH 上时抛原始堆栈崩溃(cron、受限 PATH、精简镜像):来源追踪让 `readSource()` 每次都经 `globalRoot()` 打一次 `npm root -g`,而它只兜住了 JSON 解析失败。现在 `globalRoot()` 的失败并入同一个 `null`,即回落到缺省的 npm 来源
28
+ - `compareVersions()` 遇到 semver build metadata(`0.33.0+local`)会解析出 `NaN` 导致比较结果无意义,现按规范在比较前截断 `+` 及其后内容
29
+
10
30
  ## [0.33.0] - 2026-09-15
11
31
 
12
32
  ### Added
package/README.md CHANGED
@@ -96,14 +96,43 @@ hskill install --bundle dev # 安装整个 bundle
96
96
  hskill install --skill git-workflow-init # 安装单个 skill
97
97
  hskill install --tool hub # 安装 shell 工具
98
98
  hskill list # 查看可用 skill
99
- hskill update # 更新到最新版
99
+ hskill update # 更新 hskill 自身(沿用当前来源)
100
+ hskill version # 查看版本与安装来源
100
101
  hskill --help # 查看帮助
101
102
  ```
102
103
 
103
104
  Skills 安装到 `~/.claude/skills/`,shell 工具安装到 `~/.local/bin/`。
104
105
 
106
+ **从本地仓库安装 hskill 自身**
107
+
108
+ 发版前想先用一阵子工作树里的改动,可以把 hskill 装成本地来源:
109
+
110
+ ```bash
111
+ hskill update --local ~/Projects/harveyz-skill # 从本地仓库打包安装
112
+ hskill update # 之后裸跑就一直走本地,不会跳回 npm
113
+ hskill update --npm # 显式切回 npm registry
114
+ ```
115
+
116
+ `--local` 走的是 `npm pack` + 安装 tarball,**不是软链**——产出独立快照,并且真实执行 `prepack` 与 `package.json` 的 `files[]` 白名单,等于在发 npm 之前预演一次真实发布,能提前暴露打包问题。
117
+
118
+ 两种来源共存于同一个命令,`update` 是**粘性**的:本地装的就从原仓库重新打包,npm 装的就走 registry,**裸 `update` 永远不跨来源**。跨来源只能由 `--local` / `--npm` 显式触发,触发时会打印完整迁移行。
119
+
120
+ 安装来源记在全局安装目录里(`.hskill-source.json` + 版本号后缀 `+local`)。因为任何一次 `npm install -g` 都会清空重建该目录,绕过 hskill 手工安装也会自动抹掉痕迹,所以记录的来源不会与实际情况脱节:
121
+
122
+ ```console
123
+ $ hskill version
124
+ 0.34.0+local
125
+
126
+ source: local /Users/you/Projects/harveyz-skill
127
+ branch: staging commit: 3d4192a
128
+ ```
129
+
130
+ 本地来源下 `hskill version --check` 比对的是**记录的 commit 与仓库 HEAD**,不是版本号——开发分支上版本号常常几十个提交不动,能动的只有 commit。
131
+
105
132
  **本地源码开发**
106
133
 
134
+ 不安装、直接从仓库跑 CLI 来装 skill:
135
+
107
136
  ```bash
108
137
  node bin/cli.js install --skill mermaid-diagram --target claude
109
138
  node bin/cli.js install --bundle dev --target claude
@@ -149,7 +178,7 @@ hskill hooks uninstall check-similar-branch --scope project # 从当前项目
149
178
  | **hskill_install** | 安装一个 bundle、指定 skill 或 shell 工具 |
150
179
  | **hskill_uninstall** | 卸载指定的 skill 或 shell 工具 |
151
180
  | **hskill_hooks** | 查看、安装或卸载 hskill git hooks |
152
- | **hskill_update** | hskill 自身更新到最新版本 |
181
+ | **hskill_update** | 更新 hskill 自身,沿用当前安装来源(npm 或本地仓库);切换来源需用 CLI 的 `--npm` / `--local` |
153
182
 
154
183
  在 MCP host 的配置中注册(假设已通过 `npm install -g harveyz-skill` 全局安装):
155
184
 
package/bin/cli.js CHANGED
@@ -2,14 +2,14 @@
2
2
  import { select, input, confirm } from '@inquirer/prompts'
3
3
  import chalk from 'chalk'
4
4
  import { execSync, spawnSync } from 'child_process'
5
- import { existsSync, writeFileSync, unlinkSync } from 'fs'
5
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdtempSync } from 'fs'
6
6
  import { createRequire } from 'module'
7
7
  import os from 'os'
8
8
  import path from 'path'
9
9
  import { fileURLToPath } from 'url'
10
10
  import {
11
11
  getAllSkillItems, getAllToolItems, getAllHookItems, checkHookInstalled,
12
- checkInstalled, checkToolInstalled, scopeSummary,
12
+ checkInstalled, checkToolInstalled, scopeSummary, toSkillItems,
13
13
  resolveSkills, resolveSkillsByName, resolveTools, resolveToolsByName,
14
14
  TOOL_BUNDLE_CHOICES,
15
15
  } from '../lib/bundles.js'
@@ -24,6 +24,16 @@ const args = process.argv.slice(2)
24
24
  const subcommand = args[0]
25
25
  const jsonFlag = args.includes('--json')
26
26
 
27
+ // Writing to a pipe is asynchronous in Node, and process.exit() throws away
28
+ // whatever is still buffered. Anything past the 64KiB pipe buffer was being cut
29
+ // mid-string, so `hskill status --json | jq` got exit code 0 and unparseable
30
+ // JSON. Every --json emitter goes through here, which resolves only once the
31
+ // write has actually landed.
32
+ async function emitJson(value) {
33
+ const text = JSON.stringify(value, null, 2) + '\n'
34
+ await new Promise(resolve => process.stdout.write(text, resolve))
35
+ }
36
+
27
37
  // ── Help ─────────────────────────────────────────────────────────────────────
28
38
  function printHelp() {
29
39
  console.log(`
@@ -50,10 +60,12 @@ function printHelp() {
50
60
  hskill uninstall <tool> uninstall a shell tool and clean up all files
51
61
  hskill uninstall <tool> --yes skip all confirmations (incl. config files)
52
62
  hskill uninstall <skill> --scope <s> --target <t> uninstall a skill
53
- hskill update update hskill to the latest version
63
+ hskill update update hskill (sticky: refreshes from whichever source is installed)
64
+ hskill update --local <path> switch to / refresh a local repo (npm pack + install)
65
+ hskill update --npm switch back to npm registry
54
66
  hskill mcp start an MCP server (stdio) exposing hskill's tools to MCP-capable agent hosts
55
- hskill version show version
56
- hskill version --check compare local version against npm registry (no install)
67
+ hskill version show version (adds source/branch/commit lines when installed from a local repo)
68
+ hskill version --check compare against npm registry, or against the local source repo's HEAD
57
69
  hskill --help show this help
58
70
 
59
71
  ${chalk.cyan('Examples:')}
@@ -74,7 +86,7 @@ function printHelp() {
74
86
 
75
87
  if (args[0] === '--help' || args[0] === '-h') {
76
88
  if (jsonFlag || args.includes('--json')) {
77
- console.log(JSON.stringify({
89
+ await emitJson({
78
90
  name: 'hskill',
79
91
  version,
80
92
  description: 'Skill manager for Claude Code, Cursor, Codex, OpenClaw, Hermes, OpenCode, and Pi',
@@ -138,7 +150,11 @@ if (args[0] === '--help' || args[0] === '-h') {
138
150
  },
139
151
  {
140
152
  name: 'update',
141
- description: 'Update hskill to the latest version via npm',
153
+ description: 'Update hskill; sticky to whichever source (npm or local repo) is currently installed',
154
+ flags: [
155
+ { name: '--local', arg: '<path>', description: 'Switch to / refresh a local repo source (npm pack + install -g)' },
156
+ { name: '--npm', description: 'Switch back to the npm registry source' },
157
+ ],
142
158
  },
143
159
  {
144
160
  name: 'mcp',
@@ -149,7 +165,7 @@ if (args[0] === '--help' || args[0] === '-h') {
149
165
  description: 'Print version and exit',
150
166
  },
151
167
  ],
152
- }, null, 2))
168
+ })
153
169
  process.exit(0)
154
170
  }
155
171
  printHelp()
@@ -157,12 +173,37 @@ if (args[0] === '--help' || args[0] === '-h') {
157
173
  }
158
174
 
159
175
  if (args[0] === '--version' || args[0] === '-v' || subcommand === 'version') {
176
+ const { readSource, gitInfo } = await import('../lib/install-source.js')
177
+ const source = readSource()
178
+
160
179
  if (subcommand === 'version' && args.includes('--check')) {
180
+ if (source) {
181
+ let current
182
+ try {
183
+ current = gitInfo(source.repo)
184
+ } catch (err) {
185
+ console.error(chalk.red(` ✗ Could not read local source repo: ${err.message}`))
186
+ process.exit(1)
187
+ }
188
+ const upToDate = current.commit === source.commit && !current.dirty
189
+ if (jsonFlag) {
190
+ await emitJson({ source: 'local', repo: source.repo, installedCommit: source.commit, currentCommit: current.commit, dirty: current.dirty, upToDate })
191
+ } else if (upToDate) {
192
+ console.log(chalk.green(` ✔ hskill is up to date with local source (${source.repo}@${current.commit})`))
193
+ } else if (current.commit !== source.commit) {
194
+ console.log(chalk.yellow(` ⚠ local source has new commits: ${source.commit} → ${current.commit}`))
195
+ console.log(chalk.dim(' Run: hskill update'))
196
+ } else {
197
+ console.log(chalk.yellow(' ⚠ local source has uncommitted changes not yet packed'))
198
+ console.log(chalk.dim(' Run: hskill update'))
199
+ }
200
+ process.exit(0)
201
+ }
161
202
  try {
162
203
  const { checkNpmVersion } = await import('../lib/version-check.js')
163
204
  const { current, latest, upToDate } = await checkNpmVersion('harveyz-skill', version)
164
205
  if (jsonFlag) {
165
- console.log(JSON.stringify({ current, latest, upToDate }, null, 2))
206
+ await emitJson({ current, latest, upToDate })
166
207
  } else if (upToDate) {
167
208
  console.log(chalk.green(` ✔ hskill v${current} is up to date`))
168
209
  } else {
@@ -176,6 +217,11 @@ if (args[0] === '--version' || args[0] === '-v' || subcommand === 'version') {
176
217
  process.exit(0)
177
218
  }
178
219
  console.log(version)
220
+ if (source) {
221
+ console.log('')
222
+ console.log(`source: local ${source.repo}`)
223
+ console.log(`branch: ${source.branch} commit: ${source.commit}${source.dirty ? ' (dirty)' : ''}`)
224
+ }
179
225
  process.exit(0)
180
226
  }
181
227
 
@@ -223,22 +269,138 @@ async function checkArchivedInstalls() {
223
269
  }
224
270
 
225
271
  // ── Update ───────────────────────────────────────────────────────────────────
226
- if (subcommand === 'update') {
272
+ // Sticky: whichever source is currently installed (recorded in .hskill-source.json
273
+ // inside the global install dir) is what a bare `update` refreshes from. `--local`
274
+ // and `--npm` explicitly switch the source. See docs/superpowers/specs/2026-09-15-hskill-install-source-design.md
275
+ async function updateToNpm(priorSource) {
227
276
  console.log(chalk.dim(' · Updating hskill…'))
228
277
  try {
229
278
  execSync('npm install -g harveyz-skill@latest', { stdio: 'inherit' })
230
- console.log(chalk.green(' ✔ hskill updated'))
231
279
  } catch {
232
280
  console.error(chalk.red(' ✗ Update failed. Try: npm install -g harveyz-skill@latest'))
233
281
  process.exit(1)
234
282
  }
283
+ if (priorSource) {
284
+ const { globalRoot } = await import('../lib/install-source.js')
285
+ const newVersion = JSON.parse(readFileSync(path.join(globalRoot(), 'harveyz-skill', 'package.json'), 'utf8')).version
286
+ console.log(` ${priorSource.version}+local (${priorSource.branch}@${priorSource.commit}) → ${newVersion} (npm)`)
287
+ }
288
+ console.log(chalk.green(' ✔ hskill updated'))
289
+ }
290
+
291
+ async function updateToLocal(repoPath, priorSource) {
292
+ if (!existsSync(repoPath)) {
293
+ console.error(chalk.red(` ✗ 本地来源仓库不存在:${repoPath}`))
294
+ console.error(chalk.dim(' 改用 npm: hskill update --npm'))
295
+ console.error(chalk.dim(` 指向新路径: hskill update --local <新路径>`))
296
+ process.exit(1)
297
+ }
298
+
299
+ const { gitInfo, writeSource, globalRoot } = await import('../lib/install-source.js')
300
+ // git info must be collected before `npm pack` — its `prepack` hook rewrites
301
+ // the tracked .npmignore, which would otherwise poison the dirty check.
302
+ const info = gitInfo(repoPath)
303
+ const origVersion = JSON.parse(readFileSync(path.join(repoPath, 'package.json'), 'utf8')).version
304
+
305
+ console.log(chalk.dim(' · Packing local repo…'))
306
+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'hskill-pack-'))
307
+ const packResult = spawnSync('npm', ['pack', '--pack-destination', tmpDir], { cwd: repoPath, encoding: 'utf8' })
308
+ if (packResult.status !== 0) {
309
+ console.error(chalk.red(' ✗ npm pack failed'))
310
+ process.exit(1)
311
+ }
312
+ const tarballName = packResult.stdout.trim().split('\n').pop()
313
+
314
+ console.log(chalk.dim(' · Installing packed tarball…'))
315
+ const installResult = spawnSync('npm', ['install', '-g', path.join(tmpDir, tarballName)], { stdio: 'inherit' })
316
+ if (installResult.status !== 0) {
317
+ console.error(chalk.red(' ✗ Install failed'))
318
+ process.exit(1)
319
+ }
320
+
321
+ const installedPkgPath = path.join(globalRoot(), 'harveyz-skill', 'package.json')
322
+ const installedPkg = JSON.parse(readFileSync(installedPkgPath, 'utf8'))
323
+ installedPkg.version = `${origVersion}+local`
324
+ writeFileSync(installedPkgPath, JSON.stringify(installedPkg, null, 2) + '\n')
325
+
326
+ writeSource({
327
+ repo: repoPath,
328
+ branch: info.branch,
329
+ commit: info.commit,
330
+ dirty: info.dirty,
331
+ version: origVersion,
332
+ installedAt: new Date().toISOString(),
333
+ })
334
+
335
+ if (priorSource === null) {
336
+ console.log(` ${version} (npm) → ${origVersion}+local (${info.branch}@${info.commit})`)
337
+ } else if (priorSource.repo !== repoPath) {
338
+ console.log(` ${priorSource.version}+local (${priorSource.branch}@${priorSource.commit}) → ${origVersion}+local (${info.branch}@${info.commit})`)
339
+ }
340
+
341
+ const npmignoreStatus = execSync('git status --porcelain -- .npmignore', { cwd: repoPath, encoding: 'utf8' }).trim()
342
+ if (npmignoreStatus) {
343
+ console.log(chalk.dim(' · .npmignore changed by prepack — left as-is, review with `git diff .npmignore`'))
344
+ }
345
+
346
+ console.log(chalk.green(' ✔ hskill updated'))
347
+ }
348
+
349
+ if (subcommand === 'update') {
350
+ const updateArgs = args.slice(1)
351
+ const localIdx = updateArgs.indexOf('--local')
352
+ const npmFlag = updateArgs.includes('--npm')
353
+ const localGiven = localIdx !== -1
354
+ const localPath = localGiven ? updateArgs[localIdx + 1] : undefined
355
+
356
+ if (localGiven && npmFlag) {
357
+ console.error(chalk.red(' ✗ --local and --npm are mutually exclusive'))
358
+ process.exit(1)
359
+ }
360
+ if (localGiven && (!localPath || localPath.startsWith('--'))) {
361
+ console.error(chalk.red(' ✗ --local requires a path: hskill update --local <path>'))
362
+ process.exit(1)
363
+ }
364
+
365
+ const { readSource } = await import('../lib/install-source.js')
366
+ const priorSource = readSource()
367
+
368
+ if (npmFlag) {
369
+ await updateToNpm(priorSource)
370
+ } else if (localGiven) {
371
+ await updateToLocal(localPath, priorSource)
372
+ } else if (priorSource) {
373
+ await updateToLocal(priorSource.repo, priorSource)
374
+ } else {
375
+ await updateToNpm(priorSource)
376
+ }
377
+
378
+ // Re-require fresh: the self-update above just replaced this install
379
+ // directory's skills-index.json on disk, but bin/cli.js's own top-level
380
+ // import of lib/bundles.js already cached the *old* skillDefs at process
381
+ // start. A plain require() here is the first hit for this path in this
382
+ // process, so it reads what's actually on disk now.
383
+ const { renames = [], skills: freshSkillDefs = [] } = require('../skills-index.json')
384
+ const skillsRoot = path.join(__dirname, '..', 'skills')
385
+
386
+ // Auto-upgrade skills opted into `autoUpdate: true` (skills-index.json),
387
+ // scoped to `user` to match `hskill upgrade`'s default scope.
388
+ const autoUpdateRows = buildSkillRows(null, toSkillItems(freshSkillDefs, skillsRoot))
389
+ .filter(r => r.autoUpdate)
390
+ if (autoUpdateRows.length > 0) {
391
+ const autoTargets = resolveTargets(['all'], 'user')
392
+ const autoSummary = await upgradeOutdatedSkills(autoUpdateRows, autoTargets, 'userDetail')
393
+ const upgraded = [...new Set(Object.values(autoSummary).flatMap(s => s.installed ?? []))]
394
+ const failed = [...new Set(Object.values(autoSummary).flatMap(s => (s.failed ?? []).map(f => f.name)))]
395
+ if (upgraded.length > 0) console.log(chalk.green(` ✔ Auto-updated ${upgraded.length} skill(s): ${upgraded.join(', ')}`))
396
+ if (failed.length > 0) console.log(chalk.yellow(` ⚠ Auto-update failed for: ${failed.join(', ')}`))
397
+ }
398
+
235
399
  // Run skill rename migrations
236
- const { renames = [], skills: skillDefs = [] } = require('../skills-index.json')
237
400
  if (renames.length > 0) {
238
401
  console.log(chalk.dim(' · Migrating renamed skills…'))
239
402
  const targets = SKILL_TARGETS.map(name => ({ name, dir: userSkillDir(name) }))
240
- const skillsRoot = path.join(__dirname, '..', 'skills')
241
- const migrationSummary = await migrateRenamedSkills(renames, targets, skillsRoot, skillDefs)
403
+ const migrationSummary = await migrateRenamedSkills(renames, targets, skillsRoot, freshSkillDefs)
242
404
  const totalMigrated = Object.values(migrationSummary).reduce((n, s) => n + s.migrated.length, 0)
243
405
  const totalFailed = Object.values(migrationSummary).reduce((n, s) => n + s.failed.length, 0)
244
406
  if (totalMigrated > 0) console.log(chalk.green(` ✔ Migrated ${totalMigrated} skill(s)`))
@@ -276,10 +438,10 @@ if (subcommand === 'list') {
276
438
  const { skills, tools = [] } = require('../skills-index.json')
277
439
  const sorted = [...skills].sort((a, b) => a.bundle.localeCompare(b.bundle) || a.path.split('/').pop().localeCompare(b.path.split('/').pop()))
278
440
  if (jsonFlag) {
279
- console.log(JSON.stringify({
441
+ await emitJson({
280
442
  skills: sorted.map(s => ({ name: s.path.split('/').pop(), path: s.path, bundle: s.bundle, global: s.global ?? false })),
281
443
  tools: tools.map(t => t.name),
282
- }, null, 2))
444
+ })
283
445
  process.exit(0)
284
446
  }
285
447
  const nw = Math.max(...sorted.map(s => s.path.split('/').pop().length), 4)
@@ -310,10 +472,11 @@ function resolveHookDisplayVersion(inst, sourceVersion) {
310
472
  }
311
473
 
312
474
  // ── Shared skill scan ─────────────────────────────────────────────────────────
313
- function buildSkillRows(nameFilter = null) {
475
+ function buildSkillRows(nameFilter = null, sourceItems = null) {
476
+ const allItems = sourceItems ?? getAllSkillItems()
314
477
  const items = nameFilter
315
- ? getAllSkillItems().filter(s => s.skillName === nameFilter)
316
- : getAllSkillItems()
478
+ ? allItems.filter(s => s.skillName === nameFilter)
479
+ : allItems
317
480
  return items.map(s => {
318
481
  const inst = checkInstalled(s.skillName, s.version ?? '—')
319
482
  return {
@@ -321,6 +484,7 @@ function buildSkillRows(nameFilter = null) {
321
484
  bundle: s.bundle ?? '—',
322
485
  version: s.version ?? '—',
323
486
  installScope: s.installScope ?? null,
487
+ autoUpdate: s.autoUpdate ?? false,
324
488
  srcPath: s.srcPath,
325
489
  userStatus: scopeSummary(inst.user),
326
490
  projectStatus: scopeSummary(inst.project),
@@ -330,6 +494,26 @@ function buildSkillRows(nameFilter = null) {
330
494
  })
331
495
  }
332
496
 
497
+ // Shared by `upgrade` and the post-`update` auto-upgrade step: for each
498
+ // target, install whichever rows are outdated (status === 'update') on that
499
+ // target's scope. Rows not carrying the given scope's detail are left alone.
500
+ async function upgradeOutdatedSkills(rows, targetList, scopeKey) {
501
+ const summary = {}
502
+ for (const { name: targetName, dir } of targetList) {
503
+ const upgradeList = rows
504
+ .filter(r => r[scopeKey]?.[targetName]?.status === 'update')
505
+ .map(r => ({ skillName: r.name, srcPath: r.srcPath, version: r.version }))
506
+
507
+ if (!upgradeList.length) continue
508
+
509
+ console.log('')
510
+ const result = await installSkills(upgradeList, [{ name: targetName, dir }], true)
511
+ Object.assign(summary, result)
512
+ console.log('')
513
+ }
514
+ return summary
515
+ }
516
+
333
517
  // ── Status / Outdated ─────────────────────────────────────────────────────────
334
518
  if (subcommand === 'status' || subcommand === 'outdated') {
335
519
  const outdatedOnly = subcommand === 'outdated'
@@ -380,12 +564,12 @@ if (subcommand === 'status' || subcommand === 'outdated') {
380
564
  return { name: h.name, description: h.description, user: inst.user, project: inst.project }
381
565
  })
382
566
  if (outdatedOnly) {
383
- console.log(JSON.stringify({
567
+ await emitJson({
384
568
  skills: jsonSkills.filter(s => Object.values(s.user).some(v => v.status === 'update') || Object.values(s.project).some(v => v.status === 'update')),
385
569
  tools: jsonTools.filter(t => t.status === 'update'),
386
- }, null, 2))
570
+ })
387
571
  } else {
388
- console.log(JSON.stringify({ skills: jsonSkills, tools: jsonTools, hooks: jsonHooks }, null, 2))
572
+ await emitJson({ skills: jsonSkills, tools: jsonTools, hooks: jsonHooks })
389
573
  }
390
574
  process.exit(0)
391
575
  }
@@ -513,17 +697,17 @@ if (subcommand === 'info') {
513
697
  if (jsonFlag) {
514
698
  if (skill) {
515
699
  const inst = checkInstalled(skill.skillName, skill.version ?? '—')
516
- console.log(JSON.stringify({
700
+ await emitJson({
517
701
  name: skill.skillName, type: 'skill', version: skill.version ?? '—',
518
702
  user: Object.fromEntries(targets.map(t => [t, inst.user[t]])),
519
703
  project: Object.fromEntries(targets.map(t => [t, inst.project[t]])),
520
- }, null, 2))
704
+ })
521
705
  } else {
522
706
  const inst = checkToolInstalled(tool.toolName, tool.srcPath)
523
- console.log(JSON.stringify({
707
+ await emitJson({
524
708
  name: tool.toolName, type: 'tool', version: tool.version ?? '—',
525
709
  installed: inst,
526
- }, null, 2))
710
+ })
527
711
  }
528
712
  process.exit(0)
529
713
  }
@@ -590,7 +774,7 @@ if (subcommand === 'uninstall') {
590
774
  const { removed, failed } = await uninstallTool(nameToRemove, { yes: yesFlag })
591
775
  if (jsonFlag) {
592
776
  console.error = originalError
593
- console.log(JSON.stringify({ removed: removed.length > 0, failed: failed.length > 0 }, null, 2))
777
+ await emitJson({ removed: removed.length > 0, failed: failed.length > 0 })
594
778
  } else if (removed.length > 0) {
595
779
  console.error(chalk.green.bold(`✔ ${nameToRemove} uninstalled`))
596
780
  }
@@ -617,7 +801,7 @@ if (subcommand === 'uninstall') {
617
801
  }
618
802
  if (jsonFlag) {
619
803
  console.error = originalError2
620
- console.log(JSON.stringify({ removed: anyRemoved, failed: anyFailed }, null, 2))
804
+ await emitJson({ removed: anyRemoved, failed: anyFailed })
621
805
  } else if (anyRemoved) {
622
806
  console.error(chalk.green.bold(`✔ ${nameToRemove} uninstalled`))
623
807
  }
@@ -663,7 +847,7 @@ if (subcommand === 'hooks') {
663
847
  codex: inst.codex,
664
848
  }
665
849
  })
666
- console.log(JSON.stringify({ hooks: out }, null, 2))
850
+ await emitJson({ hooks: out })
667
851
  process.exit(0)
668
852
  }
669
853
  function hookIcon(s) {
@@ -724,7 +908,7 @@ if (subcommand === 'hooks') {
724
908
  const { installed, skipped, failed } = await installHooksForTarget(toInstall, hookTargetArg, hookScopeArg, hookProjectArg, hookForce)
725
909
 
726
910
  if (hookJsonFlag) {
727
- console.log(JSON.stringify({ installed, skipped, failed }, null, 2))
911
+ await emitJson({ installed, skipped, failed })
728
912
  process.exit(failed.length ? 1 : 0)
729
913
  } else {
730
914
  if (installed.length) console.error(chalk.green.bold(`✔ Hooks installed (${hookScopeArg}):`), installed.join(', '))
@@ -746,7 +930,7 @@ if (subcommand === 'hooks') {
746
930
  }
747
931
  const { removed } = await uninstallHook(nameToRemove, hookScopeArg, hookProjectArg)
748
932
  if (hookJsonFlag) {
749
- console.log(JSON.stringify({ removed }, null, 2))
933
+ await emitJson({ removed })
750
934
  } else if (!removed) {
751
935
  console.log(chalk.dim(` · ${nameToRemove} was not installed in ${hookScopeArg} scope`))
752
936
  }
@@ -781,26 +965,14 @@ if (subcommand === 'upgrade') {
781
965
  const targetList = resolveTargets(upgradeTargetArg ? [upgradeTargetArg] : ['all'], upgradeScopeArg)
782
966
  const scopeKey = upgradeScopeArg + 'Detail' // 'userDetail' or 'projectDetail'
783
967
 
784
- const summary = {}
785
- for (const { name: targetName, dir } of targetList) {
786
- const upgradeList = rows
787
- .filter(r => r[scopeKey]?.[targetName]?.status === 'update')
788
- .map(r => ({ skillName: r.name, srcPath: r.srcPath, version: r.version }))
789
-
790
- if (!upgradeList.length) continue
791
-
792
- console.log('')
793
- const result = await installSkills(upgradeList, [{ name: targetName, dir }], true)
794
- Object.assign(summary, result)
795
- console.log('')
796
- }
968
+ const summary = await upgradeOutdatedSkills(rows, targetList, scopeKey)
797
969
 
798
970
  const nothingUpgraded = Object.keys(summary).length === 0
799
971
  if (jsonFlag) {
800
972
  if (nothingUpgraded) {
801
- console.log(JSON.stringify({ skills: {}, upToDate: true }, null, 2))
973
+ await emitJson({ skills: {}, upToDate: true })
802
974
  } else {
803
- console.log(JSON.stringify({ skills: summary }, null, 2))
975
+ await emitJson({ skills: summary })
804
976
  }
805
977
  } else {
806
978
  if (nothingUpgraded) {
@@ -1512,7 +1684,7 @@ try {
1512
1684
  const out = {}
1513
1685
  if (skillSummary !== null) out.skills = skillSummary
1514
1686
  if (toolSummary !== null) out.tools = toolSummary
1515
- console.log(JSON.stringify(out, null, 2))
1687
+ await emitJson(out)
1516
1688
  } else {
1517
1689
  printSummary(skillSummary, toolSummary)
1518
1690
  }
package/lib/bundles.js CHANGED
@@ -101,21 +101,29 @@ export function buildAllChoices() {
101
101
  return choices
102
102
  }
103
103
 
104
- // 所有 skill 展开为 { kind:'skill', skillName, bundle, srcPath } 列表(对应 all 选项)
105
- export function getAllSkillItems() {
106
- return skillDefs.map(skill => {
107
- const srcPath = path.join(skillsRoot, skill.path)
104
+ // 把一组原始 skill 定义(来自 skills-index.json skills[])展开为运行时条目。
105
+ // 抽出来是因为 `hskill update` 在自更新落盘后,需要绕开本模块顶部那次一次性
106
+ // require() 缓存的旧 skillDefs,直接用刚写到磁盘的新 skills-index.json 重新展开。
107
+ export function toSkillItems(defs, root) {
108
+ return defs.map(skill => {
109
+ const srcPath = path.join(root, skill.path)
108
110
  return {
109
111
  kind: 'skill',
110
112
  skillName: skill.path.split('/').pop(),
111
113
  bundle: skill.bundle,
112
114
  installScope: skill.installScope ?? null,
115
+ autoUpdate: skill.autoUpdate ?? false,
113
116
  srcPath,
114
117
  version: readVersion(srcPath),
115
118
  }
116
119
  })
117
120
  }
118
121
 
122
+ // 所有 skill 展开为 { kind:'skill', skillName, bundle, srcPath } 列表(对应 all 选项)
123
+ export function getAllSkillItems() {
124
+ return toSkillItems(skillDefs, skillsRoot)
125
+ }
126
+
119
127
  // 所有 tool 展开为 { kind:'tool', toolName, srcPath, version } 列表
120
128
  export function getAllToolItems() {
121
129
  return toolDefs.map(tool => {
@@ -0,0 +1,46 @@
1
+ import { execSync } from 'child_process'
2
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
3
+ import path from 'path'
4
+
5
+ let cachedGlobalRoot = null
6
+
7
+ export function globalRoot() {
8
+ if (process.env.HSKILL_GLOBAL_ROOT) return process.env.HSKILL_GLOBAL_ROOT
9
+ // stdio 'pipe' keeps npm's own stderr off ours: callers that tolerate failure
10
+ // (readSource) would otherwise leak "npm: command not found" into clean output.
11
+ if (!cachedGlobalRoot) cachedGlobalRoot = execSync('npm root -g', { encoding: 'utf8', stdio: 'pipe' }).trim()
12
+ return cachedGlobalRoot
13
+ }
14
+
15
+ function sourceFilePath() {
16
+ return path.join(globalRoot(), 'harveyz-skill', '.hskill-source.json')
17
+ }
18
+
19
+ // null means "npm source" — the default. An unreadable trace file and an
20
+ // unreachable npm both collapse into it: `version` must keep working where npm
21
+ // is off PATH, and a source we cannot read is not a source we can update from.
22
+ export function readSource() {
23
+ let file
24
+ try {
25
+ file = sourceFilePath()
26
+ } catch {
27
+ return null
28
+ }
29
+ if (!existsSync(file)) return null
30
+ try {
31
+ return JSON.parse(readFileSync(file, 'utf8'))
32
+ } catch {
33
+ return null
34
+ }
35
+ }
36
+
37
+ export function writeSource(info) {
38
+ writeFileSync(sourceFilePath(), JSON.stringify(info, null, 2) + '\n')
39
+ }
40
+
41
+ export function gitInfo(repo) {
42
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: repo, encoding: 'utf8' }).trim()
43
+ const commit = execSync('git rev-parse --short HEAD', { cwd: repo, encoding: 'utf8' }).trim()
44
+ const dirty = execSync('git status --porcelain', { cwd: repo, encoding: 'utf8' }).trim().length > 0
45
+ return { branch, commit, dirty }
46
+ }
package/lib/mcp-server.js CHANGED
@@ -152,12 +152,12 @@ export function createServer() {
152
152
  })
153
153
 
154
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.',
155
+ description: 'Update hskill itself from whichever source the current install came from: the npm registry (the default) or, if it was installed from a local repo, that same repo re-packed. Never switches between the two — use the CLI (`hskill update --npm` / `--local <path>`) to change source. This replaces the globally installed hskill binary and is irreversible except by manually reinstalling a specific version.',
156
156
  inputSchema: {},
157
157
  }, async () => {
158
158
  const { code, stdout, stderr } = await runCli(['update'])
159
159
  if (code === 0) {
160
- return { content: [{ type: 'text', text: 'hskill updated to the latest version.' }] }
160
+ return { content: [{ type: 'text', text: stdout.trim() || 'hskill updated.' }] }
161
161
  }
162
162
  const message = stderr.trim() || stdout.trim() || `update failed with exit code ${code}`
163
163
  return { content: [{ type: 'text', text: message }], isError: true }
@@ -2,8 +2,8 @@ const DEFAULT_REGISTRY = 'https://registry.npmjs.org'
2
2
  const REGISTRY_TIMEOUT_MS = 5000
3
3
 
4
4
  export function compareVersions(a, b) {
5
- const pa = a.split('.').map(Number)
6
- const pb = b.split('.').map(Number)
5
+ const pa = a.split('+')[0].split('.').map(Number)
6
+ const pb = b.split('+')[0].split('.').map(Number)
7
7
  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
8
8
  const diff = (pa[i] || 0) - (pb[i] || 0)
9
9
  if (diff !== 0) return diff