hookstack-cli 0.1.16 → 0.1.18
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/bin/cli.mjs +98 -58
- package/bin/core.mjs +11 -7
- package/package.json +2 -1
package/bin/cli.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'fs'
|
|
3
|
-
import { createInterface } from 'readline'
|
|
4
3
|
import { homedir } from 'os'
|
|
5
4
|
import { join, dirname } from 'path'
|
|
6
5
|
import { fileURLToPath } from 'url'
|
|
7
6
|
import pc from 'picocolors'
|
|
7
|
+
import * as p from '@clack/prompts'
|
|
8
8
|
import {
|
|
9
9
|
parseArgs,
|
|
10
10
|
resolveScopeRoot,
|
|
@@ -32,17 +32,18 @@ async function fetchHooks(slugs) {
|
|
|
32
32
|
return res.json()
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
37
|
-
return new Promise(resolve => rl.question(question, a => { rl.close(); resolve(a.trim()) }))
|
|
38
|
-
}
|
|
35
|
+
// ── ASCII banner ──────────────────────────────────────────────────────────────
|
|
39
36
|
|
|
40
|
-
|
|
37
|
+
const BANNER = pc.cyan([
|
|
38
|
+
'██╗ ██╗ ██████╗ ██████╗ ██╗ ██╗███████╗████████╗ █████╗ ██████╗██╗ ██╗',
|
|
39
|
+
'██║ ██║██╔═══██╗██╔═══██╗██║ ██╔╝██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝',
|
|
40
|
+
'███████║██║ ██║██║ ██║█████╔╝ ███████╗ ██║ ███████║██║ █████╔╝ ',
|
|
41
|
+
'██╔══██║██║ ██║██║ ██║██╔═██╗ ╚════██║ ██║ ██╔══██║██║ ██╔═██╗ ',
|
|
42
|
+
'██║ ██║╚██████╔╝╚██████╔╝██║ ██╗███████║ ██║ ██║ ██║╚██████╗██║ ██╗',
|
|
43
|
+
'╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝',
|
|
44
|
+
].join('\n'))
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
const s = String(value ?? '')
|
|
44
|
-
return (s.length > width ? s.slice(0, width - 1) + '…' : s).padEnd(width)
|
|
45
|
-
}
|
|
46
|
+
// ── panel content builders ────────────────────────────────────────────────────
|
|
46
47
|
|
|
47
48
|
export function summaryPanel(rows) {
|
|
48
49
|
const lines = []
|
|
@@ -50,13 +51,19 @@ export function summaryPanel(rows) {
|
|
|
50
51
|
lines.push(pc.cyan(r.path ?? `${r.name} (settings only)`))
|
|
51
52
|
const meta = [
|
|
52
53
|
r.category,
|
|
53
|
-
r.events.join(', ') + (r.blocking ? pc.yellow(' · can block') : ''),
|
|
54
|
+
r.events.join(', ') + (r.blocking ? pc.yellow(' · ⚡ can block') : ''),
|
|
54
55
|
r.matcher ? `matcher: ${r.matcher}` : null,
|
|
55
56
|
].filter(Boolean).join(pc.dim(' · '))
|
|
56
|
-
lines.push(' ' +
|
|
57
|
-
if (r.source) lines.push(
|
|
57
|
+
lines.push(pc.dim(' ' + meta))
|
|
58
|
+
if (r.source) lines.push(pc.dim(` source: ${r.source}`))
|
|
59
|
+
lines.push('')
|
|
58
60
|
}
|
|
59
|
-
return lines.join('\n')
|
|
61
|
+
return lines.join('\n').trimEnd()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function truncPad(value, width) {
|
|
65
|
+
const s = String(value ?? '')
|
|
66
|
+
return (s.length > width ? s.slice(0, width - 1) + '…' : s).padEnd(width)
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
function capCell(on, width) {
|
|
@@ -67,10 +74,10 @@ function capCell(on, width) {
|
|
|
67
74
|
const SNYK_COLOR = { high: pc.red, medium: pc.yellow, low: pc.cyan, safe: pc.green, unknown: pc.dim }
|
|
68
75
|
|
|
69
76
|
export function securityPanel(rows) {
|
|
70
|
-
const W = { name:
|
|
77
|
+
const W = { name: 28, cap: 8, snyk: 10 }
|
|
71
78
|
const header = pc.dim(
|
|
72
79
|
''.padEnd(W.name) + 'Shell'.padEnd(W.cap) + 'Net'.padEnd(W.cap) +
|
|
73
|
-
'Writes'.padEnd(W.cap) + 'Snyk'
|
|
80
|
+
'Writes'.padEnd(W.cap) + 'Snyk',
|
|
74
81
|
)
|
|
75
82
|
const body = rows.map(r =>
|
|
76
83
|
truncPad(r.name, W.name) +
|
|
@@ -89,7 +96,7 @@ export function securityPanel(rows) {
|
|
|
89
96
|
return [header, ...body, footer].join('\n')
|
|
90
97
|
}
|
|
91
98
|
|
|
92
|
-
// ── install
|
|
99
|
+
// ── install ───────────────────────────────────────────────────────────────────
|
|
93
100
|
|
|
94
101
|
function doInstall(hooks, dirs, scope, log) {
|
|
95
102
|
mkdirSync(dirs.claudeDir, { recursive: true })
|
|
@@ -121,69 +128,101 @@ function doInstall(hooks, dirs, scope, log) {
|
|
|
121
128
|
return { hookCount: hooks.length, scriptCount }
|
|
122
129
|
}
|
|
123
130
|
|
|
124
|
-
// ── flows
|
|
131
|
+
// ── flows ─────────────────────────────────────────────────────────────────────
|
|
125
132
|
|
|
126
133
|
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
127
134
|
|
|
135
|
+
function onCancel() {
|
|
136
|
+
p.cancel('Installation cancelled.')
|
|
137
|
+
process.exit(0)
|
|
138
|
+
}
|
|
139
|
+
|
|
128
140
|
async function interactiveInstall(slugs, args) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
console.log(isDefault ? '\nFetching default HookStack…' : `\nFetching ${plural(slugs.length, 'hook')}…`)
|
|
141
|
+
console.log('\n' + BANNER + '\n')
|
|
142
|
+
p.intro(pc.bold('hooks') + pc.dim(` Claude Code hook installer v${VERSION}`))
|
|
132
143
|
|
|
144
|
+
// Fetch
|
|
145
|
+
const spin = p.spinner()
|
|
146
|
+
spin.start(slugs.length === 0 ? 'Fetching default HookStack…' : `Fetching ${plural(slugs.length, 'hook')}…`)
|
|
133
147
|
let data
|
|
134
148
|
try {
|
|
135
149
|
data = await fetchHooks(slugs)
|
|
136
150
|
} catch (e) {
|
|
137
|
-
|
|
151
|
+
spin.stop(pc.red(`Fetch failed: ${e.message}`), 1)
|
|
138
152
|
process.exit(1)
|
|
139
153
|
}
|
|
140
154
|
const { hooks } = data
|
|
141
155
|
const notFound = slugs.filter(slug => !hooks.find(h => h.slug === slug))
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (notFound.length) console.warn(` ! Unknown slugs skipped: ${notFound.join(', ')}`)
|
|
146
|
-
if (hooks.length === 0) {
|
|
147
|
-
console.error('\n✗ No hooks to install.')
|
|
148
|
-
process.exit(1)
|
|
149
|
-
}
|
|
156
|
+
spin.stop(`Selected ${pc.bold(String(hooks.length))} hook${hooks.length === 1 ? '' : 's'}`)
|
|
157
|
+
if (notFound.length) p.log.warn(`Unknown slugs skipped: ${notFound.join(', ')}`)
|
|
158
|
+
if (hooks.length === 0) { p.cancel('No hooks to install.'); process.exit(1) }
|
|
150
159
|
|
|
151
|
-
// Scope
|
|
160
|
+
// Scope
|
|
152
161
|
let scope = args.scope
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
162
|
+
const scopeResult = await p.select({
|
|
163
|
+
message: 'Where to install?',
|
|
164
|
+
options: [
|
|
165
|
+
{
|
|
166
|
+
value: 'project',
|
|
167
|
+
label: 'This project',
|
|
168
|
+
hint: './.claude — committed with your repo',
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
value: 'global',
|
|
172
|
+
label: 'All my projects',
|
|
173
|
+
hint: '~/.claude — every project on this machine',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
value: 'copilot',
|
|
177
|
+
label: 'GitHub Copilot project',
|
|
178
|
+
hint: './.claude — paths adapted for Copilot',
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
initialValue: scope,
|
|
182
|
+
})
|
|
183
|
+
if (p.isCancel(scopeResult)) return onCancel()
|
|
184
|
+
scope = scopeResult
|
|
160
185
|
|
|
161
186
|
const dirs = resolveScopeRoot(scope, { cwd: process.cwd(), home: homedir() })
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
187
|
+
const summaryRows = buildSummaryRows(hooks, { root: dirs.root })
|
|
188
|
+
const secRows = buildSecurityRows(hooks)
|
|
189
|
+
|
|
190
|
+
// Panels
|
|
191
|
+
p.note(summaryPanel(summaryRows), 'Installation Summary')
|
|
192
|
+
p.note(securityPanel(secRows), 'Security Assessment')
|
|
193
|
+
|
|
194
|
+
// Confirm
|
|
195
|
+
const scopeLabel = scope === 'global' ? '~/.claude' : scope === 'copilot' ? './.claude (Copilot mode)' : './.claude'
|
|
196
|
+
const confirmed = await p.confirm({
|
|
197
|
+
message: `Install ${plural(hooks.length, 'hook')} into ${pc.cyan(scopeLabel)}?`,
|
|
198
|
+
})
|
|
199
|
+
if (p.isCancel(confirmed) || !confirmed) return onCancel()
|
|
200
|
+
|
|
201
|
+
// Install
|
|
202
|
+
const spin2 = p.spinner()
|
|
203
|
+
spin2.start('Installing…')
|
|
175
204
|
let result
|
|
176
205
|
try {
|
|
177
|
-
result = doInstall(hooks, dirs, scope, { warn: m =>
|
|
206
|
+
result = doInstall(hooks, dirs, scope, { warn: m => p.log.warn(m) })
|
|
178
207
|
} catch (e) {
|
|
179
|
-
|
|
208
|
+
spin2.stop(pc.red(`Install failed: ${e.message}`), 1)
|
|
180
209
|
process.exit(1)
|
|
181
210
|
}
|
|
211
|
+
spin2.stop(`Installed ${pc.bold(String(result.hookCount))} hook${result.hookCount === 1 ? '' : 's'}`)
|
|
212
|
+
|
|
213
|
+
// Result panel
|
|
214
|
+
const resultLines = [
|
|
215
|
+
pc.green(`✓ ${dirs.settingsPath}`),
|
|
216
|
+
result.scriptCount > 0
|
|
217
|
+
? pc.green(`✓ ${result.scriptCount} script${result.scriptCount === 1 ? '' : 's'} written to ${dirs.hooksDir}`)
|
|
218
|
+
: null,
|
|
219
|
+
'',
|
|
220
|
+
`Browse more hooks ${pc.cyan(`${API_BASE}/#catalogue`)}`,
|
|
221
|
+
`⭐ Star us ${pc.cyan(REPO_URL)}`,
|
|
222
|
+
].filter(v => v !== null).join('\n')
|
|
182
223
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
console.log(` ⭐ star us → ${pc.cyan(REPO_URL)}`)
|
|
186
|
-
console.log(pc.green(`\n✓ ${plural(result.hookCount, 'hook')} installed — restart Claude Code to activate.\n`))
|
|
224
|
+
p.note(resultLines, 'Resume installation')
|
|
225
|
+
p.outro(pc.green('Done!') + pc.dim(" You've just HookStacked your agent workflow."))
|
|
187
226
|
}
|
|
188
227
|
|
|
189
228
|
async function directInstall(slugs, args) {
|
|
@@ -221,7 +260,8 @@ const HELP = `
|
|
|
221
260
|
Options:
|
|
222
261
|
--hooks <slugs> Comma-separated list of hook slugs (omit for default set)
|
|
223
262
|
--global, -g Install into ~/.claude instead of ./.claude
|
|
224
|
-
--
|
|
263
|
+
--copilot Install into ./.claude with paths adapted for GitHub Copilot
|
|
264
|
+
--scope <s> "project" (default), "global", or "copilot"
|
|
225
265
|
--yes, -y Skip prompts (non-interactive install)
|
|
226
266
|
--version, -v Show version
|
|
227
267
|
--help, -h Show this help
|
package/bin/core.mjs
CHANGED
|
@@ -36,6 +36,7 @@ export function parseArgs(argv) {
|
|
|
36
36
|
if (arg === '--yes' || arg === '-y') { result.yes = true; continue }
|
|
37
37
|
if (arg === '--global' || arg === '-g') { result.scope = 'global'; continue }
|
|
38
38
|
if (arg === '--project') { result.scope = 'project'; continue }
|
|
39
|
+
if (arg === '--copilot') { result.scope = 'copilot'; continue }
|
|
39
40
|
if (arg.startsWith('--scope=')) {
|
|
40
41
|
const v = arg.slice('--scope='.length)
|
|
41
42
|
if (v === 'global' || v === 'project') result.scope = v
|
|
@@ -98,7 +99,8 @@ export function mergeHooks(existing, incoming) {
|
|
|
98
99
|
|
|
99
100
|
// Gathers the hook fragments from an API hook list into a single event→entries
|
|
100
101
|
// map. For global scope, rewrites $CLAUDE_PROJECT_DIR to the absolute global
|
|
101
|
-
// root so commands resolve outside any project.
|
|
102
|
+
// root so commands resolve outside any project. For copilot scope, strips
|
|
103
|
+
// $CLAUDE_PROJECT_DIR/ so paths become relative (GitHub Copilot compatible).
|
|
102
104
|
export function collectIncomingHooks(hooks, { scope = 'project', globalRoot } = {}) {
|
|
103
105
|
const incoming = {}
|
|
104
106
|
for (const hook of hooks) {
|
|
@@ -107,14 +109,16 @@ export function collectIncomingHooks(hooks, { scope = 'project', globalRoot } =
|
|
|
107
109
|
for (const [event, entries] of Object.entries(fragment)) {
|
|
108
110
|
incoming[event] ??= []
|
|
109
111
|
for (const entry of entries) {
|
|
110
|
-
const rewrite = scope === 'global' && globalRoot
|
|
111
112
|
incoming[event].push({
|
|
112
113
|
...entry,
|
|
113
|
-
hooks: entry.hooks.map(h =>
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
: h,
|
|
117
|
-
|
|
114
|
+
hooks: entry.hooks.map(h => {
|
|
115
|
+
if (!h.command || typeof h.command !== 'string') return h
|
|
116
|
+
if (scope === 'global' && globalRoot)
|
|
117
|
+
return { ...h, command: h.command.replace(PROJECT_DIR_RE, globalRoot) }
|
|
118
|
+
if (scope === 'copilot')
|
|
119
|
+
return { ...h, command: h.command.replace(/\$\{?CLAUDE_PROJECT_DIR\}?\//g, '') }
|
|
120
|
+
return h
|
|
121
|
+
}),
|
|
118
122
|
})
|
|
119
123
|
}
|
|
120
124
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hookstack-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"description": "CLI installer for the Hookstack catalogue of Claude Code hooks",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"node": ">=18"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
+
"@clack/prompts": "^0.9.1",
|
|
17
18
|
"picocolors": "^1.1.1"
|
|
18
19
|
},
|
|
19
20
|
"keywords": [
|