hookstack-cli 0.1.19 → 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'
@@ -12,6 +13,7 @@ import {
12
13
  assertSafeTarget,
13
14
  collectIncomingHooks,
14
15
  buildSecurityRows,
16
+ doInstallTests,
15
17
  } from './core.mjs'
16
18
 
17
19
  const API_BASE = process.env.HOOKSTACK_API_BASE || 'https://hookstack.vercel.app'
@@ -214,12 +216,32 @@ async function interactiveInstall(slugs, args) {
214
216
  }
215
217
  spin2.stop(`Installed ${pc.bold(String(result.hookCount))} hook${result.hookCount === 1 ? '' : 's'}`)
216
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
+
217
236
  // Result panel
218
237
  const resultLines = [
219
238
  pc.green(`✓ ${dirs.settingsPath}`),
220
239
  result.scriptCount > 0
221
240
  ? pc.green(`✓ ${result.scriptCount} script${result.scriptCount === 1 ? '' : 's'} written to ${dirs.hooksDir}`)
222
241
  : null,
242
+ testResult?.testCount > 0
243
+ ? pc.green(`✓ ${testResult.testCount} test file${testResult.testCount === 1 ? '' : 's'} written to tests/hooks/`)
244
+ : null,
223
245
  '',
224
246
  `Browse more hooks ${pc.cyan(`${API_BASE}/#catalogue`)}`,
225
247
  `⭐ Star us ${pc.cyan(REPO_URL)}`,
@@ -250,6 +272,15 @@ async function directInstall(slugs, args) {
250
272
  const log = { warn: m => console.warn(` ! ${m}`) }
251
273
  const result = doInstall(hooks, dirs, args.scope, log)
252
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
+ }
253
284
  console.log(`\n✅ ${plural(result.hookCount, 'hook')} installed · star us → ${REPO_URL}`)
254
285
  console.log(' Restart Claude Code to activate.\n')
255
286
  }
@@ -266,6 +297,7 @@ const HELP = `
266
297
  --global, -g Install into ~/.claude instead of ./.claude
267
298
  --copilot Install into ./.claude with paths adapted for GitHub Copilot
268
299
  --scope <s> "project" (default), "global", or "copilot"
300
+ --with-tests Also install vitest unit tests into tests/hooks/ (project scope only)
269
301
  --yes, -y Skip prompts (non-interactive install)
270
302
  --version, -v Show version
271
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 }
@@ -177,6 +179,21 @@ export function shortRepo(url) {
177
179
  .replace(/\/$/, '')
178
180
  }
179
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
+
180
197
  // Display rows for the "Installation Summary" panel.
181
198
  export function buildSummaryRows(hooks, { root }) {
182
199
  return hooks.map(h => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookstack-cli",
3
- "version": "0.1.19",
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": {