harveyz-skill 0.6.2 → 0.7.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.
@@ -0,0 +1,75 @@
1
+ #!/bin/bash
2
+ # check-similar-branch.sh
3
+ # version: 1.0.0
4
+ # PreToolUse command hook: 用 LLM 语义分析检测相似分支
5
+ # 触发条件: git checkout -b 或 git switch -c
6
+
7
+ set -euo pipefail
8
+
9
+ INPUT=$(cat)
10
+ CMD=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
11
+
12
+ # 只拦截分支创建命令
13
+ if ! echo "$CMD" | grep -qE 'git (checkout -b|switch -c)'; then
14
+ exit 0
15
+ fi
16
+
17
+ # 提取新分支名
18
+ NEW_BRANCH=$(echo "$CMD" | grep -oE '(checkout -b|switch -c)\s+\S+' | awk '{print $NF}' | tr -d "'\"")
19
+ [ -z "$NEW_BRANCH" ] && exit 0
20
+
21
+ # 确保在 git 仓库中
22
+ GIT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
23
+
24
+ # 获取现有分支列表(排除新分支本身和 HEAD)
25
+ BRANCHES=$(git -C "$GIT_ROOT" branch 2>/dev/null \
26
+ | sed 's|[ *]*||' \
27
+ | grep -v "^${NEW_BRANCH}$" \
28
+ | grep -v 'HEAD' \
29
+ || true)
30
+
31
+ [ -z "$BRANCHES" ] && exit 0
32
+
33
+ # 用 claude -p 做语义相似度分析
34
+ BRANCH_LIST=$(echo "$BRANCHES" | head -40) # 最多传 40 条,避免 token 过多
35
+
36
+ LLM_RESULT=$(claude -p "You are a git branch similarity checker. Determine if the new branch semantically overlaps with any existing branches.
37
+
38
+ New branch: $NEW_BRANCH
39
+
40
+ Existing branches:
41
+ $BRANCH_LIST
42
+
43
+ Rules:
44
+ - SIMILAR: same type prefix (feature/, fix/, etc.) AND same feature/module/topic (even if worded differently)
45
+ - NOT similar: different type prefix, or completely unrelated topics
46
+ - Ignore generic words: add, update, fix, improve, cleanup
47
+
48
+ Respond with ONLY valid JSON, no explanation:
49
+ - If similar branches found: {\"similar\": true, \"matches\": [\"branch-name-1\", \"branch-name-2\"]}
50
+ - If none found: {\"similar\": false}" 2>/dev/null)
51
+
52
+ # 解析 LLM 结果
53
+ IS_SIMILAR=$(echo "$LLM_RESULT" | jq -r '.similar // false' 2>/dev/null || echo "false")
54
+
55
+ if [ "$IS_SIMILAR" = "true" ]; then
56
+ # 格式化相似分支列表
57
+ MATCH_LIST=$(echo "$LLM_RESULT" | jq -r '.matches[]' 2>/dev/null | sed 's/^/ • /' | tr '\n' '|' | sed 's/|$//' | tr '|' '\n')
58
+
59
+ MSG="⚠️ BRANCH_GUARD: 发现语义相似的已有分支,建议复用而非新建。
60
+
61
+ 新分支: $NEW_BRANCH
62
+ 相似分支:
63
+ $MATCH_LIST
64
+
65
+ 请询问用户:是否复用已有分支?
66
+ 若选择复用,将同步 staging 最新代码:
67
+ git checkout <branch>
68
+ git fetch origin
69
+ git merge origin/staging"
70
+
71
+ jq -n --arg msg "$MSG" \
72
+ '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","additionalContext":$msg}}'
73
+ fi
74
+
75
+ exit 0
package/lib/bundles.js CHANGED
@@ -3,11 +3,12 @@ import os from 'os'
3
3
  import { createRequire } from 'module'
4
4
  import path from 'path'
5
5
  import { fileURLToPath } from 'url'
6
+ import { USER_ONLY_TARGETS } from './targets.js'
6
7
 
7
8
  const require = createRequire(import.meta.url)
8
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
10
 
10
- const { bundleMeta, skills: skillDefs, toolBundleMeta = {}, tools: toolDefs = [] } = require('../skills-index.json')
11
+ const { bundleMeta, skills: skillDefs, toolBundleMeta = {}, tools: toolDefs = [], hooks: hookDefs = [] } = require('../skills-index.json')
11
12
  const repoRoot = path.join(__dirname, '..')
12
13
  const skillsRoot = path.join(repoRoot, 'skills')
13
14
 
@@ -49,7 +50,8 @@ export function resolveSkillsByName(names) {
49
50
  return names.map(name => {
50
51
  const skill = skillDefs.find(s => s.path.split('/').pop() === name)
51
52
  if (!skill) throw new Error(`Unknown skill: "${name}"`)
52
- return { skillName: name, srcPath: path.join(skillsRoot, skill.path) }
53
+ const srcPath = path.join(skillsRoot, skill.path)
54
+ return { skillName: name, srcPath, version: readVersion(srcPath) }
53
55
  })
54
56
  }
55
57
 
@@ -65,7 +67,8 @@ export function resolveSkills(selectedBundles) {
65
67
  if (seen.has(skill.path)) continue
66
68
  seen.add(skill.path)
67
69
  const skillName = skill.path.split('/').pop()
68
- result.push({ skillName, srcPath: path.join(skillsRoot, skill.path) })
70
+ const srcPath = path.join(skillsRoot, skill.path)
71
+ result.push({ skillName, srcPath, version: readVersion(srcPath) })
69
72
  }
70
73
  }
71
74
  return result
@@ -129,12 +132,14 @@ export function getAllToolItems() {
129
132
  // 每个工具: { version: string, status: 'up-to-date'|'update'|'none' }
130
133
  export function checkInstalled(skillName, availableVersion) {
131
134
  const home = os.homedir()
132
- const targets = ['claude', 'cursor', 'codex']
135
+ const targets = ['claude', 'cursor', 'codex', 'openclaw', 'hermes']
133
136
 
134
137
  function checkScope(dirFn) {
135
138
  const result = {}
136
139
  for (const t of targets) {
137
- const skillPath = path.join(dirFn(t), skillName)
140
+ const dir = dirFn(t)
141
+ if (dir === null) { result[t] = { version: '—', status: 'none' }; continue }
142
+ const skillPath = path.join(dir, skillName)
138
143
  const version = readVersion(skillPath)
139
144
  const status = version === '—' ? 'none'
140
145
  : version === availableVersion ? 'up-to-date'
@@ -149,7 +154,9 @@ export function checkInstalled(skillName, availableVersion) {
149
154
 
150
155
  return {
151
156
  user: checkScope(t => path.join(home, `.${t}`, 'skills')),
152
- project: cwd === home ? noneScope : checkScope(t => path.join(cwd, `.${t}`, 'skills')),
157
+ project: cwd === home ? noneScope : checkScope(t =>
158
+ USER_ONLY_TARGETS.has(t) ? null : path.join(cwd, `.${t}`, 'skills')
159
+ ),
153
160
  }
154
161
  }
155
162
 
@@ -161,7 +168,67 @@ export function scopeSummary(scopeDetail) {
161
168
  return 'none'
162
169
  }
163
170
 
164
- export { formatChoice, readVersion }
171
+ // ── Hooks ────────────────────────────────────────────────────────────────────
172
+
173
+ function readHookVersion(scriptPath) {
174
+ try {
175
+ const content = fs.readFileSync(scriptPath, 'utf-8')
176
+ const m = content.match(/^#\s*version:\s*(.+)$/m)
177
+ return m ? m[1].trim() : '—'
178
+ } catch { return '—' }
179
+ }
180
+
181
+ export function getAllHookItems() {
182
+ return hookDefs.map(hook => {
183
+ const srcPath = path.join(repoRoot, hook.path, `${hook.name}.sh`)
184
+ return {
185
+ ...hook,
186
+ srcPath,
187
+ version: readHookVersion(srcPath),
188
+ }
189
+ })
190
+ }
191
+
192
+ export function checkHookInstalled(hookName) {
193
+ const home = os.homedir()
194
+ const cwd = process.cwd()
195
+
196
+ function checkScope(hooksDir, settingsPath) {
197
+ const scriptPath = path.join(hooksDir, `${hookName}.sh`)
198
+ const scriptExists = fs.existsSync(scriptPath)
199
+ const installedVersion = scriptExists ? readHookVersion(scriptPath) : '—'
200
+ let registered = false
201
+ try {
202
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'))
203
+ registered = Object.values(settings.hooks ?? {}).some(entries =>
204
+ Array.isArray(entries) && entries.some(e =>
205
+ Array.isArray(e.hooks) && e.hooks.some(h =>
206
+ typeof h.command === 'string' && h.command.includes(`${hookName}.sh`)
207
+ )
208
+ )
209
+ )
210
+ } catch { /* settings.json 不存在或解析失败 */ }
211
+
212
+ const status = scriptExists && registered ? 'installed'
213
+ : scriptExists || registered ? 'partial'
214
+ : 'none'
215
+ return { status, version: installedVersion }
216
+ }
217
+
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')
222
+
223
+ return {
224
+ user: checkScope(userHooksDir, userSettings),
225
+ project: cwd === home
226
+ ? { status: 'none', version: '—' }
227
+ : checkScope(projectHooksDir, projectSettings),
228
+ }
229
+ }
230
+
231
+ export { formatChoice, readVersion, readHookVersion }
165
232
 
166
233
  const HSKILL_TOOLS_DATA = path.join(os.homedir(), '.local', 'share', 'hskill', 'tools')
167
234
 
package/lib/installer.js CHANGED
@@ -4,6 +4,7 @@ import path from 'path'
4
4
  import { confirm } from '@inquirer/prompts'
5
5
  import chalk from 'chalk'
6
6
  import { loadVarDefs, buildAutoVars, resolveVars, substituteVars } from './vars.js'
7
+ import { readVersion, readHookVersion } from './bundles.js'
7
8
 
8
9
  const BINARY_EXTS = new Set(['.db', '.pyc', '.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf'])
9
10
  const SKIP_NAMES = new Set(['vars.json', '__pycache__', '.DS_Store'])
@@ -32,25 +33,35 @@ async function copyDir(srcDir, destDir, varsMap) {
32
33
  export async function installTools(tools, targetDir, force = false) {
33
34
  const exists = await fs.pathExists(targetDir)
34
35
  if (!exists) {
35
- console.log(chalk.dim(` · Creating directory: ${targetDir}`))
36
+ console.error(chalk.dim(` · Creating directory: ${targetDir}`))
36
37
  await fs.ensureDir(targetDir)
37
38
  }
38
39
 
39
40
  const installed = []
41
+ const skipped = []
42
+ const failed = []
43
+
40
44
  for (const { toolName, srcPath } of tools) {
41
45
  const scriptSrc = path.join(srcPath, `${toolName}.sh`)
42
46
  const destPath = path.join(targetDir, toolName)
43
47
 
44
48
  if (!await fs.pathExists(scriptSrc)) {
45
- console.log(chalk.red(` ✗ Script not found: ${scriptSrc}`))
49
+ console.error(chalk.red(` ✗ Script not found: ${scriptSrc}`))
50
+ failed.push({ name: toolName, reason: 'source_not_found' })
46
51
  continue
47
52
  }
48
53
 
49
54
  const destExists = await fs.pathExists(destPath)
50
55
  if (destExists && !force) {
56
+ if (!process.stdout.isTTY) {
57
+ skipped.push({ name: toolName, reason: 'already_exists' })
58
+ console.error(chalk.dim(` · Skipped ${toolName} (already exists — use --force to overwrite)`))
59
+ continue
60
+ }
51
61
  const ok = await confirm({ message: `${destPath} already exists. Overwrite?`, default: true })
52
62
  if (!ok) {
53
- console.log(chalk.dim(` · Skipped ${toolName}`))
63
+ skipped.push({ name: toolName, reason: 'already_exists' })
64
+ console.error(chalk.dim(` · Skipped ${toolName}`))
54
65
  continue
55
66
  }
56
67
  }
@@ -59,11 +70,20 @@ export async function installTools(tools, targetDir, force = false) {
59
70
  const varDefs = await loadVarDefs(srcPath)
60
71
  let varsMap = {}
61
72
  if (varDefs.length > 0) {
62
- console.log(chalk.bold(`\n Configure ${toolName}:`))
63
- varsMap = await resolveVars(varDefs, buildAutoVars())
64
- for (const def of varDefs) {
65
- if (def.configFile && def.configKey) {
66
- await _writeToolConfigVar(def, varsMap[def.name])
73
+ if (!process.stdout.isTTY) {
74
+ const autoVars = buildAutoVars()
75
+ for (const def of varDefs) {
76
+ autoVars[def.name] = substituteVars(def.default ?? '', autoVars)
77
+ }
78
+ varsMap = autoVars
79
+ console.error(chalk.dim(` · ${toolName}: using default vars (non-TTY)`))
80
+ } else {
81
+ console.error(chalk.bold(`\n Configure ${toolName}:`))
82
+ varsMap = await resolveVars(varDefs, buildAutoVars())
83
+ for (const def of varDefs) {
84
+ if (def.configFile && def.configKey) {
85
+ await _writeToolConfigVar(def, varsMap[def.name])
86
+ }
67
87
  }
68
88
  }
69
89
  }
@@ -80,18 +100,19 @@ export async function installTools(tools, targetDir, force = false) {
80
100
  installed.push(toolName)
81
101
  await _patchZshrc(srcPath, toolName)
82
102
  } catch (err) {
83
- console.log(chalk.red(` ✗ Failed to install ${toolName}: ${err.message}`))
103
+ console.error(chalk.red(` ✗ Failed to install ${toolName}: ${err.message}`))
104
+ failed.push({ name: toolName, reason: 'copy_failed', detail: err.message })
84
105
  }
85
106
  }
86
107
 
87
- return installed
108
+ return { installed, skipped, failed }
88
109
  }
89
110
 
90
111
  async function _writeToolConfigVar(def, value) {
91
112
  const configPath = def.configFile.replace(/^~/, os.homedir())
92
113
  await fs.ensureDir(path.dirname(configPath))
93
114
  await fs.writeFile(configPath, `${def.configKey}=("${value}")\n`, 'utf-8')
94
- console.log(chalk.green(` ✓ Config written to ${def.configFile}`))
115
+ console.error(chalk.green(` ✓ Config written to ${def.configFile}`))
95
116
  }
96
117
 
97
118
  async function _patchZshrc(srcPath, toolName) {
@@ -106,7 +127,12 @@ async function _patchZshrc(srcPath, toolName) {
106
127
  : ''
107
128
 
108
129
  if (existing.includes(marker)) {
109
- console.log(chalk.dim(` · ~/.zshrc already has ${toolName} config, skipping`))
130
+ console.error(chalk.dim(` · ~/.zshrc already has ${toolName} config, skipping`))
131
+ return
132
+ }
133
+
134
+ if (!process.stdout.isTTY) {
135
+ console.error(chalk.dim(` · Skipped ~/.zshrc patch for ${toolName} (non-TTY — add manually)`))
110
136
  return
111
137
  }
112
138
 
@@ -118,7 +144,7 @@ async function _patchZshrc(srcPath, toolName) {
118
144
 
119
145
  const snippet = await fs.readFile(snippetPath, 'utf-8')
120
146
  await fs.appendFile(zshrcPath, snippet, 'utf-8')
121
- console.log(chalk.green(` ✓ Written to ~/.zshrc`))
147
+ console.error(chalk.green(` ✓ Written to ~/.zshrc`))
122
148
  }
123
149
 
124
150
  // skills: [{ skillName, srcPath }]
@@ -130,46 +156,246 @@ export async function installSkills(skills, targets, force = false) {
130
156
  for (const { name: targetName, dir: targetDir } of targets) {
131
157
  const exists = await fs.pathExists(targetDir)
132
158
  if (!exists) {
133
- console.log(chalk.dim(` · Skipping ${targetName} (directory not found: ${targetDir})`))
134
- continue
159
+ console.error(chalk.dim(` · Creating directory: ${targetDir}`))
160
+ await fs.ensureDir(targetDir)
135
161
  }
136
162
 
137
163
  const installed = []
138
- for (const { skillName, srcPath } of skills) {
139
- const destPath = path.join(targetDir, skillName)
164
+ const skipped = []
165
+ const failed = []
166
+
167
+ for (const { skillName, srcPath, version: availableVersion } of skills) {
168
+ if (!await fs.pathExists(srcPath)) {
169
+ console.error(chalk.red(` ✗ Source not found: ${srcPath}`))
170
+ failed.push({ name: skillName, reason: 'source_not_found' })
171
+ continue
172
+ }
173
+
174
+ const destPath = path.join(targetDir, skillName)
140
175
  const destExists = await fs.pathExists(destPath)
141
176
 
142
177
  if (destExists && !force) {
143
- const ok = await confirm({ message: `${targetName}/${skillName} already exists. Overwrite?`, default: false })
144
- if (!ok) {
145
- console.log(chalk.dim(` · Skipped ${targetName}/${skillName}`))
178
+ const installedVersion = readVersion(destPath)
179
+ if (availableVersion && installedVersion === availableVersion) {
180
+ skipped.push({ name: skillName, reason: 'up-to-date', version: installedVersion })
181
+ console.error(chalk.dim(` · Skipped ${skillName} (up-to-date ${installedVersion})`))
146
182
  continue
147
183
  }
148
- }
149
184
 
150
- if (!await fs.pathExists(srcPath)) {
151
- console.log(chalk.red(` ✗ Source not found: ${srcPath}`))
152
- continue
185
+ if (!process.stdout.isTTY) {
186
+ skipped.push({
187
+ name: skillName, reason: 'outdated',
188
+ installed: installedVersion, available: availableVersion ?? '—',
189
+ })
190
+ console.error(chalk.dim(` · Skipped ${skillName} (outdated ${installedVersion} → ${availableVersion ?? '—'}, use --force to overwrite)`))
191
+ continue
192
+ }
193
+
194
+ const ok = await confirm({
195
+ message: `${targetName}/${skillName} ${installedVersion} → ${availableVersion ?? '?'}. Overwrite?`,
196
+ default: false,
197
+ })
198
+ if (!ok) {
199
+ skipped.push({
200
+ name: skillName, reason: 'outdated',
201
+ installed: installedVersion, available: availableVersion ?? '—',
202
+ })
203
+ console.error(chalk.dim(` · Skipped ${skillName}`))
204
+ continue
205
+ }
153
206
  }
154
207
 
155
208
  try {
156
209
  const varDefs = await loadVarDefs(srcPath)
157
210
  let varsMap = {}
158
211
  if (varDefs.length > 0) {
159
- console.log(chalk.bold(`\n Configure ${skillName}:`))
160
- varsMap = await resolveVars(varDefs, buildAutoVars())
212
+ if (!process.stdout.isTTY) {
213
+ const autoVars = buildAutoVars()
214
+ for (const def of varDefs) {
215
+ autoVars[def.name] = substituteVars(def.default ?? '', autoVars)
216
+ }
217
+ varsMap = autoVars
218
+ console.error(chalk.dim(` · ${skillName}: using default vars (non-TTY)`))
219
+ } else {
220
+ console.error(chalk.bold(`\n Configure ${skillName}:`))
221
+ varsMap = await resolveVars(varDefs, buildAutoVars())
222
+ }
161
223
  }
162
224
  await copyDir(srcPath, destPath, varsMap)
163
225
  installed.push(skillName)
164
226
  } catch (err) {
165
- console.log(chalk.red(` ✗ Failed to copy ${targetName}/${skillName}: ${err.message}`))
227
+ console.error(chalk.red(` ✗ Failed to copy ${targetName}/${skillName}: ${err.message}`))
228
+ failed.push({ name: skillName, reason: 'copy_failed', detail: err.message })
166
229
  }
167
230
  }
168
231
 
169
- if (installed.length > 0) {
170
- summary[targetName] = installed
171
- }
232
+ summary[targetName] = { installed, skipped, failed }
172
233
  }
173
234
 
174
235
  return summary
175
236
  }
237
+
238
+ // ── Hooks ────────────────────────────────────────────────────────────────────
239
+
240
+ function _hooksDir(scope, projectDir) {
241
+ return scope === 'user'
242
+ ? path.join(os.homedir(), '.claude', 'hooks')
243
+ : path.join(projectDir, '.claude', 'hooks')
244
+ }
245
+
246
+ function _settingsPath(scope, projectDir) {
247
+ return scope === 'user'
248
+ ? path.join(os.homedir(), '.claude', 'settings.json')
249
+ : path.join(projectDir, '.claude', 'settings.json')
250
+ }
251
+
252
+ function _hookCommand(hookName, scope) {
253
+ return scope === 'user'
254
+ ? `bash ~/.claude/hooks/${hookName}.sh`
255
+ : `bash "$(git rev-parse --show-toplevel 2>/dev/null || echo .)/.claude/hooks/${hookName}.sh"`
256
+ }
257
+
258
+ async function _patchSettings(settingsPath, hook, scope, force) {
259
+ let settings = {}
260
+ try {
261
+ settings = JSON.parse(await fs.readFile(settingsPath, 'utf-8'))
262
+ } catch { /* 文件不存在时从空对象开始 */ }
263
+
264
+ if (!settings.hooks) settings.hooks = {}
265
+ if (!settings.hooks[hook.event]) settings.hooks[hook.event] = []
266
+
267
+ const command = _hookCommand(hook.name, scope)
268
+
269
+ // 检查是否已有相同 command
270
+ const alreadyRegistered = settings.hooks[hook.event].some(entry =>
271
+ Array.isArray(entry.hooks) && entry.hooks.some(h => h.command === command)
272
+ )
273
+
274
+ if (alreadyRegistered && !force) return false
275
+ if (alreadyRegistered && force) {
276
+ settings.hooks[hook.event] = settings.hooks[hook.event].filter(entry =>
277
+ !Array.isArray(entry.hooks) || !entry.hooks.some(h => h.command === command)
278
+ )
279
+ }
280
+
281
+ const hookEntry = { type: 'command', command }
282
+ if (hook.timeout) hookEntry.timeout = hook.timeout
283
+ if (hook.statusMessage) hookEntry.statusMessage = hook.statusMessage
284
+
285
+ settings.hooks[hook.event].push({
286
+ matcher: hook.matcher ?? '',
287
+ hooks: [hookEntry],
288
+ })
289
+
290
+ await fs.ensureDir(path.dirname(settingsPath))
291
+ await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8')
292
+ return true
293
+ }
294
+
295
+ export async function installHooks(hooks, scope, projectDir, force = false) {
296
+ const hooksDir = _hooksDir(scope, projectDir)
297
+ const settingsPath = _settingsPath(scope, projectDir)
298
+
299
+ await fs.ensureDir(hooksDir)
300
+
301
+ const installed = []
302
+ const skipped = []
303
+ const failed = []
304
+
305
+ for (const hook of hooks) {
306
+ const destScript = path.join(hooksDir, `${hook.name}.sh`)
307
+ const scriptExists = await fs.pathExists(destScript)
308
+
309
+ if (scriptExists && !force) {
310
+ const installedVersion = readHookVersion(destScript)
311
+ const availableVersion = hook.version
312
+
313
+ if (availableVersion && installedVersion === availableVersion) {
314
+ skipped.push({ name: hook.name, reason: 'up-to-date', version: installedVersion })
315
+ console.error(chalk.dim(` · Skipped ${hook.name} (up-to-date ${installedVersion})`))
316
+ continue
317
+ }
318
+
319
+ if (!process.stdout.isTTY) {
320
+ skipped.push({
321
+ name: hook.name, reason: 'outdated',
322
+ installed: installedVersion, available: availableVersion ?? '—',
323
+ })
324
+ console.error(chalk.dim(` · Skipped ${hook.name} (outdated ${installedVersion} → ${availableVersion}, use --force to overwrite)`))
325
+ continue
326
+ }
327
+
328
+ const ok = await confirm({
329
+ message: `${hook.name} ${installedVersion} → ${availableVersion}. Overwrite?`,
330
+ default: false,
331
+ })
332
+ if (!ok) {
333
+ skipped.push({
334
+ name: hook.name, reason: 'outdated',
335
+ installed: installedVersion, available: availableVersion ?? '—',
336
+ })
337
+ console.error(chalk.dim(` · Skipped ${hook.name}`))
338
+ continue
339
+ }
340
+ }
341
+
342
+ try {
343
+ if (!await fs.pathExists(hook.srcPath)) {
344
+ failed.push({ name: hook.name, reason: 'source_not_found' })
345
+ console.error(chalk.red(` ✗ Source not found: ${hook.srcPath}`))
346
+ continue
347
+ }
348
+
349
+ await fs.copy(hook.srcPath, destScript, { overwrite: true })
350
+ await fs.chmod(destScript, 0o755)
351
+ await _patchSettings(settingsPath, hook, scope, force)
352
+
353
+ installed.push(hook.name)
354
+ console.error(chalk.green(` ✓ ${hook.name} → ${destScript}`))
355
+ } catch (err) {
356
+ failed.push({ name: hook.name, reason: 'copy_failed', detail: err.message })
357
+ console.error(chalk.red(` ✗ Failed to install ${hook.name}: ${err.message}`))
358
+ }
359
+ }
360
+
361
+ return { installed, skipped, failed }
362
+ }
363
+
364
+ export async function uninstallHook(hookName, scope, projectDir) {
365
+ const hooksDir = _hooksDir(scope, projectDir)
366
+ const settingsPath = _settingsPath(scope, projectDir)
367
+ const destScript = path.join(hooksDir, `${hookName}.sh`)
368
+ let removed = false
369
+
370
+ if (await fs.pathExists(destScript)) {
371
+ await fs.remove(destScript)
372
+ removed = true
373
+ console.error(chalk.green(` ✓ Removed ${destScript}`))
374
+ }
375
+
376
+ try {
377
+ const settings = JSON.parse(await fs.readFile(settingsPath, 'utf-8'))
378
+ let changed = false
379
+ for (const event of Object.keys(settings.hooks ?? {})) {
380
+ const before = settings.hooks[event].length
381
+ settings.hooks[event] = settings.hooks[event].filter(entry =>
382
+ !Array.isArray(entry.hooks) ||
383
+ !entry.hooks.some(h => typeof h.command === 'string' && h.command.includes(`${hookName}.sh`))
384
+ )
385
+ if (settings.hooks[event].length !== before) {
386
+ changed = true
387
+ removed = true
388
+ if (settings.hooks[event].length === 0) delete settings.hooks[event]
389
+ }
390
+ }
391
+ if (changed && Object.keys(settings.hooks).length === 0) {
392
+ delete settings.hooks
393
+ }
394
+ if (changed) {
395
+ await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8')
396
+ console.error(chalk.green(` ✓ Unregistered from ${settingsPath}`))
397
+ }
398
+ } catch { /* settings.json 不存在,忽略 */ }
399
+
400
+ return { removed }
401
+ }
package/lib/targets.js CHANGED
@@ -1,33 +1,42 @@
1
1
  import os from 'os'
2
2
  import path from 'path'
3
3
 
4
- const SKILL_TARGETS = ['claude', 'cursor', 'codex']
4
+ const SKILL_TARGETS = ['claude', 'cursor', 'codex', 'openclaw', 'hermes']
5
+
6
+ export const USER_ONLY_TARGETS = new Set(['openclaw', 'hermes'])
5
7
 
6
8
  function skillDir(name, scope) {
7
- if (scope === 'project') return path.join(process.cwd(), `.${name}`, 'skills')
8
- return path.join(os.homedir(), `.${name}`, 'skills')
9
+ if (USER_ONLY_TARGETS.has(name) || scope !== 'project')
10
+ return path.join(os.homedir(), `.${name}`, 'skills')
11
+ return path.join(process.cwd(), `.${name}`, 'skills')
9
12
  }
10
13
 
11
14
  export const TARGETS = {
12
- claude: path.join(os.homedir(), '.claude', 'skills'),
13
- cursor: path.join(os.homedir(), '.cursor', 'skills'),
14
- codex: path.join(os.homedir(), '.codex', 'skills'),
15
- shell: path.join(os.homedir(), '.local', 'bin'),
15
+ claude: path.join(os.homedir(), '.claude', 'skills'),
16
+ cursor: path.join(os.homedir(), '.cursor', 'skills'),
17
+ codex: path.join(os.homedir(), '.codex', 'skills'),
18
+ openclaw: path.join(os.homedir(), '.openclaw', 'skills'),
19
+ hermes: path.join(os.homedir(), '.hermes', 'skills'),
20
+ shell: path.join(os.homedir(), '.local', 'bin'),
16
21
  }
17
22
 
18
23
  export function buildTargetChoices(scope = 'user') {
19
- return SKILL_TARGETS.map(name => {
20
- const dir = skillDir(name, scope)
21
- return { name: `${name.padEnd(8)} (${dir})`, value: name }
22
- })
24
+ return SKILL_TARGETS
25
+ .filter(name => scope === 'user' || !USER_ONLY_TARGETS.has(name))
26
+ .map(name => {
27
+ const dir = skillDir(name, scope)
28
+ return { name: `${name.padEnd(8)} (${dir})`, value: name }
29
+ })
23
30
  }
24
31
 
25
32
  export const TARGET_CHOICES = buildTargetChoices('user')
26
33
 
27
34
  // 返回选中 targets 的 { name, dir } 列表,all 展开为全部
28
35
  export function resolveTargets(selected, scope = 'user') {
29
- const allTargets = SKILL_TARGETS.map(name => ({ name, dir: skillDir(name, scope) }))
30
- if (selected.includes('all')) return allTargets
36
+ const eligible = scope === 'user'
37
+ ? SKILL_TARGETS
38
+ : SKILL_TARGETS.filter(name => !USER_ONLY_TARGETS.has(name))
39
+ if (selected.includes('all')) return eligible.map(name => ({ name, dir: skillDir(name, scope) }))
31
40
  return selected.map(name => {
32
41
  if (!SKILL_TARGETS.includes(name)) throw new Error(`Unknown target: "${name}"`)
33
42
  return { name, dir: skillDir(name, scope) }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harveyz-skill",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Skill manager for Claude Code, Cursor, and Codex",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,16 +8,15 @@
8
8
  },
9
9
  "scripts": {
10
10
  "prepack": "node scripts/generate-npmignore.js",
11
- "test": "bats tests/ tools/p-launch/tests/"
11
+ "test": "bats tests/ tools/p-launch/tests/ && bash scripts/run-skill-tests.sh"
12
12
  },
13
13
  "files": [
14
14
  "bin/",
15
15
  "lib/",
16
16
  "bundles.json",
17
17
  "skills-index.json",
18
- "skills/analysis/skill-analyzer/CHANGELOG.md",
19
- "skills/analysis/skill-analyzer/SKILL.md",
20
- "skills/analysis/skill-analyzer/references/",
18
+ "CHANGELOG.md",
19
+ "skills/analysis/skill-analyzer/",
21
20
  "skills/harness/diataxis-docs/",
22
21
  "skills/harness/full-stack-debug-env/",
23
22
  "skills/superpowers-fork/brainstorming/",
@@ -30,7 +29,10 @@
30
29
  "skills/task/task-close/",
31
30
  "skills/web-fetch/article-fetcher/",
32
31
  "skills/writing/mermaid-diagram/",
33
- "tools/p-launch/"
32
+ "skills/design/sync-design-html/",
33
+ "skills/analysis/git-cleanup/",
34
+ "tools/p-launch/",
35
+ "hooks/check-similar-branch/"
34
36
  ],
35
37
  "engines": {
36
38
  "node": ">=18"