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.
- package/CHANGELOG.md +89 -0
- package/README.md +85 -39
- package/bin/cli.js +444 -51
- package/hooks/check-similar-branch/check-similar-branch.sh +75 -0
- package/lib/bundles.js +74 -7
- package/lib/installer.js +256 -30
- package/lib/targets.js +22 -13
- package/package.json +8 -6
- package/skills/analysis/git-cleanup/SKILL.md +189 -0
- package/skills/design/sync-design-html/SKILL.md +354 -0
- package/skills/design/sync-design-html/references/stack-electron.md +119 -0
- package/skills/design/sync-design-html/references/stack-nextjs.md +101 -0
- package/skills/design/sync-design-html/references/stack-react.md +117 -0
- package/skills/design/sync-design-html/references/stack-swiftui.md +115 -0
- package/skills/design/sync-design-html/references/stack-vue.md +142 -0
- package/skills/design/sync-design-html/references/stacks.md +45 -0
- package/skills/web-fetch/article-fetcher/SKILL.md +95 -509
- package/skills/web-fetch/article-fetcher/references/__pycache__/article_utils.cpython-314.pyc +0 -0
- package/skills/web-fetch/article-fetcher/references/article_utils.py +32 -5
- package/skills/web-fetch/article-fetcher/scripts/playwright_web.py +172 -0
- package/skills/web-fetch/article-fetcher/scripts/playwright_xcom.py +221 -0
- package/skills/web-fetch/article-fetcher/scripts/validate_article.py +36 -0
- package/skills/web-fetch/article-fetcher/tests/test_security.py +257 -0
- package/skills-index.json +18 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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 =>
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
134
|
-
|
|
159
|
+
console.error(chalk.dim(` · Creating directory: ${targetDir}`))
|
|
160
|
+
await fs.ensureDir(targetDir)
|
|
135
161
|
}
|
|
136
162
|
|
|
137
163
|
const installed = []
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
144
|
-
if (
|
|
145
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
8
|
-
|
|
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:
|
|
13
|
-
cursor:
|
|
14
|
-
codex:
|
|
15
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
30
|
-
|
|
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.
|
|
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
|
-
"
|
|
19
|
-
"skills/analysis/skill-analyzer/
|
|
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
|
-
"
|
|
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"
|