hookstack-cli 0.1.18 → 0.1.20
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/README.md +7 -1
- package/bin/cli.mjs +56 -20
- package/bin/core.mjs +30 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,7 +24,9 @@ npx hookstack-cli@latest install --hooks=<slug1>,<slug2>,...
|
|
|
24
24
|
Options:
|
|
25
25
|
--hooks <slugs> Comma-separated hook slugs (required)
|
|
26
26
|
--global, -g Install into ~/.claude instead of ./.claude
|
|
27
|
-
--
|
|
27
|
+
--copilot Install into ./.claude with paths adapted for GitHub Copilot
|
|
28
|
+
--scope <s> "project" (default), "global", or "copilot"
|
|
29
|
+
--with-tests Also install vitest unit tests into tests/hooks/ (project scope only)
|
|
28
30
|
--yes, -y Skip prompts (non-interactive / CI)
|
|
29
31
|
--version, -v Print version
|
|
30
32
|
--help, -h Show help
|
|
@@ -46,6 +48,9 @@ Skips all prompts — useful in CI or dotfile bootstrap scripts.
|
|
|
46
48
|
```bash
|
|
47
49
|
# CI bootstrap
|
|
48
50
|
npx hookstack-cli@latest install --hooks=secret-detection,git-push-guard --yes --scope=project
|
|
51
|
+
|
|
52
|
+
# CI bootstrap with unit tests (avoids SonarQube gating on new files without tests)
|
|
53
|
+
npx hookstack-cli@latest install --hooks=secret-detection,git-push-guard --yes --with-tests
|
|
49
54
|
```
|
|
50
55
|
|
|
51
56
|
---
|
|
@@ -56,6 +61,7 @@ For each hook the CLI:
|
|
|
56
61
|
|
|
57
62
|
- Writes the `.mjs` script to `.claude/hooks/` (or `~/.claude/hooks/` for global scope)
|
|
58
63
|
- Patches `.claude/settings.json` to register the hook on the right lifecycle event
|
|
64
|
+
- Optionally writes vitest unit tests to `tests/hooks/` when `--with-tests` is passed (or confirmed interactively)
|
|
59
65
|
|
|
60
66
|
No new dependencies are added to your project. Hooks are plain Node.js scripts — no SDK, no agent modification.
|
|
61
67
|
|
package/bin/cli.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'fs'
|
|
3
|
+
import { join as pathJoin } from 'path'
|
|
3
4
|
import { homedir } from 'os'
|
|
4
5
|
import { join, dirname } from 'path'
|
|
5
6
|
import { fileURLToPath } from 'url'
|
|
@@ -11,8 +12,8 @@ import {
|
|
|
11
12
|
mergeHooks,
|
|
12
13
|
assertSafeTarget,
|
|
13
14
|
collectIncomingHooks,
|
|
14
|
-
buildSummaryRows,
|
|
15
15
|
buildSecurityRows,
|
|
16
|
+
doInstallTests,
|
|
16
17
|
} from './core.mjs'
|
|
17
18
|
|
|
18
19
|
const API_BASE = process.env.HOOKSTACK_API_BASE || 'https://hookstack.vercel.app'
|
|
@@ -71,29 +72,36 @@ function capCell(on, width) {
|
|
|
71
72
|
return on ? pc.yellow(text) : pc.dim(text)
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
const
|
|
75
|
+
const VERDICT_COLOR = { high: pc.red, medium: pc.yellow, low: pc.cyan, safe: pc.green, unknown: pc.dim }
|
|
75
76
|
|
|
76
77
|
export function securityPanel(rows) {
|
|
77
|
-
const W = { name:
|
|
78
|
+
const W = { name: 24, benefit: 34, cap: 7, snyk: 9, codeql: 8 }
|
|
78
79
|
const header = pc.dim(
|
|
79
|
-
''.padEnd(W.name) +
|
|
80
|
-
'
|
|
80
|
+
''.padEnd(W.name) +
|
|
81
|
+
''.padEnd(W.benefit) +
|
|
82
|
+
'Shell'.padEnd(W.cap) +
|
|
83
|
+
'Net'.padEnd(W.cap) +
|
|
84
|
+
'Writes'.padEnd(W.cap) +
|
|
85
|
+
'Snyk'.padEnd(W.snyk) +
|
|
86
|
+
'CodeQL',
|
|
81
87
|
)
|
|
82
|
-
const body = rows.
|
|
83
|
-
truncPad(r.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
const body = rows.flatMap(r => {
|
|
89
|
+
const benefitText = r.benefit ? truncPad(r.benefit, W.benefit) : ''.padEnd(W.benefit)
|
|
90
|
+
const row = truncPad(r.name, W.name) +
|
|
91
|
+
pc.dim(benefitText) +
|
|
92
|
+
capCell(r.shell, W.cap) +
|
|
93
|
+
capCell(r.network, W.cap) +
|
|
94
|
+
capCell(r.fsWrite, W.cap) +
|
|
95
|
+
(VERDICT_COLOR[r.snyk.level] ?? pc.dim)(truncPad(r.snyk.label, W.snyk)) +
|
|
96
|
+
(VERDICT_COLOR[r.codeql.level] ?? pc.dim)(r.codeql.label)
|
|
97
|
+
return [row]
|
|
98
|
+
})
|
|
90
99
|
const footer = [
|
|
91
100
|
'',
|
|
92
101
|
pc.dim('Shell = runs commands · Net = network access · Writes = filesystem writes'),
|
|
93
|
-
anyUnknown ? pc.dim('Snyk "—" = not scanned yet') : null,
|
|
94
102
|
pc.dim(`Details: ${API_BASE}/hook/<slug>`),
|
|
95
|
-
].
|
|
96
|
-
return [header, ...body, footer].join('\n')
|
|
103
|
+
].join('\n')
|
|
104
|
+
return [header, '', ...body, footer].join('\n')
|
|
97
105
|
}
|
|
98
106
|
|
|
99
107
|
// ── install ───────────────────────────────────────────────────────────────────
|
|
@@ -184,12 +192,10 @@ async function interactiveInstall(slugs, args) {
|
|
|
184
192
|
scope = scopeResult
|
|
185
193
|
|
|
186
194
|
const dirs = resolveScopeRoot(scope, { cwd: process.cwd(), home: homedir() })
|
|
187
|
-
const summaryRows = buildSummaryRows(hooks, { root: dirs.root })
|
|
188
195
|
const secRows = buildSecurityRows(hooks)
|
|
189
196
|
|
|
190
|
-
//
|
|
191
|
-
p.note(
|
|
192
|
-
p.note(securityPanel(secRows), 'Security Assessment')
|
|
197
|
+
// Panel
|
|
198
|
+
p.note(securityPanel(secRows), 'Installation Summary')
|
|
193
199
|
|
|
194
200
|
// Confirm
|
|
195
201
|
const scopeLabel = scope === 'global' ? '~/.claude' : scope === 'copilot' ? './.claude (Copilot mode)' : './.claude'
|
|
@@ -210,12 +216,32 @@ async function interactiveInstall(slugs, args) {
|
|
|
210
216
|
}
|
|
211
217
|
spin2.stop(`Installed ${pc.bold(String(result.hookCount))} hook${result.hookCount === 1 ? '' : 's'}`)
|
|
212
218
|
|
|
219
|
+
// Unit tests prompt (project/copilot scope only)
|
|
220
|
+
let testResult = null
|
|
221
|
+
const hooksWithTests = hooks.filter(h => h.test_snippet)
|
|
222
|
+
if (scope !== 'global' && hooksWithTests.length > 0) {
|
|
223
|
+
const wantTests = await p.confirm({
|
|
224
|
+
message: `Install unit tests for ${plural(hooksWithTests.length, 'hook')} into ${pc.cyan('tests/hooks/')}? ${pc.dim('(vitest — helps SonarQube gating)')}`,
|
|
225
|
+
initialValue: true,
|
|
226
|
+
})
|
|
227
|
+
if (!p.isCancel(wantTests) && wantTests) {
|
|
228
|
+
try {
|
|
229
|
+
testResult = doInstallTests(hooks, process.cwd(), { mkdirSync, writeFileSync, join: pathJoin })
|
|
230
|
+
} catch (e) {
|
|
231
|
+
p.log.warn(`Could not write tests: ${e.message}`)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
213
236
|
// Result panel
|
|
214
237
|
const resultLines = [
|
|
215
238
|
pc.green(`✓ ${dirs.settingsPath}`),
|
|
216
239
|
result.scriptCount > 0
|
|
217
240
|
? pc.green(`✓ ${result.scriptCount} script${result.scriptCount === 1 ? '' : 's'} written to ${dirs.hooksDir}`)
|
|
218
241
|
: null,
|
|
242
|
+
testResult?.testCount > 0
|
|
243
|
+
? pc.green(`✓ ${testResult.testCount} test file${testResult.testCount === 1 ? '' : 's'} written to tests/hooks/`)
|
|
244
|
+
: null,
|
|
219
245
|
'',
|
|
220
246
|
`Browse more hooks ${pc.cyan(`${API_BASE}/#catalogue`)}`,
|
|
221
247
|
`⭐ Star us ${pc.cyan(REPO_URL)}`,
|
|
@@ -246,6 +272,15 @@ async function directInstall(slugs, args) {
|
|
|
246
272
|
const log = { warn: m => console.warn(` ! ${m}`) }
|
|
247
273
|
const result = doInstall(hooks, dirs, args.scope, log)
|
|
248
274
|
console.log(` ✓ ${dirs.settingsPath}`)
|
|
275
|
+
if (args.withTests && args.scope !== 'global') {
|
|
276
|
+
try {
|
|
277
|
+
const testResult = doInstallTests(hooks, process.cwd(), { mkdirSync, writeFileSync, join: pathJoin })
|
|
278
|
+
if (testResult.testCount > 0)
|
|
279
|
+
console.log(` ✓ ${testResult.testCount} test file${testResult.testCount === 1 ? '' : 's'} written to tests/hooks/`)
|
|
280
|
+
} catch (e) {
|
|
281
|
+
console.warn(` ! Could not write tests: ${e.message}`)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
249
284
|
console.log(`\n✅ ${plural(result.hookCount, 'hook')} installed · star us → ${REPO_URL}`)
|
|
250
285
|
console.log(' Restart Claude Code to activate.\n')
|
|
251
286
|
}
|
|
@@ -262,6 +297,7 @@ const HELP = `
|
|
|
262
297
|
--global, -g Install into ~/.claude instead of ./.claude
|
|
263
298
|
--copilot Install into ./.claude with paths adapted for GitHub Copilot
|
|
264
299
|
--scope <s> "project" (default), "global", or "copilot"
|
|
300
|
+
--with-tests Also install vitest unit tests into tests/hooks/ (project scope only)
|
|
265
301
|
--yes, -y Skip prompts (non-interactive install)
|
|
266
302
|
--version, -v Show version
|
|
267
303
|
--help, -h Show this help
|
package/bin/core.mjs
CHANGED
|
@@ -27,6 +27,7 @@ export function parseArgs(argv) {
|
|
|
27
27
|
version: false,
|
|
28
28
|
scope: 'project',
|
|
29
29
|
yes: false,
|
|
30
|
+
withTests: false,
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -34,6 +35,7 @@ export function parseArgs(argv) {
|
|
|
34
35
|
if (arg === '--help' || arg === '-h') { result.help = true; continue }
|
|
35
36
|
if (arg === '--version' || arg === '-v') { result.version = true; continue }
|
|
36
37
|
if (arg === '--yes' || arg === '-y') { result.yes = true; continue }
|
|
38
|
+
if (arg === '--with-tests') { result.withTests = true; continue }
|
|
37
39
|
if (arg === '--global' || arg === '-g') { result.scope = 'global'; continue }
|
|
38
40
|
if (arg === '--project') { result.scope = 'project'; continue }
|
|
39
41
|
if (arg === '--copilot') { result.scope = 'copilot'; continue }
|
|
@@ -159,6 +161,16 @@ export function snykVerdict(snyk) {
|
|
|
159
161
|
return { label: 'Safe', level: 'safe' }
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
// Maps a stored CodeQL scan to a short verdict label.
|
|
165
|
+
export function codeqlVerdict(codeql) {
|
|
166
|
+
if (!codeql || typeof codeql !== 'object') return { label: '—', level: 'unknown' }
|
|
167
|
+
const { critical = 0, high = 0, medium = 0, low = 0 } = codeql
|
|
168
|
+
if (critical > 0 || high > 0) return { label: 'High Risk', level: 'high' }
|
|
169
|
+
if (medium > 0) return { label: 'Med Risk', level: 'medium' }
|
|
170
|
+
if (low > 0) return { label: 'Low Risk', level: 'low' }
|
|
171
|
+
return { label: 'Safe', level: 'safe' }
|
|
172
|
+
}
|
|
173
|
+
|
|
162
174
|
export function shortRepo(url) {
|
|
163
175
|
if (!url) return null
|
|
164
176
|
return String(url)
|
|
@@ -167,6 +179,21 @@ export function shortRepo(url) {
|
|
|
167
179
|
.replace(/\/$/, '')
|
|
168
180
|
}
|
|
169
181
|
|
|
182
|
+
// Writes test files for installed hooks into <projectRoot>/tests/hooks/.
|
|
183
|
+
// Only hooks that have a test_snippet are written; others are silently skipped.
|
|
184
|
+
export function doInstallTests(hooks, projectRoot, { mkdirSync, writeFileSync, join }) {
|
|
185
|
+
const testsDir = join(projectRoot, 'tests', 'hooks')
|
|
186
|
+
mkdirSync(testsDir, { recursive: true })
|
|
187
|
+
let testCount = 0
|
|
188
|
+
for (const hook of hooks) {
|
|
189
|
+
if (!hook.test_snippet) continue
|
|
190
|
+
const dest = join(testsDir, `${hook.slug}.test.mjs`)
|
|
191
|
+
writeFileSync(dest, hook.test_snippet, 'utf8')
|
|
192
|
+
testCount++
|
|
193
|
+
}
|
|
194
|
+
return { testCount }
|
|
195
|
+
}
|
|
196
|
+
|
|
170
197
|
// Display rows for the "Installation Summary" panel.
|
|
171
198
|
export function buildSummaryRows(hooks, { root }) {
|
|
172
199
|
return hooks.map(h => {
|
|
@@ -184,12 +211,14 @@ export function buildSummaryRows(hooks, { root }) {
|
|
|
184
211
|
})
|
|
185
212
|
}
|
|
186
213
|
|
|
187
|
-
// Display rows for the "
|
|
214
|
+
// Display rows for the "Installation Summary" panel: description + static capabilities + verdicts.
|
|
188
215
|
export function buildSecurityRows(hooks) {
|
|
189
216
|
return hooks.map(h => ({
|
|
190
217
|
slug: h.slug,
|
|
191
218
|
name: h.name ?? h.slug,
|
|
219
|
+
benefit: h.benefit ?? null,
|
|
192
220
|
...analyzeSecurity(h.code_snippet),
|
|
193
221
|
snyk: snykVerdict(h.security?.snyk),
|
|
222
|
+
codeql: codeqlVerdict(h.security?.codeql),
|
|
194
223
|
}))
|
|
195
224
|
}
|