hookstack-cli 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.
Files changed (2) hide show
  1. package/bin/cli.mjs +162 -0
  2. package/package.json +23 -0
package/bin/cli.mjs ADDED
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
3
+ import { join } from 'path'
4
+
5
+ const API_BASE = 'https://claudehooks.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
+ }
45
+
46
+ async function fetchHooks(slugs) {
47
+ const url = `${API_BASE}/api/hooks?slugs=${slugs.join(',')}`
48
+ const res = await fetch(url)
49
+ if (!res.ok) {
50
+ const body = await res.text()
51
+ throw new Error(`API error ${res.status}: ${body}`)
52
+ }
53
+ return res.json()
54
+ }
55
+
56
+ async function install(slugs, root) {
57
+ console.log(`\nFetching ${slugs.length} hook${slugs.length > 1 ? 's' : ''}…`)
58
+
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
+ }
66
+
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)
75
+ }
76
+
77
+ const claudeDir = join(root, '.claude')
78
+ const settingsPath = join(claudeDir, 'settings.json')
79
+ const hooksDir = join(claudeDir, 'hooks')
80
+
81
+ mkdirSync(claudeDir, { recursive: true })
82
+ mkdirSync(hooksDir, { recursive: true })
83
+
84
+ let settings = {}
85
+ if (existsSync(settingsPath)) {
86
+ try {
87
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
88
+ console.log(' Found existing settings.json — merging…')
89
+ } 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)
101
+ }
102
+ }
103
+
104
+ settings.hooks = mergeHooks(settings.hooks ?? {}, incomingHooks)
105
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n')
106
+ console.log(' ✓ .claude/settings.json updated')
107
+
108
+ let scriptCount = 0
109
+ for (const hook of hooks) {
110
+ if (!hook.script_path || !hook.code_snippet) continue
111
+ const dest = join(root, hook.script_path)
112
+ mkdirSync(join(dest, '..'), { recursive: true })
113
+ writeFileSync(dest, hook.code_snippet, 'utf8')
114
+ scriptCount++
115
+ console.log(` ✓ ${hook.script_path}`)
116
+ }
117
+
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')
120
+ }
121
+
122
+ async function main() {
123
+ const { command, hooks, help, version } = parseArgs(process.argv)
124
+
125
+ if (version) {
126
+ console.log(VERSION)
127
+ return
128
+ }
129
+
130
+ if (help || (!command && hooks.length === 0)) {
131
+ console.log(`
132
+ hookstack — Claude Code hook installer
133
+
134
+ Usage:
135
+ npx hookstack-cli-cli install --hooks=<slug1>,<slug2>,...
136
+
137
+ Options:
138
+ --hooks <slugs> Comma-separated list of hook slugs
139
+ --version, -v Show version
140
+ --help, -h Show this help
141
+
142
+ Browse hooks at https://claudehooks.vercel.app
143
+ `)
144
+ return
145
+ }
146
+
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://claudehooks.vercel.app')
151
+ process.exit(1)
152
+ }
153
+ await install(hooks, process.cwd())
154
+ return
155
+ }
156
+
157
+ console.error(`✗ Unknown command: ${command}`)
158
+ console.error(' Run --help for usage.')
159
+ process.exit(1)
160
+ }
161
+
162
+ main()
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "hookstack-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI installer for the Hookstack catalogue of Claude Code hooks",
5
+ "type": "module",
6
+ "bin": {
7
+ "hookstack-cli": "./bin/cli.mjs"
8
+ },
9
+ "files": [
10
+ "bin/"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "keywords": [
16
+ "claude-code",
17
+ "hooks",
18
+ "ai",
19
+ "claude"
20
+ ],
21
+ "license": "MIT",
22
+ "homepage": "https://hookstack.vercel.app"
23
+ }