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 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
- --scope <s> "project" (default) or "global"
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 SNYK_COLOR = { high: pc.red, medium: pc.yellow, low: pc.cyan, safe: pc.green, unknown: pc.dim }
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: 28, cap: 8, snyk: 10 }
78
+ const W = { name: 24, benefit: 34, cap: 7, snyk: 9, codeql: 8 }
78
79
  const header = pc.dim(
79
- ''.padEnd(W.name) + 'Shell'.padEnd(W.cap) + 'Net'.padEnd(W.cap) +
80
- 'Writes'.padEnd(W.cap) + 'Snyk',
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.map(r =>
83
- truncPad(r.name, W.name) +
84
- capCell(r.shell, W.cap) +
85
- capCell(r.network, W.cap) +
86
- capCell(r.fsWrite, W.cap) +
87
- (SNYK_COLOR[r.snyk.level] ?? pc.dim)(truncPad(r.snyk.label, W.snyk)),
88
- )
89
- const anyUnknown = rows.some(r => r.snyk.level === 'unknown')
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
- ].filter(v => v !== null).join('\n')
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
- // Panels
191
- p.note(summaryPanel(summaryRows), 'Installation Summary')
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 "Security" panel: local static capabilities + Snyk verdict.
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookstack-cli",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "CLI installer for the Hookstack catalogue of Claude Code hooks",
5
5
  "type": "module",
6
6
  "bin": {