hookstack-cli 0.1.3 → 0.1.5

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.
Files changed (3) hide show
  1. package/bin/cli.mjs +195 -109
  2. package/bin/core.mjs +191 -0
  3. package/package.json +5 -1
package/bin/cli.mjs CHANGED
@@ -1,162 +1,248 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
3
+ import { homedir } from 'os'
3
4
  import { join } from 'path'
4
-
5
- const API_BASE = 'https://hookstack.vercel.app'
6
- const VERSION = '0.1.0'
7
-
8
- function parseArgs(argv) {
9
- const args = argv.slice(2)
10
- const result = { command: null, hooks: [], help: false, version: false }
11
-
12
- for (let i = 0; i < args.length; i++) {
13
- const arg = args[i]
14
- if (arg === '--help' || arg === '-h') { result.help = true; continue }
15
- if (arg === '--version' || arg === '-v') { result.version = true; continue }
16
- if (arg.startsWith('--hooks=')) {
17
- result.hooks = arg.slice('--hooks='.length).split(',').filter(Boolean)
18
- continue
19
- }
20
- if (arg === '--hooks' && args[i + 1]) {
21
- result.hooks = args[++i].split(',').filter(Boolean)
22
- continue
23
- }
24
- if (!result.command) result.command = arg
25
- }
26
-
27
- return result
28
- }
29
-
30
- function mergeHooks(existing, incoming) {
31
- const merged = JSON.parse(JSON.stringify(existing))
32
- for (const [event, entries] of Object.entries(incoming)) {
33
- merged[event] ??= []
34
- for (const entry of entries) {
35
- const found = merged[event].find(e => (e.matcher ?? '') === (entry.matcher ?? ''))
36
- if (found) {
37
- found.hooks.push(...entry.hooks)
38
- } else {
39
- merged[event].push({ ...entry, hooks: [...entry.hooks] })
40
- }
41
- }
42
- }
43
- return merged
44
- }
5
+ import { fileURLToPath } from 'url'
6
+ import * as p from '@clack/prompts'
7
+ import pc from 'picocolors'
8
+ import {
9
+ parseArgs,
10
+ resolveScopeRoot,
11
+ mergeHooks,
12
+ assertSafeTarget,
13
+ collectIncomingHooks,
14
+ buildSummaryRows,
15
+ buildSecurityRows,
16
+ } from './core.mjs'
17
+
18
+ const API_BASE = process.env.HOOKSTACK_API_BASE || 'https://hookstack.vercel.app'
19
+ const REPO_URL = 'github.com/steve-magne/hookstack'
20
+ const VERSION = '0.1.3'
45
21
 
46
22
  async function fetchHooks(slugs) {
47
- const url = `${API_BASE}/api/hooks?slugs=${slugs.join(',')}`
23
+ const url = `${API_BASE}/api/hooks?slugs=${slugs.map(encodeURIComponent).join(',')}`
48
24
  const res = await fetch(url)
49
25
  if (!res.ok) {
50
- const body = await res.text()
26
+ const body = await res.text().catch(() => '')
51
27
  throw new Error(`API error ${res.status}: ${body}`)
52
28
  }
53
29
  return res.json()
54
30
  }
55
31
 
