@dianzhong/create-harness-app 0.1.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/dist/index.mjs +412 -0
- package/package.json +29 -0
- package/templates/axios/.env.example +2 -0
- package/templates/axios/src/api/auth.ts +19 -0
- package/templates/axios/src/api/request.ts +61 -0
- package/templates/axios/src/types/api.ts +26 -0
- package/templates/axios/src/utils/auth.ts +5 -0
- package/templates/axios/src/utils/storage.ts +17 -0
- package/templates/harness/full/.agents/skills/find-skills/SKILL.md +143 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/LICENSE.md +21 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/SKILL.md +155 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/SYNC.md +5 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/animation-class-based-technique.md +258 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/animation-state-driven-technique.md +287 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/component-async.md +99 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/component-data-flow.md +313 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/component-fallthrough-attrs.md +179 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/component-keep-alive.md +139 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/component-slots.md +226 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/component-suspense.md +231 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/component-teleport.md +110 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/component-transition-group.md +131 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/component-transition.md +135 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/composables.md +303 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/directives.md +168 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/perf-avoid-component-abstraction-in-lists.md +177 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/perf-v-once-v-memo-directives.md +185 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/perf-virtualize-large-lists.md +182 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/plugins.md +178 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/reactivity.md +371 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/render-functions.md +227 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/sfc.md +355 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/state-management.md +138 -0
- package/templates/harness/full/.agents/skills/vue-best-practices/references/updated-hook-performance.md +193 -0
- package/templates/harness/full/.claude/agents/code-reviewer.md +109 -0
- package/templates/harness/full/.claude/agents/harness-reviewer.md +51 -0
- package/templates/harness/full/.claude/hooks/guard-tool.cjs +234 -0
- package/templates/harness/full/.claude/hooks/notify.cjs +168 -0
- package/templates/harness/full/.claude/hooks/quality-gate.cjs +135 -0
- package/templates/harness/full/.claude/rules/delivery.md +66 -0
- package/templates/harness/full/.claude/rules/formatting.md +7 -0
- package/templates/harness/full/.claude/rules/git.md +8 -0
- package/templates/harness/full/.claude/rules/skills-mcp.md +13 -0
- package/templates/harness/full/.claude/rules/vue.md +227 -0
- package/templates/harness/full/.claude/settings.json +123 -0
- package/templates/harness/full/.claude/skills/find-skills/SKILL.md +143 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/LICENSE.md +21 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/SKILL.md +155 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/SYNC.md +5 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/animation-class-based-technique.md +258 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/animation-state-driven-technique.md +287 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/component-async.md +99 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/component-data-flow.md +313 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/component-fallthrough-attrs.md +179 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/component-keep-alive.md +139 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/component-slots.md +226 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/component-suspense.md +231 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/component-teleport.md +110 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/component-transition-group.md +131 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/component-transition.md +135 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/composables.md +303 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/directives.md +168 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/perf-avoid-component-abstraction-in-lists.md +177 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/perf-v-once-v-memo-directives.md +185 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/perf-virtualize-large-lists.md +182 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/plugins.md +178 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/reactivity.md +371 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/render-functions.md +227 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/sfc.md +355 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/state-management.md +138 -0
- package/templates/harness/full/.claude/skills/vue-best-practices/references/updated-hook-performance.md +193 -0
- package/templates/harness/full/.editorconfig +8 -0
- package/templates/harness/full/.husky/commit-msg +1 -0
- package/templates/harness/full/.husky/pre-commit +1 -0
- package/templates/harness/full/.lintstagedrc.json +4 -0
- package/templates/harness/full/.nvmrc +1 -0
- package/templates/harness/full/.oxlintrc.json +11 -0
- package/templates/harness/full/.prettierrc.json +6 -0
- package/templates/harness/full/AGENTS.md +3 -0
- package/templates/harness/full/CLAUDE.md +28 -0
- package/templates/harness/full/GEMINI.md +3 -0
- package/templates/harness/full/commitlint.config.ts +3 -0
- package/templates/harness/full/docs/ai-harness.md +77 -0
- package/templates/harness/full/docs/delivery-template.md +66 -0
- package/templates/harness/full/docs/git.md +24 -0
- package/templates/harness/full/docs/harness-quick-reference.md +89 -0
- package/templates/harness/full/docs/review-checklist.md +49 -0
- package/templates/harness/full/scripts/harness-hooks.test.mjs +218 -0
- package/templates/harness/full/scripts/verify-skills.mjs +248 -0
- package/templates/harness/full/scripts/verify-skills.test.mjs +72 -0
- package/templates/harness/full/skills-lock.json +50 -0
- package/templates/harness/minimal/.claude/hooks/guard-tool.cjs +234 -0
- package/templates/harness/minimal/.claude/hooks/quality-gate.cjs +135 -0
- package/templates/harness/minimal/.claude/settings.json +27 -0
- package/templates/harness/minimal/CLAUDE.md +12 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* harness hooks 的最小行为测试
|
|
4
|
+
*
|
|
5
|
+
* 覆盖:
|
|
6
|
+
* 1. guard-tool 阻断敏感路径
|
|
7
|
+
* 2. guard-tool 阻断危险命令
|
|
8
|
+
* 3. guard-tool 放行普通命令
|
|
9
|
+
* 4. quality-gate 在 dry-run Stop 模式可完成质量门禁流程
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { spawnSync } from 'node:child_process'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
|
|
17
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
18
|
+
const guardTool = path.join(repoRoot, '.claude', 'hooks', 'guard-tool.cjs')
|
|
19
|
+
const qualityGate = path.join(repoRoot, '.claude', 'hooks', 'quality-gate.cjs')
|
|
20
|
+
const notifyHook = path.join(repoRoot, '.claude', 'hooks', 'notify.cjs')
|
|
21
|
+
|
|
22
|
+
function runHook(script, args, payload) {
|
|
23
|
+
return spawnSync(process.execPath, [script, ...args], {
|
|
24
|
+
cwd: repoRoot,
|
|
25
|
+
input: JSON.stringify(payload),
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseStdout(result) {
|
|
31
|
+
assert.equal(result.status, 0, result.stderr)
|
|
32
|
+
return JSON.parse(result.stdout)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sensitiveRead = parseStdout(
|
|
36
|
+
runHook(guardTool, [], {
|
|
37
|
+
tool_name: 'Read',
|
|
38
|
+
tool_input: { file_path: '.env.production' },
|
|
39
|
+
}),
|
|
40
|
+
)
|
|
41
|
+
assert.equal(sensitiveRead.continue, false)
|
|
42
|
+
assert.equal(sensitiveRead.permissionDecision, 'deny')
|
|
43
|
+
|
|
44
|
+
const sensitiveShellRedirect = parseStdout(
|
|
45
|
+
runHook(guardTool, [], {
|
|
46
|
+
tool_name: 'Bash',
|
|
47
|
+
tool_input: { command: 'cat<.env.production' },
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
assert.equal(sensitiveShellRedirect.continue, false)
|
|
51
|
+
assert.match(sensitiveShellRedirect.stopReason, /sensitive path/)
|
|
52
|
+
|
|
53
|
+
const sensitiveInlineScript = parseStdout(
|
|
54
|
+
runHook(guardTool, [], {
|
|
55
|
+
tool_name: 'Bash',
|
|
56
|
+
tool_input: { command: `node -e "require('fs').readFileSync('.env.production','utf8')"` },
|
|
57
|
+
}),
|
|
58
|
+
)
|
|
59
|
+
assert.equal(sensitiveInlineScript.continue, false)
|
|
60
|
+
assert.match(sensitiveInlineScript.stopReason, /sensitive path/)
|
|
61
|
+
|
|
62
|
+
const destructiveCommand = parseStdout(
|
|
63
|
+
runHook(guardTool, [], {
|
|
64
|
+
tool_name: 'Bash',
|
|
65
|
+
tool_input: { command: 'git status && rm -rf dist' },
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
assert.equal(destructiveCommand.continue, false)
|
|
69
|
+
assert.match(destructiveCommand.stopReason, /recursive force delete/)
|
|
70
|
+
|
|
71
|
+
const recursiveForceSwapped = parseStdout(
|
|
72
|
+
runHook(guardTool, [], {
|
|
73
|
+
tool_name: 'Bash',
|
|
74
|
+
tool_input: { command: 'rm -fr dist' },
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
assert.equal(recursiveForceSwapped.continue, false)
|
|
78
|
+
assert.match(recursiveForceSwapped.stopReason, /recursive force delete/)
|
|
79
|
+
|
|
80
|
+
const recursiveForceSplit = parseStdout(
|
|
81
|
+
runHook(guardTool, [], {
|
|
82
|
+
tool_name: 'Bash',
|
|
83
|
+
tool_input: { command: 'rm -r -f dist' },
|
|
84
|
+
}),
|
|
85
|
+
)
|
|
86
|
+
assert.equal(recursiveForceSplit.continue, false)
|
|
87
|
+
assert.match(recursiveForceSplit.stopReason, /recursive force delete/)
|
|
88
|
+
|
|
89
|
+
const safeCommand = parseStdout(
|
|
90
|
+
runHook(guardTool, [], {
|
|
91
|
+
tool_name: 'Bash',
|
|
92
|
+
tool_input: { command: 'git status --short' },
|
|
93
|
+
}),
|
|
94
|
+
)
|
|
95
|
+
assert.equal(safeCommand.continue, true)
|
|
96
|
+
|
|
97
|
+
const envVarBypass = parseStdout(
|
|
98
|
+
runHook(guardTool, [], {
|
|
99
|
+
tool_name: 'Bash',
|
|
100
|
+
tool_input: { command: 'GIT_DIR=.git git reset --hard' },
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
assert.equal(envVarBypass.continue, false)
|
|
104
|
+
assert.match(envVarBypass.stopReason, /destructive git reset/)
|
|
105
|
+
|
|
106
|
+
const gitClean = parseStdout(
|
|
107
|
+
runHook(guardTool, [], {
|
|
108
|
+
tool_name: 'Bash',
|
|
109
|
+
tool_input: { command: 'git clean -fd' },
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
assert.equal(gitClean.continue, false)
|
|
113
|
+
assert.match(gitClean.stopReason, /destructive git clean/)
|
|
114
|
+
|
|
115
|
+
const gitCleanSwapped = parseStdout(
|
|
116
|
+
runHook(guardTool, [], {
|
|
117
|
+
tool_name: 'Bash',
|
|
118
|
+
tool_input: { command: 'git clean -df' },
|
|
119
|
+
}),
|
|
120
|
+
)
|
|
121
|
+
assert.equal(gitCleanSwapped.continue, false)
|
|
122
|
+
assert.match(gitCleanSwapped.stopReason, /destructive git clean/)
|
|
123
|
+
|
|
124
|
+
const gitCheckoutPath = parseStdout(
|
|
125
|
+
runHook(guardTool, [], {
|
|
126
|
+
tool_name: 'Bash',
|
|
127
|
+
tool_input: { command: 'git checkout -- src/App.vue' },
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
assert.equal(gitCheckoutPath.continue, false)
|
|
131
|
+
assert.match(gitCheckoutPath.stopReason, /destructive git checkout/)
|
|
132
|
+
|
|
133
|
+
const gitRestorePath = parseStdout(
|
|
134
|
+
runHook(guardTool, [], {
|
|
135
|
+
tool_name: 'Bash',
|
|
136
|
+
tool_input: { command: 'git restore src/App.vue' },
|
|
137
|
+
}),
|
|
138
|
+
)
|
|
139
|
+
assert.equal(gitRestorePath.continue, false)
|
|
140
|
+
assert.match(gitRestorePath.stopReason, /destructive git restore/)
|
|
141
|
+
|
|
142
|
+
const forceRefSpec = parseStdout(
|
|
143
|
+
runHook(guardTool, [], {
|
|
144
|
+
tool_name: 'Bash',
|
|
145
|
+
tool_input: { command: 'git push origin +main' },
|
|
146
|
+
}),
|
|
147
|
+
)
|
|
148
|
+
assert.equal(forceRefSpec.continue, false)
|
|
149
|
+
assert.match(forceRefSpec.stopReason, /force push via refspec/)
|
|
150
|
+
|
|
151
|
+
const stopDryRun = parseStdout(runHook(qualityGate, ['stop', '--dry-run'], {}))
|
|
152
|
+
assert.equal(stopDryRun.continue, true)
|
|
153
|
+
assert.match(stopDryRun.systemMessage, /Harness 质量门禁(通过|跳过)/)
|
|
154
|
+
|
|
155
|
+
// --- notify.cjs SubagentStop 测试 ---
|
|
156
|
+
|
|
157
|
+
// JSON 契约 ok=false 应阻断
|
|
158
|
+
const subagentJsonFail = parseStdout(
|
|
159
|
+
runHook(notifyHook, ['subagent-stop', '--dry-run'], {
|
|
160
|
+
last_assistant_message: 'Review done. {"ok": false, "reason": "test failure"}',
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
assert.equal(subagentJsonFail.continue, false)
|
|
164
|
+
assert.match(subagentJsonFail.stopReason, /审查未通过/)
|
|
165
|
+
|
|
166
|
+
// JSON 契约 ok=true 应放行
|
|
167
|
+
const subagentJsonPass = parseStdout(
|
|
168
|
+
runHook(notifyHook, ['subagent-stop', '--dry-run'], {
|
|
169
|
+
last_assistant_message: 'Review done. {"ok": true}',
|
|
170
|
+
}),
|
|
171
|
+
)
|
|
172
|
+
assert.equal(subagentJsonPass.continue, true)
|
|
173
|
+
|
|
174
|
+
// 纯字符串 FAIL 标记(在末尾)应阻断
|
|
175
|
+
const subagentStringFail = parseStdout(
|
|
176
|
+
runHook(notifyHook, ['subagent-stop', '--dry-run'], {
|
|
177
|
+
message: 'Some review output\nHARNESS_REVIEW_RESULT: FAIL',
|
|
178
|
+
}),
|
|
179
|
+
)
|
|
180
|
+
assert.equal(subagentStringFail.continue, false)
|
|
181
|
+
assert.match(subagentStringFail.stopReason, /审查未通过/)
|
|
182
|
+
|
|
183
|
+
// 纯字符串 PASS 标记(在末尾)应放行
|
|
184
|
+
const subagentStringPass = parseStdout(
|
|
185
|
+
runHook(notifyHook, ['subagent-stop', '--dry-run'], {
|
|
186
|
+
message: 'Some review output\nHARNESS_REVIEW_RESULT: PASS',
|
|
187
|
+
}),
|
|
188
|
+
)
|
|
189
|
+
assert.equal(subagentStringPass.continue, true)
|
|
190
|
+
|
|
191
|
+
// 边界:解释性文本含 FAIL 但 JSON ok=true,应放行(核心 bug 修复验证)
|
|
192
|
+
const subagentExplainFail = parseStdout(
|
|
193
|
+
runHook(notifyHook, ['subagent-stop', '--dry-run'], {
|
|
194
|
+
message:
|
|
195
|
+
'Code review passed. Note: HARNESS_REVIEW_RESULT: FAIL would mean issues. {"ok": true, "reason": "All checks passed."}',
|
|
196
|
+
}),
|
|
197
|
+
)
|
|
198
|
+
assert.equal(subagentExplainFail.continue, true)
|
|
199
|
+
|
|
200
|
+
// 边界:JSON 前有代码块含 {},应正确解析最后一个 JSON
|
|
201
|
+
const subagentCodeBlock = parseStdout(
|
|
202
|
+
runHook(notifyHook, ['subagent-stop', '--dry-run'], {
|
|
203
|
+
message: 'Config: {"name": "test"}. Result: {"ok": false, "reason": "missing type"}',
|
|
204
|
+
}),
|
|
205
|
+
)
|
|
206
|
+
assert.equal(subagentCodeBlock.continue, false)
|
|
207
|
+
assert.match(subagentCodeBlock.stopReason, /审查未通过/)
|
|
208
|
+
|
|
209
|
+
// 边界:中间含 FAIL 但末尾是 PASS(字符串 fallback),应放行
|
|
210
|
+
const subagentMiddleFail = parseStdout(
|
|
211
|
+
runHook(notifyHook, ['subagent-stop', '--dry-run'], {
|
|
212
|
+
message:
|
|
213
|
+
'I checked the code. HARNESS_REVIEW_RESULT: FAIL would be bad.\nHARNESS_REVIEW_RESULT: PASS',
|
|
214
|
+
}),
|
|
215
|
+
)
|
|
216
|
+
assert.equal(subagentMiddleFail.continue, true)
|
|
217
|
+
|
|
218
|
+
console.log('harness hook tests passed')
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 校验 skills 体系的一致性
|
|
4
|
+
*
|
|
5
|
+
* 检查项:
|
|
6
|
+
* 1. .claude/skills 与 .agents/skills 目录结构完全镜像
|
|
7
|
+
* 2. SKILL.md 内容 hash 一致
|
|
8
|
+
* 3. api/assets/references/scripts/templates 等资源目录内容一致
|
|
9
|
+
* 4. skills-lock.json 的 computedHash 与实际目录内容一致
|
|
10
|
+
*
|
|
11
|
+
* --write 模式:更新 skills-lock.json(用于发布 skill 变更时)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'node:crypto'
|
|
15
|
+
import { existsSync } from 'node:fs'
|
|
16
|
+
import { lstat, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
|
|
19
|
+
const root = process.cwd()
|
|
20
|
+
const skillsDir = path.join(root, '.claude', 'skills')
|
|
21
|
+
const agentsSkillsDir = path.join(root, '.agents', 'skills')
|
|
22
|
+
const lockPath = path.join(root, 'skills-lock.json')
|
|
23
|
+
const writeMode = process.argv.includes('--write')
|
|
24
|
+
const mirroredResourceDirs = new Set([
|
|
25
|
+
'api',
|
|
26
|
+
'assets',
|
|
27
|
+
'examples',
|
|
28
|
+
'references',
|
|
29
|
+
'scripts',
|
|
30
|
+
'templates',
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
const defaultSources = {
|
|
34
|
+
'element-plus-vue3': {
|
|
35
|
+
source: 'partme-ai/full-stack-skills',
|
|
36
|
+
sourceType: 'github',
|
|
37
|
+
},
|
|
38
|
+
'find-skills': {
|
|
39
|
+
source: 'anthropics/skills',
|
|
40
|
+
sourceType: 'github',
|
|
41
|
+
},
|
|
42
|
+
'frontend-design': {
|
|
43
|
+
source: 'anthropics/claude-code',
|
|
44
|
+
sourceType: 'github',
|
|
45
|
+
},
|
|
46
|
+
'vue-best-practices': {
|
|
47
|
+
source: 'moeru-ai/airi',
|
|
48
|
+
sourceType: 'github',
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 路径分隔符转 POSIX 格式(跨平台一致性)
|
|
53
|
+
function toPosix(relativePath) {
|
|
54
|
+
return relativePath.split(path.sep).join('/')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readJson(filePath) {
|
|
58
|
+
return JSON.parse(await readFile(filePath, 'utf8'))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 递归收集目录下所有文件,跳过 .DS_Store,禁止符号链接(skill 路径必须是真实文件)
|
|
62
|
+
async function collectFiles(dir, baseDir = dir) {
|
|
63
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
64
|
+
const files = []
|
|
65
|
+
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (entry.name === '.DS_Store') {
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const fullPath = path.join(dir, entry.name)
|
|
72
|
+
const stat = await lstat(fullPath)
|
|
73
|
+
|
|
74
|
+
if (stat.isSymbolicLink()) {
|
|
75
|
+
throw new Error(`Skill paths must be real project files, not links: ${fullPath}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (entry.isDirectory()) {
|
|
79
|
+
files.push(...(await collectFiles(fullPath, baseDir)))
|
|
80
|
+
} else if (entry.isFile()) {
|
|
81
|
+
files.push({
|
|
82
|
+
fullPath,
|
|
83
|
+
relativePath: toPosix(path.relative(baseDir, fullPath)),
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 计算目录内容确定性 hash:按相对路径排序,拼接路径+内容+分隔符后 SHA-256
|
|
92
|
+
async function hashDirectory(dir) {
|
|
93
|
+
const hash = createHash('sha256')
|
|
94
|
+
const files = await collectFiles(dir)
|
|
95
|
+
|
|
96
|
+
for (const file of files) {
|
|
97
|
+
hash.update(file.relativePath)
|
|
98
|
+
hash.update('\0')
|
|
99
|
+
hash.update(await readFile(file.fullPath))
|
|
100
|
+
hash.update('\0')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return hash.digest('hex')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 计算单个文件的 SHA-256 hash
|
|
107
|
+
async function hashFile(filePath) {
|
|
108
|
+
const hash = createHash('sha256')
|
|
109
|
+
hash.update(await readFile(filePath))
|
|
110
|
+
return hash.digest('hex')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function listSkillNames(dir) {
|
|
114
|
+
if (!existsSync(dir)) {
|
|
115
|
+
return []
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
119
|
+
|
|
120
|
+
return entries
|
|
121
|
+
.filter((entry) => entry.isDirectory())
|
|
122
|
+
.map((entry) => entry.name)
|
|
123
|
+
.sort()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 断言两个 skill 名称集合完全一致(用于 .claude/skills 与 .agents/skills 镜像校验)
|
|
127
|
+
function assertEqualSets(label, actual, expected) {
|
|
128
|
+
const actualList = [...actual].sort()
|
|
129
|
+
const expectedList = [...expected].sort()
|
|
130
|
+
|
|
131
|
+
if (actualList.join('\n') !== expectedList.join('\n')) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`${label} mismatch.\nActual: ${actualList.join(', ') || '(none)'}\nExpected: ${
|
|
134
|
+
expectedList.join(', ') || '(none)'
|
|
135
|
+
}`,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function main() {
|
|
141
|
+
if (!existsSync(skillsDir)) {
|
|
142
|
+
throw new Error(`Missing skills directory: ${skillsDir}`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!existsSync(agentsSkillsDir)) {
|
|
146
|
+
throw new Error(`Missing agents skills mirror directory: ${agentsSkillsDir}`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const skillNames = await listSkillNames(skillsDir)
|
|
150
|
+
const agentsSkillNames = await listSkillNames(agentsSkillsDir)
|
|
151
|
+
assertEqualSets('.agents/skills mirror entries', new Set(agentsSkillNames), new Set(skillNames))
|
|
152
|
+
|
|
153
|
+
const hashes = {}
|
|
154
|
+
|
|
155
|
+
for (const skillName of skillNames) {
|
|
156
|
+
hashes[skillName] = await hashDirectory(path.join(skillsDir, skillName))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const existingLock = existsSync(lockPath) ? await readJson(lockPath) : { version: 1, skills: {} }
|
|
160
|
+
const nextLock = {
|
|
161
|
+
version: 1,
|
|
162
|
+
skills: Object.fromEntries(
|
|
163
|
+
skillNames.map((skillName) => {
|
|
164
|
+
const existing = existingLock.skills?.[skillName] || {}
|
|
165
|
+
const source = existing.source || defaultSources[skillName]?.source || 'project-local'
|
|
166
|
+
const sourceType = existing.sourceType || defaultSources[skillName]?.sourceType || 'local'
|
|
167
|
+
|
|
168
|
+
return [
|
|
169
|
+
skillName,
|
|
170
|
+
{
|
|
171
|
+
source,
|
|
172
|
+
sourceType,
|
|
173
|
+
computedHash: hashes[skillName],
|
|
174
|
+
},
|
|
175
|
+
]
|
|
176
|
+
}),
|
|
177
|
+
),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (writeMode) {
|
|
181
|
+
await writeFile(lockPath, `${JSON.stringify(nextLock, null, 2)}\n`)
|
|
182
|
+
process.stdout.write(`Updated skills-lock.json for ${skillNames.length} skill(s).\n`)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const lock = existingLock
|
|
187
|
+
const lockNames = new Set(Object.keys(lock.skills || {}))
|
|
188
|
+
const skillNameSet = new Set(skillNames)
|
|
189
|
+
assertEqualSets('skills-lock.json entries', lockNames, skillNameSet)
|
|
190
|
+
|
|
191
|
+
// 校验每个 skill 的镜像同步、SKILL.md hash、资源目录和 lock hash
|
|
192
|
+
for (const skillName of skillNames) {
|
|
193
|
+
const skillPath = path.join(skillsDir, skillName)
|
|
194
|
+
const agentsSkillPath = path.join(agentsSkillsDir, skillName)
|
|
195
|
+
const sourceSkillMd = path.join(skillPath, 'SKILL.md')
|
|
196
|
+
const mirroredSkillMd = path.join(agentsSkillPath, 'SKILL.md')
|
|
197
|
+
|
|
198
|
+
if (!existsSync(sourceSkillMd)) {
|
|
199
|
+
throw new Error(`Missing skill entrypoint: ${sourceSkillMd}`)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!existsSync(mirroredSkillMd)) {
|
|
203
|
+
throw new Error(`Missing mirrored skill entrypoint: ${mirroredSkillMd}`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if ((await hashFile(sourceSkillMd)) !== (await hashFile(mirroredSkillMd))) {
|
|
207
|
+
throw new Error(`Mirrored SKILL.md is out of sync for ${skillName}.`)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const resourceDir of mirroredResourceDirs) {
|
|
211
|
+
const sourceResourcePath = path.join(skillPath, resourceDir)
|
|
212
|
+
const mirroredResourcePath = path.join(agentsSkillPath, resourceDir)
|
|
213
|
+
|
|
214
|
+
if (!existsSync(sourceResourcePath)) {
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!existsSync(mirroredResourcePath)) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`Missing mirrored skill resource: ${toPosix(path.relative(root, mirroredResourcePath))}`,
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (
|
|
225
|
+
(await hashDirectory(sourceResourcePath)) !== (await hashDirectory(mirroredResourcePath))
|
|
226
|
+
) {
|
|
227
|
+
throw new Error(`Mirrored skill resource is out of sync: ${skillName}/${resourceDir}`)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const lockedHash = lock.skills?.[skillName]?.computedHash
|
|
232
|
+
|
|
233
|
+
if (lockedHash !== hashes[skillName]) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Hash mismatch for ${skillName}.\nExpected lock: ${hashes[skillName]}\nActual lock: ${lockedHash}`,
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
process.stdout.write(
|
|
241
|
+
`Harness skill check passed for ${skillNames.length} skill(s), including .agents/skills mirror.\n`,
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
main().catch((error) => {
|
|
246
|
+
process.stderr.write(`${error.message}\n`)
|
|
247
|
+
process.exit(1)
|
|
248
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* verify-skills.mjs 的最小集成测试
|
|
4
|
+
*
|
|
5
|
+
* 覆盖:
|
|
6
|
+
* 1. --write 能生成 lock
|
|
7
|
+
* 2. .agents/skills 镜像内容漂移时会失败
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { spawnSync } from 'node:child_process'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
|
|
17
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
18
|
+
const verifyScript = path.join(repoRoot, 'scripts', 'verify-skills.mjs')
|
|
19
|
+
|
|
20
|
+
async function writeFixtureFile(root, relativePath, content) {
|
|
21
|
+
const fullPath = path.join(root, relativePath)
|
|
22
|
+
await mkdir(path.dirname(fullPath), { recursive: true })
|
|
23
|
+
await writeFile(fullPath, content)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function runVerify(root, args = []) {
|
|
27
|
+
return spawnSync(process.execPath, [verifyScript, ...args], {
|
|
28
|
+
cwd: root,
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function withSkillFixture(testFn) {
|
|
34
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'verify-skills-'))
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const skill = `---
|
|
38
|
+
name: sample-skill
|
|
39
|
+
description: Sample skill for harness tests.
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
# Sample Skill
|
|
43
|
+
`
|
|
44
|
+
|
|
45
|
+
await writeFixtureFile(root, '.claude/skills/sample-skill/SKILL.md', skill)
|
|
46
|
+
await writeFixtureFile(root, '.agents/skills/sample-skill/SKILL.md', skill)
|
|
47
|
+
|
|
48
|
+
await testFn(root)
|
|
49
|
+
} finally {
|
|
50
|
+
await rm(root, { recursive: true, force: true })
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await withSkillFixture(async (root) => {
|
|
55
|
+
const writeResult = runVerify(root, ['--write'])
|
|
56
|
+
assert.equal(writeResult.status, 0, writeResult.stderr)
|
|
57
|
+
|
|
58
|
+
const checkResult = runVerify(root)
|
|
59
|
+
assert.equal(checkResult.status, 0, checkResult.stderr)
|
|
60
|
+
assert.match(checkResult.stdout, /Harness skill check passed/)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
await withSkillFixture(async (root) => {
|
|
64
|
+
assert.equal(runVerify(root, ['--write']).status, 0)
|
|
65
|
+
await writeFixtureFile(root, '.agents/skills/sample-skill/SKILL.md', '# Drifted Skill\n')
|
|
66
|
+
|
|
67
|
+
const result = runVerify(root)
|
|
68
|
+
assert.notEqual(result.status, 0)
|
|
69
|
+
assert.match(result.stderr, /Mirrored SKILL\.md is out of sync/)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
console.log('verify-skills tests passed')
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"skills": {
|
|
4
|
+
"element-plus-vue3": {
|
|
5
|
+
"source": "partme-ai/full-stack-skills",
|
|
6
|
+
"sourceType": "github",
|
|
7
|
+
"computedHash": "a2d4fa311bdce8b6e914ad5a84b9e6a1985a8df59a94aae8259b7aff7a3db41e"
|
|
8
|
+
},
|
|
9
|
+
"find-skills": {
|
|
10
|
+
"source": "anthropics/skills",
|
|
11
|
+
"sourceType": "github",
|
|
12
|
+
"computedHash": "c6bcd076436f910c67f591a5a83f8e6182a2b71d22a686084854fe9924324567"
|
|
13
|
+
},
|
|
14
|
+
"frontend-design": {
|
|
15
|
+
"source": "anthropics/claude-code",
|
|
16
|
+
"sourceType": "github",
|
|
17
|
+
"computedHash": "514ce855a27bfcf4e883a46fc02c68a3bc9cd8571a2d3fe5ac1aa93cde008433"
|
|
18
|
+
},
|
|
19
|
+
"openspec-apply-change": {
|
|
20
|
+
"source": "project-local",
|
|
21
|
+
"sourceType": "local",
|
|
22
|
+
"computedHash": "b0d3d68a1782fd8fd9c5b54c057461079bbdf8d8d597be2d2dc0d990f5f5509d"
|
|
23
|
+
},
|
|
24
|
+
"openspec-archive-change": {
|
|
25
|
+
"source": "project-local",
|
|
26
|
+
"sourceType": "local",
|
|
27
|
+
"computedHash": "cef47988c2959dbd2967efd2553bc94dc01178cffc76ae39c2d841eb4d5e6326"
|
|
28
|
+
},
|
|
29
|
+
"openspec-explore": {
|
|
30
|
+
"source": "partme-ai/full-stack-skills",
|
|
31
|
+
"sourceType": "github",
|
|
32
|
+
"computedHash": "7f84a20d2e1c238585bce57750d498c18d52ad7be6f2842f486bbdd074c29cf7"
|
|
33
|
+
},
|
|
34
|
+
"openspec-propose": {
|
|
35
|
+
"source": "project-local",
|
|
36
|
+
"sourceType": "local",
|
|
37
|
+
"computedHash": "5f1b30ef0e2134741b29affd8c5815d4e66d36bcf9404034bdb056bb6c25e405"
|
|
38
|
+
},
|
|
39
|
+
"project-structure-guard": {
|
|
40
|
+
"source": "project-local",
|
|
41
|
+
"sourceType": "local",
|
|
42
|
+
"computedHash": "bae2ec47defeae0d996fca6c05bce6d138ec31909bf0625ecc942926491fca72"
|
|
43
|
+
},
|
|
44
|
+
"vue-best-practices": {
|
|
45
|
+
"source": "moeru-ai/airi",
|
|
46
|
+
"sourceType": "github",
|
|
47
|
+
"computedHash": "ce4811a2206a750236deed1e7bc346c776a2d663bb386ba84d624e8852efd936"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|