hookstack-cli 0.1.48 → 0.1.50

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 (4) hide show
  1. package/README.md +41 -19
  2. package/bin/cli +48 -16
  3. package/bin/core.mjs +56 -15
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # hookstack-cli
2
2
 
3
- **Install Claude Code hooks in one command.**
3
+ **Install agentic hooks in one command — Claude Code, OpenAI Codex, or GitHub Copilot.**
4
4
 
5
- [hookstack.app](https://www.hookstack.app) — the community catalogue for Claude Code hooks. Browse, select, and wire them into your project with one command.
5
+ [hookstack.app](https://www.hookstack.app) — the community catalogue for agentic hooks. Browse, select, and wire them into your project with one command. The same hooks install for any of the three supported agents; only the config file format differs.
6
6
 
7
7
  ---
8
8
 
@@ -22,35 +22,57 @@ That's it. The CLI fetches the hooks, shows you what will be installed, and patc
22
22
  npx hookstack-cli@latest install --hooks=<slug1>,<slug2>,...
23
23
 
24
24
  Options:
25
- --hooks <slugs> Comma-separated hook slugs (required)
26
- --global, -g Install into ~/.claude instead of ./.claude
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)
30
- --yes, -y Skip prompts (non-interactive / CI)
31
- --version, -v Print version
32
- --help, -h Show help
25
+ --hooks <slugs> Comma-separated hook slugs (required)
26
+ --project Claude Code, this project ./.claude (default)
27
+ --global, -g Claude Code, all projects ~/.claude
28
+ --codex-project OpenAI Codex, this project — ./.codex/hooks.json (committed)
29
+ --codex-profile OpenAI Codex, all projects ~/.codex/hooks.json
30
+ --copilot GitHub Copilot ./.claude with paths adapted for Copilot
31
+ --scope <s> "project" (default), "global", "copilot",
32
+ "codex-project", or "codex-profile"
33
+ --with-tests Also install vitest unit tests into tests/hooks/ (project scope only)
34
+ --yes, -y Skip prompts (non-interactive / CI)
35
+ --version, -v Print version
36
+ --help, -h Show help
33
37
  ```
34
38
 
39
+ ### Target agents & scopes
40
+
41
+ The hook code is identical across agents — only the config file it's wired into changes. Pick a target with a flag (or via the interactive menu):
42
+
43
+ | Flag | Agent | Scope | Config file | Scripts dir |
44
+ |---|---|---|---|---|
45
+ | `--project` (default) | Claude Code | this project | `.claude/settings.json` | `.claude/hooks/` |
46
+ | `--global`, `-g` | Claude Code | all projects | `~/.claude/settings.json` | `~/.claude/hooks/` |
47
+ | `--codex-project` | OpenAI Codex | this project | `.codex/hooks.json` (committed) | `.codex/hooks/` |
48
+ | `--codex-profile` | OpenAI Codex | all projects | `~/.codex/hooks.json` | `~/.codex/hooks/` |
49
+ | `--copilot` | GitHub Copilot | this project | `.claude/` paths adapted | `.claude/hooks/` |
50
+
51
+ Codex and Claude Code expose the same lifecycle event names (`PreToolUse`, `PostToolUse`, `SessionStart`, `Stop`…), so a HookStack hook is portable between them without any change to the `.mjs` — the CLI just writes the appropriate config format.
52
+
35
53
  ### Interactive mode (default in a terminal)
36
54
 
37
55
  When run in a terminal the CLI opens an interactive prompt:
38
56
 
39
- 1. Fetches the requested hooks from the registry
40
- 2. Shows an **installation summary** (path, category, events, blocking flag)
41
- 3. Shows a **security panel** (shell access · network · filesystem writes · Snyk score)
42
- 4. Asks for confirmation before writing anything
57
+ 1. Asks which **target agent** to install for — the menu order is: This project → All my projects → Codex profile → Codex project → GitHub Copilot
58
+ 2. Fetches the requested hooks from the registry
59
+ 3. Shows an **installation summary** (path, category, events, blocking flag)
60
+ 4. Shows a **security panel** (shell access · network · filesystem writes · Snyk score)
61
+ 5. Asks for confirmation before writing anything
43
62
 
44
63
  ### Non-interactive mode (`--yes` or piped)
45
64
 
46
65
  Skips all prompts — useful in CI or dotfile bootstrap scripts.
47
66
 
48
67
  ```bash
49
- # CI bootstrap
68
+ # CI bootstrap (Claude Code, project)
50
69
  npx hookstack-cli@latest install --hooks=pre-bash-secret-detection,pre-bash-guard-git-push-main --yes --scope=project
51
70
 
52
71
  # CI bootstrap with unit tests (avoids SonarQube gating on new files without tests)
53
72
  npx hookstack-cli@latest install --hooks=pre-bash-secret-detection,pre-bash-guard-git-push-main --yes --with-tests
73
+
74
+ # CI bootstrap for OpenAI Codex (committed ./.codex/hooks.json)
75
+ npx hookstack-cli@latest install --hooks=pre-bash-secret-detection,pre-bash-guard-git-push-main --yes --scope=codex-project
54
76
  ```
55
77
 
56
78
  ---
@@ -59,11 +81,11 @@ npx hookstack-cli@latest install --hooks=pre-bash-secret-detection,pre-bash-guar
59
81
 
60
82
  For each hook the CLI:
61
83
 
62
- - Writes the `.mjs` script to `.claude/hooks/` (or `~/.claude/hooks/` for global scope)
63
- - Patches `.claude/settings.json` to register the hook on the right lifecycle event
84
+ - Writes the `.mjs` script to the scripts directory for the chosen agent (`.claude/hooks/`, `~/.claude/hooks/`, `.codex/hooks/`, or `~/.codex/hooks/`)
85
+ - Patches the agent's config file (`.claude/settings.json` or `.codex/hooks.json`) to register the hook on the right lifecycle event
64
86
  - Optionally writes vitest unit tests to `tests/hooks/` when `--with-tests` is passed (or confirmed interactively)
65
87
 
66
- No new dependencies are added to your project. Hooks are plain Node.js scripts — no SDK, no agent modification.
88
+ The same hook `.mjs` is used regardless of agent — Claude Code and Codex share lifecycle event names, so only the config file format changes. No new dependencies are added to your project. Hooks are plain Node.js scripts — no SDK, no agent modification.
67
89
 
68
90
  ---
69
91
 
@@ -92,7 +114,7 @@ Browse and filter the full catalogue at **[hookstack.app](https://www.hookstack.
92
114
  ## Requirements
93
115
 
94
116
  - Node.js ≥ 18
95
- - Claude Code installed (hooks are wired into its lifecycle)
117
+ - One of the supported agents installed — Claude Code, OpenAI Codex, or GitHub Copilot (hooks are wired into the agent's lifecycle)
96
118
 
97
119
  ---
98
120
 
package/bin/cli CHANGED
@@ -12,9 +12,11 @@ import {
12
12
  mergeHooks,
13
13
  assertSafeTarget,
14
14
  collectIncomingHooks,
15
+ resolveScriptPath,
15
16
  buildSecurityRows,
16
17
  buildPostInstallHints,
17
18
  doInstallTests,
19
+ isGlobalScope,
18
20
  } from './core.mjs'
19
21
 
20
22
  const API_BASE = process.env.HOOKSTACK_API_BASE || 'https://hookstack.vercel.app'
@@ -111,24 +113,31 @@ function doInstall(hooks, dirs, scope, log) {
111
113
  mkdirSync(dirs.claudeDir, { recursive: true })
112
114
  mkdirSync(dirs.hooksDir, { recursive: true })
113
115
 
116
+ const configName = dirs.format === 'codex' ? 'hooks.json' : 'settings.json'
114
117
  let settings = {}
115
118
  if (existsSync(dirs.settingsPath)) {
116
119
  try {
117
120
  settings = JSON.parse(readFileSync(dirs.settingsPath, 'utf8'))
118
121
  } catch {
119
- log.warn('Could not parse existing settings.json — starting fresh')
122
+ log.warn(`Could not parse existing ${configName} — starting fresh`)
120
123
  }
121
124
  }
122
125
 
123
126
  const incoming = collectIncomingHooks(hooks, { scope, globalRoot: dirs.root })
124
- settings.hooks = mergeHooks(settings.hooks ?? {}, incoming)
127
+ if (dirs.format === 'codex') {
128
+ // Codex hooks.json holds events at the top level (no "hooks" wrapper).
129
+ settings = mergeHooks(settings, incoming)
130
+ } else {
131
+ settings.hooks = mergeHooks(settings.hooks ?? {}, incoming)
132
+ }
125
133
  writeFileSync(dirs.settingsPath, JSON.stringify(settings, null, 2) + '\n')
126
134
 
127
135
  let scriptCount = 0
128
136
  for (const hook of hooks) {
129
137
  if (!hook.script_path || !hook.code_snippet) continue
130
- assertSafeTarget(dirs.root, hook.script_path)
131
- const dest = join(dirs.root, hook.script_path)
138
+ const target = resolveScriptPath(hook.script_path, scope)
139
+ assertSafeTarget(dirs.root, target)
140
+ const dest = join(dirs.root, target)
132
141
  mkdirSync(join(dest, '..'), { recursive: true })
133
142
  writeFileSync(dest, hook.code_snippet, 'utf8')
134
143
  scriptCount++
@@ -141,6 +150,15 @@ function doInstall(hooks, dirs, scope, log) {
141
150
 
142
151
  const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
143
152
 
153
+ // Human-readable destination shown in the confirm prompt and result panel.
154
+ const SCOPE_LABELS = {
155
+ project: './.claude',
156
+ global: '~/.claude',
157
+ copilot: './.claude (Copilot mode)',
158
+ 'codex-project': './.codex/hooks.json (Codex)',
159
+ 'codex-profile': '~/.codex/hooks.json (Codex)',
160
+ }
161
+
144
162
  function onCancel() {
145
163
  p.cancel('Installation cancelled.')
146
164
  process.exit(0)
@@ -148,7 +166,7 @@ function onCancel() {
148
166
 
149
167
  async function interactiveInstall(slugs, args) {
150
168
  console.log('\n' + BANNER + '\n')
151
- p.intro(pc.bold('hooks') + pc.dim(` Claude Code hook installer v${VERSION}`))
169
+ p.intro(pc.bold('hooks') + pc.dim(` Agentic hook installer · Claude Code · Codex · Copilot v${VERSION}`))
152
170
 
153
171
  // Fetch
154
172
  const spin = p.spinner()
@@ -173,18 +191,28 @@ async function interactiveInstall(slugs, args) {
173
191
  options: [
174
192
  {
175
193
  value: 'project',
176
- label: 'This project',
177
- hint: './.claude — committed with your repo',
194
+ label: 'Claude Code - This project',
195
+ hint: '<repo>/.claude',
178
196
  },
179
197
  {
180
198
  value: 'global',
181
- label: 'All my projects',
182
- hint: '~/.claude — every project on this machine',
199
+ label: 'Claude Code - Profile',
200
+ hint: '~/.claude/',
201
+ },
202
+ {
203
+ value: 'codex-project',
204
+ label: 'Codex - This project',
205
+ hint: '<repo>/.codex/',
206
+ },
207
+ {
208
+ value: 'codex-profile',
209
+ label: 'Codex - Profile',
210
+ hint: '~/.codex/',
183
211
  },
184
212
  {
185
213
  value: 'copilot',
186
- label: 'GitHub Copilot project',
187
- hint: './.claude — paths adapted for Copilot',
214
+ label: 'GitHub Copilot - This project',
215
+ hint: '<repo>/.claude/ Copilot compatible',
188
216
  },
189
217
  ],
190
218
  initialValue: scope,
@@ -199,7 +227,7 @@ async function interactiveInstall(slugs, args) {
199
227
  p.note(securityPanel(secRows), 'Installation Summary')
200
228
 
201
229
  // Confirm
202
- const scopeLabel = scope === 'global' ? '~/.claude' : scope === 'copilot' ? './.claude (Copilot mode)' : './.claude'
230
+ const scopeLabel = SCOPE_LABELS[scope] ?? './.claude'
203
231
  const confirmed = await p.confirm({
204
232
  message: `Install ${plural(hooks.length, 'hook')} into ${pc.cyan(scopeLabel)}?`,
205
233
  })
@@ -220,7 +248,7 @@ async function interactiveInstall(slugs, args) {
220
248
  // Unit tests prompt (project/copilot scope only)
221
249
  let testResult = null
222
250
  const hooksWithTests = hooks.filter(h => h.test_snippet)
223
- if (scope !== 'global' && hooksWithTests.length > 0) {
251
+ if (!isGlobalScope(scope) && hooksWithTests.length > 0) {
224
252
  const wantTests = await p.confirm({
225
253
  message: `Install unit tests for ${plural(hooksWithTests.length, 'hook')} into ${pc.cyan('tests/hooks/')}? ${pc.dim('(vitest — helps SonarQube gating)')}`,
226
254
  initialValue: true,
@@ -276,7 +304,7 @@ async function directInstall(slugs, args) {
276
304
  const log = { warn: m => console.warn(` ! ${m}`) }
277
305
  const result = doInstall(hooks, dirs, args.scope, log)
278
306
  console.log(` ✓ ${dirs.settingsPath}`)
279
- if (args.withTests && args.scope !== 'global') {
307
+ if (args.withTests && !isGlobalScope(args.scope)) {
280
308
  try {
281
309
  const testResult = doInstallTests(hooks, process.cwd(), { mkdirSync, writeFileSync, join: pathJoin })
282
310
  if (testResult.testCount > 0)
@@ -289,8 +317,9 @@ async function directInstall(slugs, args) {
289
317
  if (hints.length > 0) {
290
318
  for (const h of hints) console.warn(` ⚠ ${h.slug}: ${h.hint}`)
291
319
  }
320
+ const agent = dirs.format === 'codex' ? 'Codex' : 'Claude Code'
292
321
  console.log(`\n✅ ${plural(result.hookCount, 'hook')} installed · star us → ${REPO_URL}`)
293
- console.log(' Restart Claude Code to activate.\n')
322
+ console.log(` Restart ${agent} to activate.\n`)
294
323
  }
295
324
 
296
325
  const HELP = `
@@ -304,7 +333,10 @@ const HELP = `
304
333
  --hooks <slugs> Comma-separated list of hook slugs (omit for default set)
305
334
  --global, -g Install into ~/.claude instead of ./.claude
306
335
  --copilot Install into ./.claude with paths adapted for GitHub Copilot
307
- --scope <s> "project" (default), "global", or "copilot"
336
+ --codex-project Install into ./.codex/hooks.json (OpenAI Codex, this repo)
337
+ --codex-profile Install into ~/.codex/hooks.json (OpenAI Codex, all projects)
338
+ --scope <s> "project" (default), "global", "copilot",
339
+ "codex-project", or "codex-profile"
308
340
  --with-tests Also install vitest unit tests into tests/hooks/ (project scope only)
309
341
  --yes, -y Skip prompts (non-interactive install)
310
342
  --version, -v Show version
package/bin/core.mjs CHANGED
@@ -13,6 +13,19 @@ const BLOCKING_EVENTS = new Set([
13
13
 
14
14
  // Matches $CLAUDE_PROJECT_DIR and ${CLAUDE_PROJECT_DIR}.
15
15
  export const PROJECT_DIR_RE = /\$\{?CLAUDE_PROJECT_DIR\}?/g
16
+ // Matches the "$CLAUDE_PROJECT_DIR/.claude/" prefix — rewritten to ".codex/" for
17
+ // Codex installs so scripts resolve under the Codex agent directory instead.
18
+ const CLAUDE_PREFIX_RE = /\$\{?CLAUDE_PROJECT_DIR\}?\/\.claude\//g
19
+
20
+ // All recognized install scopes. Claude-family: project, global, copilot
21
+ // (settings.json under .claude). Codex-family: codex-project, codex-profile
22
+ // (hooks.json under .codex, events at the top level).
23
+ export const SCOPES = new Set(['project', 'global', 'copilot', 'codex-project', 'codex-profile'])
24
+ const GLOBAL_SCOPES = new Set(['global', 'codex-profile'])
25
+ const CODEX_SCOPES = new Set(['codex-project', 'codex-profile'])
26
+
27
+ export const isGlobalScope = scope => GLOBAL_SCOPES.has(scope)
28
+ export const isCodexScope = scope => CODEX_SCOPES.has(scope)
16
29
 
17
30
  function splitList(raw) {
18
31
  return raw.split(',').map(s => s.trim()).filter(Boolean)
@@ -39,9 +52,11 @@ export function parseArgs(argv) {
39
52
  if (arg === '--global' || arg === '-g') { result.scope = 'global'; continue }
40
53
  if (arg === '--project') { result.scope = 'project'; continue }
41
54
  if (arg === '--copilot') { result.scope = 'copilot'; continue }
55
+ if (arg === '--codex-profile') { result.scope = 'codex-profile'; continue }
56
+ if (arg === '--codex-project') { result.scope = 'codex-project'; continue }
42
57
  if (arg.startsWith('--scope=')) {
43
58
  const v = arg.slice('--scope='.length)
44
- if (v === 'global' || v === 'project') result.scope = v
59
+ if (SCOPES.has(v)) result.scope = v
45
60
  continue
46
61
  }
47
62
  if (arg.startsWith('--hooks=')) { result.hooks = splitList(arg.slice('--hooks='.length)); continue }
@@ -52,16 +67,22 @@ export function parseArgs(argv) {
52
67
  return result
53
68
  }
54
69
 
55
- // Resolves where .claude/ lives for a given scope. Project → cwd; global → home.
70
+ // Resolves where the agent directory lives for a given scope.
71
+ // Project/copilot → cwd; global/codex-profile → home.
72
+ // Claude-family uses .claude/settings.json; Codex-family uses .codex/hooks.json.
73
+ // `claudeDir`/`settingsPath` keys are kept for back-compat (they hold the agent
74
+ // dir and config-file path regardless of which agent it targets).
56
75
  export function resolveScopeRoot(scope, { cwd, home }) {
57
- const root = scope === 'global' ? home : cwd
58
- const claudeDir = join(root, '.claude')
76
+ const root = isGlobalScope(scope) ? home : cwd
77
+ const codex = isCodexScope(scope)
78
+ const agentDir = join(root, codex ? '.codex' : '.claude')
59
79
  return {
60
80
  scope,
61
81
  root,
62
- claudeDir,
63
- hooksDir: join(claudeDir, 'hooks'),
64
- settingsPath: join(claudeDir, 'settings.json'),
82
+ format: codex ? 'codex' : 'claude',
83
+ claudeDir: agentDir,
84
+ hooksDir: join(agentDir, 'hooks'),
85
+ settingsPath: join(agentDir, codex ? 'hooks.json' : 'settings.json'),
65
86
  }
66
87
  }
67
88
 
@@ -99,10 +120,27 @@ export function mergeHooks(existing, incoming) {
99
120
  return merged
100
121
  }
101
122
 
123
+ // Rewrites a hook command's path for the target scope:
124
+ // - global → $CLAUDE_PROJECT_DIR ↦ absolute global root (.claude stays)
125
+ // - copilot → strips $CLAUDE_PROJECT_DIR/ (relative, Copilot compatible)
126
+ // - codex-project → "$CLAUDE_PROJECT_DIR/.claude/" ↦ ".codex/" (relative)
127
+ // - codex-profile → "$CLAUDE_PROJECT_DIR/.claude/" ↦ "<home>/.codex/" (absolute)
128
+ function rewriteCommand(command, scope, globalRoot) {
129
+ if (scope === 'global' && globalRoot)
130
+ return command.replace(PROJECT_DIR_RE, globalRoot)
131
+ if (scope === 'copilot')
132
+ return command.replace(/\$\{?CLAUDE_PROJECT_DIR\}?\//g, '')
133
+ if (scope === 'codex-project')
134
+ return command.replace(CLAUDE_PREFIX_RE, '.codex/')
135
+ if (scope === 'codex-profile' && globalRoot)
136
+ return command.replace(CLAUDE_PREFIX_RE, `${globalRoot}/.codex/`)
137
+ return command
138
+ }
139
+
102
140
  // Gathers the hook fragments from an API hook list into a single event→entries
103
- // map. For global scope, rewrites $CLAUDE_PROJECT_DIR to the absolute global
104
- // root so commands resolve outside any project. For copilot scope, strips
105
- // $CLAUDE_PROJECT_DIR/ so paths become relative (GitHub Copilot compatible).
141
+ // map, rewriting command paths per scope (see rewriteCommand). The resulting map
142
+ // is identical in shape for both Claude (settings.hooks) and Codex (top-level
143
+ // hooks.json) only doInstall decides how to nest it on disk.
106
144
  export function collectIncomingHooks(hooks, { scope = 'project', globalRoot } = {}) {
107
145
  const incoming = {}
108
146
  for (const hook of hooks) {
@@ -115,11 +153,7 @@ export function collectIncomingHooks(hooks, { scope = 'project', globalRoot } =
115
153
  ...entry,
116
154
  hooks: entry.hooks.map(h => {
117
155
  if (!h.command || typeof h.command !== 'string') return h
118
- if (scope === 'global' && globalRoot)
119
- return { ...h, command: h.command.replace(PROJECT_DIR_RE, globalRoot) }
120
- if (scope === 'copilot')
121
- return { ...h, command: h.command.replace(/\$\{?CLAUDE_PROJECT_DIR\}?\//g, '') }
122
- return h
156
+ return { ...h, command: rewriteCommand(h.command, scope, globalRoot) }
123
157
  }),
124
158
  })
125
159
  }
@@ -128,6 +162,13 @@ export function collectIncomingHooks(hooks, { scope = 'project', globalRoot } =
128
162
  return incoming
129
163
  }
130
164
 
165
+ // Maps a hook's script_path to its on-disk destination for the target scope.
166
+ // Codex installs relocate scripts from .claude/hooks/ to .codex/hooks/.
167
+ export function resolveScriptPath(scriptPath, scope) {
168
+ if (isCodexScope(scope)) return scriptPath.replace(/^\.claude\//, '.codex/')
169
+ return scriptPath
170
+ }
171
+
131
172
  export function isBlockingEvent(event) {
132
173
  return BLOCKING_EVENTS.has(event)
133
174
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hookstack-cli",
3
- "version": "0.1.48",
3
+ "version": "0.1.50",
4
4
  "description": "CLI installer for the Hookstack catalogue of Claude Code hooks",
5
5
  "type": "module",
6
6
  "bin": {