56
- async function install(slugs, root) {
57
- console.log(`\nFetching ${slugs.length} hook${slugs.length > 1 ? 's' : ''}…`)
32
+ // ── panel rendering ────────────────────────────────────────────────────────
58
33
 
59
- let data
60
- try {
61
- data = await fetchHooks(slugs)
62
- } catch (e) {
63
- console.error(`\n✗ Failed to fetch hooks: ${e.message}`)
64
- process.exit(1)
65
- }
34
+ // Truncate to a visible width then pad — operate on PLAIN text only. Color is
35
+ // applied afterwards so ANSI codes never throw off column alignment.
36
+ function truncPad(value, width) {
37
+ const s = String(value ?? '')
38
+ return (s.length > width ? s.slice(0, width - 1) + '…' : s).padEnd(width)
39
+ }
66
40
 
67
- const { hooks } = data
68
- const notFound = slugs.filter(s => !hooks.find(h => h.slug === s))
69
- if (notFound.length > 0) {
70
- console.warn(` ! Unknown slugs (skipped): ${notFound.join(', ')}`)
71
- }
72
- if (hooks.length === 0) {
73
- console.error('\n✗ No hooks to install.')
74
- process.exit(1)
41
+ export function summaryPanel(rows) {
42
+ const lines = []
43
+ for (const r of rows) {
44
+ lines.push(pc.cyan(r.path ?? `${r.name} (settings only)`))
45
+ const meta = [
46
+ r.category,
47
+ r.events.join(', ') + (r.blocking ? pc.yellow(' · can block') : ''),
48
+ r.matcher ? `matcher: ${r.matcher}` : null,
49
+ ].filter(Boolean).join(pc.dim(' · '))
50
+ lines.push(' ' + pc.dim(meta))
51
+ if (r.source) lines.push(' ' + pc.dim(`source: ${r.source}`))
75
52
  }
53
+ return lines.join('\n')
54
+ }
76
55
 
77
- const claudeDir = join(root, '.claude')
78
- const settingsPath = join(claudeDir, 'settings.json')
79
- const hooksDir = join(claudeDir, 'hooks')
56
+ // Boolean capability cell: pad the plain "yes"/"no" first, then colorize.
57
+ function capCell(on, width) {
58
+ const text = (on ? 'yes' : 'no').padEnd(width)
59
+ return on ? pc.yellow(text) : pc.dim(text)
60
+ }
61
+
62
+ const SNYK_COLOR = { high: pc.red, medium: pc.yellow, low: pc.cyan, safe: pc.green, unknown: pc.dim }
63
+
64
+ export function securityPanel(rows) {
65
+ const W = { name: 26, cap: 8, snyk: 10 }
66
+ const header = pc.dim(
67
+ ''.padEnd(W.name) + 'Shell'.padEnd(W.cap) + 'Net'.padEnd(W.cap) +
68
+ 'Writes'.padEnd(W.cap) + 'Snyk'.padEnd(W.snyk),
69
+ )
70
+ const body = rows.map(r =>
71
+ truncPad(r.name, W.name) +
72
+ capCell(r.shell, W.cap) +
73
+ capCell(r.network, W.cap) +
74
+ capCell(r.fsWrite, W.cap) +
75
+ (SNYK_COLOR[r.snyk.level] ?? pc.dim)(truncPad(r.snyk.label, W.snyk)),
76
+ )
77
+ const anyUnknown = rows.some(r => r.snyk.level === 'unknown')
78
+ const footer = [
79
+ '',
80
+ pc.dim('Shell = runs commands · Net = network access · Writes = filesystem writes'),
81
+ anyUnknown ? pc.dim('Snyk "—" = not scanned yet') : null,
82
+ pc.dim(`Details: ${API_BASE}/hook/<slug>`),
83
+ ].filter(v => v !== null).join('\n')
84
+ return [header, ...body, footer].join('\n')
85
+ }
80
86
 
81
- mkdirSync(claudeDir, { recursive: true })
82
- mkdirSync(hooksDir, { recursive: true })
87
+ // ── install ──────────────────────────────────────────────────────────────
88
+
89
+ function doInstall(hooks, dirs, scope, log) {
90
+ mkdirSync(dirs.claudeDir, { recursive: true })
91
+ mkdirSync(dirs.hooksDir, { recursive: true })
83
92
 
84
93
  let settings = {}
85
- if (existsSync(settingsPath)) {
94
+ if (existsSync(dirs.settingsPath)) {
86
95
  try {
87
- settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
88
- console.log(' Found existing settings.json — merging…')
96
+ settings = JSON.parse(readFileSync(dirs.settingsPath, 'utf8'))
89
97
  } catch {
90
- console.warn(' ! Could not parse settings.json — starting fresh')
91
- }
92
- }
93
-
94
- const incomingHooks = {}
95
- for (const hook of hooks) {
96
- const fragment = hook.config?.hooks
97
- if (!fragment) continue
98
- for (const [event, entries] of Object.entries(fragment)) {
99
- incomingHooks[event] ??= []
100
- incomingHooks[event].push(...entries)
98
+ log.warn('Could not parse existing settings.json — starting fresh')
101
99
  }
102
100
  }
103
101
 
104
- settings.hooks = mergeHooks(settings.hooks ?? {}, incomingHooks)
105
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n')
106
- console.log(' ✓ .claude/settings.json updated')
102
+ const incoming = collectIncomingHooks(hooks, { scope, globalRoot: dirs.root })
103
+ settings.hooks = mergeHooks(settings.hooks ?? {}, incoming)
104
+ writeFileSync(dirs.settingsPath, JSON.stringify(settings, null, 2) + '\n')
107
105
 
108
106
  let scriptCount = 0
109
107
  for (const hook of hooks) {
110
108
  if (!hook.script_path || !hook.code_snippet) continue
111
- const dest = join(root, hook.script_path)
109
+ assertSafeTarget(dirs.root, hook.script_path)
110
+ const dest = join(dirs.root, hook.script_path)
112
111
  mkdirSync(join(dest, '..'), { recursive: true })
113
112
  writeFileSync(dest, hook.code_snippet, 'utf8')
114
113
  scriptCount++
115
- console.log(` ✓ ${hook.script_path}`)
116
114
  }
117
115
 
118
- console.log(`\n✅ Installed ${hooks.length} hook${hooks.length > 1 ? 's' : ''}${scriptCount > 0 ? ` + ${scriptCount} script${scriptCount > 1 ? 's' : ''}` : ''}.`)
119
- console.log(' Restart Claude Code to activate.\n')
116
+ return { hookCount: hooks.length, scriptCount }
120
117
  }
121
118
 
122
- async function main() {
123
- const { command, hooks, help, version } = parseArgs(process.argv)
119
+ // ── flows ──────────────────────────────────────────────────────────────────
124
120
 
125
- if (version) {
126
- console.log(VERSION)
127
- return
121
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
122
+
123
+ async function interactiveInstall(slugs, args) {
124
+ p.intro(pc.bgCyan(pc.black(' hookstack-cli ')))
125
+
126
+ const s = p.spinner()
127
+ s.start(`Fetching ${plural(slugs.length, 'hook')}`)
128
+ let data
129
+ try {
130
+ data = await fetchHooks(slugs)
131
+ } catch (e) {
132
+ s.stop(pc.red('Fetch failed'))
133
+ p.cancel(e.message)
134
+ process.exit(1)
135
+ }
136
+ const { hooks } = data
137
+ const notFound = slugs.filter(slug => !hooks.find(h => h.slug === slug))
138
+ s.stop(`Fetched ${plural(hooks.length, 'hook')}`)
139
+ if (notFound.length) p.log.warn(`Unknown slugs skipped: ${notFound.join(', ')}`)
140
+ if (hooks.length === 0) {
141
+ p.cancel('No hooks to install.')
142
+ process.exit(1)
143
+ }
144
+
145
+ const scope = await p.select({
146
+ message: 'Installation scope',
147
+ initialValue: args.scope,
148
+ options: [
149
+ { value: 'project', label: 'Project', hint: './.claude — committed with your project' },
150
+ { value: 'global', label: 'Global', hint: '~/.claude — every project on this machine' },
151
+ ],
152
+ })
153
+ if (p.isCancel(scope)) { p.cancel('Cancelled.'); process.exit(0) }
154
+
155
+ const dirs = resolveScopeRoot(scope, { cwd: process.cwd(), home: homedir() })
156
+
157
+ p.note(summaryPanel(buildSummaryRows(hooks, { root: dirs.root })), 'Installation Summary')
158
+ p.note(securityPanel(buildSecurityRows(hooks)), 'Security')
159
+
160
+ const ok = await p.confirm({ message: `Install ${plural(hooks.length, 'hook')} into ${scope === 'global' ? '~/.claude' : './.claude'}?` })
161
+ if (p.isCancel(ok) || !ok) { p.cancel('Cancelled.'); process.exit(0) }
162
+
163
+ const s2 = p.spinner()
164
+ s2.start('Installing')
165
+ let result
166
+ try {
167
+ result = doInstall(hooks, dirs, scope, p.log)
168
+ } catch (e) {
169
+ s2.stop(pc.red('Install failed'))
170
+ p.cancel(e.message)
171
+ process.exit(1)
128
172
  }
173
+ s2.stop(`Wrote ${plural(result.scriptCount, 'script')} + patched settings.json`)
129
174
 
130
- if (help || (!command && hooks.length === 0)) {
131
- console.log(`
175
+ p.log.info(`⭐ star us ${pc.cyan(REPO_URL)}`)
176
+ p.outro(pc.green(`✓ Installed ${plural(result.hookCount, 'hook')} — restart Claude Code to activate.`))
177
+ }
178
+
179
+ async function directInstall(slugs, args) {
180
+ console.log(`\nFetching ${plural(slugs.length, 'hook')}…`)
181
+ let data
182
+ try {
183
+ data = await fetchHooks(slugs)
184
+ } catch (e) {
185
+ console.error(`\n✗ Failed: ${e.message}`)
186
+ process.exit(1)
187
+ }
188
+ const { hooks } = data
189
+ const notFound = slugs.filter(slug => !hooks.find(h => h.slug === slug))
190
+ if (notFound.length) console.warn(` ! Unknown slugs (skipped): ${notFound.join(', ')}`)
191
+ if (hooks.length === 0) {
192
+ console.error('\n✗ No hooks to install.')
193
+ process.exit(1)
194
+ }
195
+ const dirs = resolveScopeRoot(args.scope, { cwd: process.cwd(), home: homedir() })
196
+ const log = { warn: m => console.warn(` ! ${m}`) }
197
+ const result = doInstall(hooks, dirs, args.scope, log)
198
+ console.log(` ✓ ${dirs.settingsPath}`)
199
+ console.log(`\n✅ ${plural(result.hookCount, 'hook')} installed · star us → ${REPO_URL}`)
200
+ console.log(' Restart Claude Code to activate.\n')
201
+ }
202
+
203
+ const HELP = `
132
204
  hookstack — Claude Code hook installer
133
205
 
134
206
  Usage:
135
- npx hookstack-cli-cli install --hooks=<slug1>,<slug2>,...
207
+ npx hookstack-cli@latest install --hooks=<slug1>,<slug2>,...
136
208
 
137
209
  Options:
138
210
  --hooks <slugs> Comma-separated list of hook slugs
211
+ --global, -g Install into ~/.claude instead of ./.claude
212
+ --scope <s> "project" (default) or "global"
213
+ --yes, -y Skip prompts (non-interactive install)
139
214
  --version, -v Show version
140
215
  --help, -h Show this help
141
216
 
142
- Browse hooks at https://hookstack.vercel.app
143
- `)
144
- return
217
+ Runs interactively in a terminal; falls back to a direct install when piped
218
+ or when --yes is passed. Browse hooks at https://hookstack.vercel.app
219
+ `
220
+
221
+ async function main() {
222
+ const args = parseArgs(process.argv)
223
+
224
+ if (args.version) { console.log(VERSION); return }
225
+ if (args.help || (!args.command && args.hooks.length === 0)) { console.log(HELP); return }
226
+
227
+ const command = args.command ?? 'install'
228
+ if (command !== 'install') {
229
+ console.error(`✗ Unknown command: ${command}`)
230
+ console.error(' Run --help for usage.')
231
+ process.exit(1)
145
232
  }
146
233
 
147
- if (command === 'install' || command === null) {
148
- if (hooks.length === 0) {
149
- console.error(' No hooks specified. Use --hooks=<slug1>,<slug2>')
150
- console.error(' Browse hooks at https://hookstack.vercel.app')
151
- process.exit(1)
152
- }
153
- await install(hooks, process.cwd())
154
- return
234
+ if (args.hooks.length === 0) {
235
+ console.error('✗ No hooks specified. Use --hooks=<slug1>,<slug2>')
236
+ console.error(' Browse hooks at https://hookstack.vercel.app')
237
+ process.exit(1)
155
238
  }
156
239
 
157
- console.error(`✗ Unknown command: ${command}`)
158
- console.error(' Run --help for usage.')
159
- process.exit(1)
240
+ const interactive = Boolean(process.stdout.isTTY) && !args.yes
241
+ if (interactive) await interactiveInstall(args.hooks, args)
242
+ else await directInstall(args.hooks, args)
160
243
  }
161
244
 
162
- main()
245
+ /* v8 ignore next 3 */
246
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
247
+ main()
248
+ }
package/bin/core.mjs ADDED
@@ -0,0 +1,191 @@
1
+ // Pure, dependency-free logic for the hookstack CLI.
2
+ // Everything here is side-effect free and unit-tested in isolation; the
3
+ // interactive I/O (clack/picocolors, fs, fetch) lives in cli.mjs. This mirrors
4
+ // the project's "pure run() + thin I/O guard" hook convention.
5
+ import { join, resolve, relative, isAbsolute } from 'path'
6
+
7
+ const BLOCKING_EVENTS = new Set([
8
+ 'PreToolUse',
9
+ 'UserPromptSubmit',
10
+ 'PreCompact',
11
+ 'PermissionRequest',
12
+ ])
13
+
14
+ // Matches $CLAUDE_PROJECT_DIR and ${CLAUDE_PROJECT_DIR}.
15
+ export const PROJECT_DIR_RE = /\$\{?CLAUDE_PROJECT_DIR\}?/g
16
+
17
+ function splitList(raw) {
18
+ return raw.split(',').map(s => s.trim()).filter(Boolean)
19
+ }
20
+
21
+ export function parseArgs(argv) {
22
+ const args = argv.slice(2)
23
+ const result = {
24
+ command: null,
25
+ hooks: [],
26
+ help: false,
27
+ version: false,
28
+ scope: 'project',
29
+ yes: false,
30
+ }
31
+
32
+ for (let i = 0; i < args.length; i++) {
33
+ const arg = args[i]
34
+ if (arg === '--help' || arg === '-h') { result.help = true; continue }
35
+ if (arg === '--version' || arg === '-v') { result.version = true; continue }
36
+ if (arg === '--yes' || arg === '-y') { result.yes = true; continue }
37
+ if (arg === '--global' || arg === '-g') { result.scope = 'global'; continue }
38
+ if (arg === '--project') { result.scope = 'project'; continue }
39
+ if (arg.startsWith('--scope=')) {
40
+ const v = arg.slice('--scope='.length)
41
+ if (v === 'global' || v === 'project') result.scope = v
42
+ continue
43
+ }
44
+ if (arg.startsWith('--hooks=')) { result.hooks = splitList(arg.slice('--hooks='.length)); continue }
45
+ if (arg === '--hooks' && args[i + 1]) { result.hooks = splitList(args[++i]); continue }
46
+ if (!result.command) result.command = arg
47
+ }
48
+
49
+ return result
50
+ }
51
+
52
+ // Resolves where .claude/ lives for a given scope. Project → cwd; global → home.
53
+ export function resolveScopeRoot(scope, { cwd, home }) {
54
+ const root = scope === 'global' ? home : cwd
55
+ const claudeDir = join(root, '.claude')
56
+ return {
57
+ scope,
58
+ root,
59
+ claudeDir,
60
+ hooksDir: join(claudeDir, 'hooks'),
61
+ settingsPath: join(claudeDir, 'settings.json'),
62
+ }
63
+ }
64
+
65
+ // Rejects target paths that would escape destDir, even if the registry JSON was
66
+ // tampered with. Adapted from hyperframes' installer.assertSafeTarget.
67
+ export function assertSafeTarget(destDir, target) {
68
+ if (isAbsolute(target)) {
69
+ throw new Error(`Unsafe path "${target}": absolute paths are not allowed.`)
70
+ }
71
+ if (/(^|[/\\])\.\.([/\\]|$)/.test(target)) {
72
+ throw new Error(`Unsafe path "${target}": ".." segments are not allowed.`)
73
+ }
74
+ if (/^[A-Za-z]:[/\\]/.test(target)) {
75
+ throw new Error(`Unsafe path "${target}": Windows drive letters are not allowed.`)
76
+ }
77
+ const resolved = resolve(destDir, target)
78
+ const rel = relative(resolve(destDir), resolved)
79
+ if (rel.startsWith('..') || isAbsolute(rel)) {
80
+ throw new Error(`Unsafe path "${target}": resolves outside ${destDir}.`)
81
+ }
82
+ }
83
+
84
+ // Merges incoming settings.json hook fragments into existing ones, grouping by
85
+ // event then by matcher (no overwrite). Same contract as src/lib/mergeConfig.
86
+ export function mergeHooks(existing, incoming) {
87
+ const merged = structuredClone(existing)
88
+ for (const [event, entries] of Object.entries(incoming)) {
89
+ merged[event] ??= []
90
+ for (const entry of entries) {
91
+ const found = merged[event].find(e => (e.matcher ?? '') === (entry.matcher ?? ''))
92
+ if (found) found.hooks.push(...entry.hooks)
93
+ else merged[event].push({ ...entry, hooks: [...entry.hooks] })
94
+ }
95
+ }
96
+ return merged
97
+ }
98
+
99
+ // Gathers the hook fragments from an API hook list into a single event→entries
100
+ // map. For global scope, rewrites $CLAUDE_PROJECT_DIR to the absolute global
101
+ // root so commands resolve outside any project.
102
+ export function collectIncomingHooks(hooks, { scope = 'project', globalRoot } = {}) {
103
+ const incoming = {}
104
+ for (const hook of hooks) {
105
+ const fragment = hook.config?.hooks
106
+ if (!fragment) continue
107
+ for (const [event, entries] of Object.entries(fragment)) {
108
+ incoming[event] ??= []
109
+ for (const entry of entries) {
110
+ const rewrite = scope === 'global' && globalRoot
111
+ incoming[event].push({
112
+ ...entry,
113
+ hooks: entry.hooks.map(h =>
114
+ rewrite && typeof h.command === 'string'
115
+ ? { ...h, command: h.command.replace(PROJECT_DIR_RE, globalRoot) }
116
+ : h,
117
+ ),
118
+ })
119
+ }
120
+ }
121
+ }
122
+ return incoming
123
+ }
124
+
125
+ export function isBlockingEvent(event) {
126
+ return BLOCKING_EVENTS.has(event)
127
+ }
128
+
129
+ // Honest static read of what a hook's code does — no external service.
130
+ export function analyzeSecurity(codeSnippet) {
131
+ const code = codeSnippet ?? ''
132
+ const has = (...patterns) => patterns.some(re => re.test(code))
133
+ return {
134
+ shell: has(/\b(execSync|execFileSync|execFile|exec|spawnSync|spawn|fork)\s*\(/, /child_process/),
135
+ network: has(
136
+ /\bfetch\s*\(/,
137
+ /['"]node:(https?|net|dgram|dns)['"]/,
138
+ /\brequire\(\s*['"](https?|net|dgram|dns)['"]\s*\)/,
139
+ /\bfrom\s+['"](node:)?https?['"]/,
140
+ ),
141
+ fsWrite: has(
142
+ /\b(writeFileSync|writeFile|appendFileSync|appendFile|rmSync|unlinkSync|unlink|mkdirSync|renameSync|rename|rmdirSync|cpSync)\s*\(/,
143
+ ),
144
+ }
145
+ }
146
+
147
+ // Maps a stored Snyk scan ({high, medium, low}) to a short verdict label.
148
+ // Returns the "unknown" placeholder when no scan data is available yet.
149
+ export function snykVerdict(snyk) {
150
+ if (!snyk || typeof snyk !== 'object') return { label: '—', level: 'unknown' }
151
+ const { high = 0, medium = 0, low = 0 } = snyk
152
+ if (high > 0) return { label: 'High Risk', level: 'high' }
153
+ if (medium > 0) return { label: 'Med Risk', level: 'medium' }
154
+ if (low > 0) return { label: 'Low Risk', level: 'low' }
155
+ return { label: 'Safe', level: 'safe' }
156
+ }
157
+
158
+ export function shortRepo(url) {
159
+ if (!url) return null
160
+ return String(url)
161
+ .replace(/^https?:\/\/github\.com\//, '')
162
+ .replace(/\.git$/, '')
163
+ .replace(/\/$/, '')
164
+ }
165
+
166
+ // Display rows for the "Installation Summary" panel.
167
+ export function buildSummaryRows(hooks, { root }) {
168
+ return hooks.map(h => {
169
+ const events = h.config?.hooks ? Object.keys(h.config.hooks) : []
170
+ return {
171
+ slug: h.slug,
172
+ name: h.name ?? h.slug,
173
+ path: h.script_path ? join(root, h.script_path) : null,
174
+ category: h.category ?? null,
175
+ events,
176
+ blocking: events.some(isBlockingEvent),
177
+ matcher: h.trigger ?? null,
178
+ source: shortRepo(h.community_examples?.[0]?.repo),
179
+ }
180
+ })
181
+ }
182
+
183
+ // Display rows for the "Security" panel: local static capabilities + Snyk verdict.
184
+ export function buildSecurityRows(hooks) {
185
+ return hooks.map(h => ({
186
+ slug: h.slug,
187
+ name: h.name ?? h.slug,
188
+ ...analyzeSecurity(h.code_snippet),
189
+ snyk: snykVerdict(h.security?.snyk),
190
+ }))
191
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookstack-cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "CLI installer for the Hookstack catalogue of Claude Code hooks",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,10 @@
12
12
  "engines": {
13
13
  "node": ">=18"
14
14
  },
15
+ "dependencies": {
16
+ "@clack/prompts": "^1.0.0",
17
+ "picocolors": "^1.1.1"
18
+ },
15
19
  "keywords": [
16
20
  "claude-code",
17
21
  "hooks",