axel-setup 0.2.1 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -9,21 +9,35 @@ Releases are grouped by date and logical scope. npm package releases use semver
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ---
13
+
14
+ ## [0.4.0] 2026-06-28 iTerm2 session theming add-on
15
+
12
16
  ### Added
13
- - `axel-setup review-upgrades` for target-aware inspection of generated upgrade proposals before manual apply.
14
- - `core` install profile as the public safe default for reusable Claude Code setup.
15
- - Release automation now supports npm Trusted Publishing via GitHub Actions OIDC, with an `NPM_TOKEN` provenance fallback for first publish or migration windows.
16
- - `install.sh` curl wrapper for a macOS one-line install path that delegates to the packaged AXEL release.
17
- - Fixture-driven hook harness covering PreToolUse model routing, PostToolUse action logging, and Stop session persistence without a live Claude session.
18
- - `axel-setup metrics [--json]` report for context-budget, usage-monitor, and hook-harness impact signals generated from package assets and public fixtures only.
17
+ - `extras/iterm-theming/` optional add-on (macOS + iTerm2 only): per-session tab color, directory plus git branch badge, `tabname`/`tabcolor` helpers, and an opt-in "Claude" dynamic profile (bigger badge, confirm-before-closing, unlimited scrollback). Installed explicitly via `extras/iterm-theming/install.sh` (idempotent, backs up `~/.zshrc`, ShellCheck-clean). Not run by `bootstrap.sh`, which stays additive to `~/.claude` only.
19
18
 
20
19
  ### Changed
21
- - Default installs now use `--profile core`, keeping `personal` and `full` as explicit choices for fuller local automation.
22
- - Maintainer release notes now document npm verification commands and conservative rollback through fixed patch releases or `latest` dist-tag movement.
20
+ - CI: `actions/checkout` bumped to v7 (#29) and `actions/setup-node` bumped to v6 (#27).
23
21
 
24
- ### Fixed
25
- - Re-running the bootstrap with only `MEMORY.md` in the memory directory no longer exits early.
26
- - Portable targets now generate `axel-upgrades/REVIEW.md` and `MANIFEST.md` when local files differ from the package.
22
+ ---
23
+
24
+ ## [0.3.0] 2026-06-13 security and hooks hardening
25
+
26
+ ### Added
27
+ - `SECURITY.md` with vulnerability scope, reporting channel (GitHub private security advisories), and response commitment.
28
+ - `CODE_OF_CONDUCT.md` adopting Contributor Covenant v2.1.
29
+ - `.github/dependabot.yml` for weekly GitHub Actions dependency updates.
30
+ - `.editorconfig` with consistent LF, UTF-8, final newline, and 2-space indent across shell, JSON, YAML, JS, and Markdown files.
31
+ - `.shellcheckrc` placeholder committing the project to ShellCheck defaults with a comment explaining the policy.
32
+ - README badges (npm version, CI status, license, Node requirement) added just below the H1.
33
+ - README Security section summarising install footprint, permission profile escalation, and vulnerability reporting.
34
+
35
+ ### Changed
36
+ - `install.sh`: default package pinned to `axel-setup@0.3.0` (overridable via `AXEL_SETUP_PACKAGE` env). Prevents unintended upgrades via `@latest` on a curl pipe.
37
+ - `README.md`: `npx axel-setup@0.3.0` is now the primary recommended install path, above the curl wrapper. The curl URL now points to the release tag (`v0.3.0`) instead of `main`.
38
+ - `README.md`: Maintainer Publish Checklist no longer contains a hardcoded personal path; uses `<repo-root>` placeholder.
39
+ - `README.md`: Language/Customization section clarifies that the Spanish default applies only to the generated personal `CLAUDE.md` template, not to the CLI or documentation.
40
+ - `package.json`: version bumped to `0.3.0`.
27
41
 
28
42
  ---
29
43
 
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # AXEL Setup — Claude Code Power Configuration
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/axel-setup.svg)](https://www.npmjs.com/package/axel-setup)
4
+ [![CI](https://github.com/cveralyon/axel-setup/actions/workflows/ci.yml/badge.svg)](https://github.com/cveralyon/axel-setup/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
6
+ [![Node >=18](https://img.shields.io/node/v/axel-setup)](https://www.npmjs.com/package/axel-setup)
7
+
3
8
  **AXEL** = **A**utonomous e**X**celsior **E**ngineering **L**ayer
4
9
 
5
10
  A complete, production-grade configuration package for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) that transforms it into a proactive engineering partner. Includes session persistence, automatic memory, proactive error resolution, a curated suite of specialized agents and slash commands, and a real-time usage monitor.
@@ -46,18 +51,18 @@ See [AXEL Multi-Runtime Roadmap](docs/roadmap/multi-runtime.md) for the adapter
46
51
 
47
52
  ## Quick Start
48
53
 
49
- The npm registry install is the primary path:
54
+ The recommended install path is directly through npx:
50
55
 
51
56
  ```bash
52
- npx axel-setup --dry-run --user-name "Your Name"
57
+ npx axel-setup@0.4.0 --dry-run --user-name "Your Name"
53
58
  ```
54
59
 
55
60
  By default AXEL uses `--profile core`, a public safe Claude Code install that keeps conservative permissions and skips optional side effects such as plugin installation, usage monitor launchd setup, keybindings, and the external GSD installer. Use `--profile personal` or `--profile full` when you explicitly want the fuller local automation setup.
56
61
 
57
- For a macOS one-line installer, use the curl wrapper. It checks for Claude Code, jq, zsh, and Node before delegating to the packaged npm release:
62
+ Alternatively, for a macOS one-line installer, use the pinned curl wrapper. It checks for Claude Code, jq, zsh, and Node before delegating to the packaged npm release:
58
63
 
59
64
  ```bash
60
- curl -fsSL https://raw.githubusercontent.com/cveralyon/axel-setup/main/install.sh | bash -s -- --dry-run --user-name "Your Name"
65
+ curl -fsSL https://raw.githubusercontent.com/cveralyon/axel-setup/v0.4.0/install.sh | bash -s -- --dry-run --user-name "Your Name"
61
66
  ```
62
67
 
63
68
  Before the first npm publish, use the same wrapper against the GitHub package source:
@@ -85,7 +90,7 @@ bash bootstrap.sh --user-name "Your Name"
85
90
  Run publish commands from the repository root, where `package.json` lives:
86
91
 
87
92
  ```bash
88
- cd /Users/cveralyon/axel-onboarding
93
+ cd <repo-root>
89
94
  npm whoami
90
95
  npm run check
91
96
  npm run publish:dry-run
@@ -95,7 +100,7 @@ npm publish --access public
95
100
  From another directory, pass the package path explicitly:
96
101
 
97
102
  ```bash
98
- npm publish /Users/cveralyon/axel-onboarding --access public
103
+ npm publish <repo-root> --access public
99
104
  ```
100
105
 
101
106
  ### Maintainer Release Automation
@@ -185,6 +190,17 @@ bash bootstrap.sh --dry-run
185
190
 
186
191
  **Safe to run multiple times.** The bootstrap is fully additive — it only adds what's missing, proposes upgrades for existing files, and never overwrites your configuration, memory, or CLAUDE.md.
187
192
 
193
+ ### Optional: iTerm2 session theming (macOS)
194
+
195
+ A separate, opt-in add-on under [`extras/iterm-theming/`](extras/iterm-theming/) gives each iTerm2 session a distinct, stable tab color plus a directory and git branch badge, so parallel sessions are easy to tell apart at a glance. It is **not** run by `bootstrap.sh`: it is macOS + iTerm2 only, and it is the one component that writes outside `~/.claude` (to `~/.config/iterm`, `~/.zshrc`, and iTerm2 DynamicProfiles). Install it explicitly:
196
+
197
+ ```bash
198
+ bash extras/iterm-theming/install.sh # install (idempotent, backs up ~/.zshrc)
199
+ bash extras/iterm-theming/install.sh --dry-run # preview, change nothing
200
+ ```
201
+
202
+ See [`extras/iterm-theming/README.md`](extras/iterm-theming/README.md) for what it does, the commands it adds, and how to revert.
203
+
188
204
  ## Prerequisites
189
205
 
190
206
  | Tool | Why | Install |
@@ -438,7 +454,7 @@ Edit `~/.claude/settings.json` and add entries to the relevant event:
438
454
 
439
455
  ### Language
440
456
 
441
- Default is Spanish. Change `"language"` in `settings.json` to your preferred language.
457
+ The default language in the generated `CLAUDE.md` personal template is Spanish. This applies only to the template file written during install — the CLI itself, the documentation, and all hook/agent code are in English. Change `"language"` in `settings.json` to your preferred language for hook output (session summaries, memory extraction, etc.).
442
458
 
443
459
  ### Team CLAUDE.md
444
460
 
@@ -450,6 +466,14 @@ The template at `~/CLAUDE.md` (created only if you don't have one) includes:
450
466
 
451
467
  Customize it with your team's specific repos, conventions, and rules.
452
468
 
469
+ ## Security
470
+
471
+ AXEL writes exclusively to `~/.claude` (hooks, agents, commands, skills, settings). All installs are additive: existing files are never silently overwritten, a backup is proposed before any replacement, and `settings.json` is deep-merged rather than replaced.
472
+
473
+ **Permission profiles:** the default `--profile core` uses `acceptEdits` mode, which requires explicit confirmation before file edits. The `--profile personal` and `--profile full` profiles elevate to `bypassPermissions`, granting the agent broad autonomy. Only use those profiles when you understand and accept the expanded trust boundary.
474
+
475
+ **Reporting vulnerabilities:** please use [GitHub private security advisories](https://github.com/cveralyon/axel-setup/security/advisories/new) to report security issues. See [`SECURITY.md`](./SECURITY.md) for scope, response commitment, and contact details.
476
+
453
477
  ## Contributing
454
478
 
455
479
  Contributions are welcome when they improve AXEL as reusable developer tooling. Start with [`CONTRIBUTING.md`](./CONTRIBUTING.md), open a focused issue when the scope is unclear, and keep private company context out of public examples.
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "package": {
4
4
  "name": "axel-setup",
5
- "version": "0.2.0"
5
+ "version": "0.4.0"
6
6
  },
7
7
  "defaultTarget": "claude",
8
8
  "targets": {
@@ -123,7 +123,7 @@
123
123
  "id": "gsd",
124
124
  "name": "get-shit-done-cc",
125
125
  "managedExternally": true,
126
- "installCommand": "npx -y get-shit-done-cc@latest"
126
+ "installCommand": "npx -y get-shit-done-cc@1.42.3"
127
127
  }
128
128
  ]
129
129
  }
package/bin/axel-setup.js CHANGED
@@ -50,7 +50,7 @@ function parseRuntimeArgs(argv, command) {
50
50
  const arg = argv[index];
51
51
  if (arg === "--home") {
52
52
  requireValue(argv, index, arg);
53
- home = argv[index + 1];
53
+ home = path.resolve(argv[index + 1]);
54
54
  index += 1;
55
55
  } else if (arg === "--target") {
56
56
  requireValue(argv, index, arg);
@@ -58,11 +58,11 @@ function parseRuntimeArgs(argv, command) {
58
58
  index += 1;
59
59
  } else if (arg === "--codex-home") {
60
60
  requireValue(argv, index, arg);
61
- codexHome = argv[index + 1];
61
+ codexHome = path.resolve(argv[index + 1]);
62
62
  index += 1;
63
63
  } else if (arg === "--output") {
64
64
  requireValue(argv, index, arg);
65
- output = argv[index + 1];
65
+ output = path.resolve(argv[index + 1]);
66
66
  index += 1;
67
67
  } else if (arg === "--apply" && command === "uninstall") {
68
68
  apply = true;
@@ -511,8 +511,55 @@ function pruneEmptyParents(startDir, stopDir) {
511
511
  }
512
512
  }
513
513
 
514
+ const DANGEROUS_ROOTS = new Set(["/", "/etc", "/usr", "/bin", "/sbin", "/var", "/tmp"]);
515
+
516
+ function assertSafeInstallRoot(installRoot, apply) {
517
+ if (!apply) {
518
+ return;
519
+ }
520
+
521
+ // Block exact dangerous roots
522
+ if (DANGEROUS_ROOTS.has(installRoot)) {
523
+ console.error(`Refusing to uninstall from dangerous root: ${installRoot}`);
524
+ process.exit(1);
525
+ }
526
+
527
+ // Block exact home directory (subdir is fine: ~/.claude is allowed)
528
+ if (installRoot === os.homedir()) {
529
+ console.error(`Refusing to uninstall from home directory directly: ${installRoot}`);
530
+ process.exit(1);
531
+ }
532
+ }
533
+
534
+ function assertManifestValid(manifestPath, installRoot, apply) {
535
+ if (!apply) {
536
+ return;
537
+ }
538
+
539
+ if (!fs.existsSync(manifestPath)) {
540
+ console.error(`Refusing to uninstall: no axel-manifest.json found at ${manifestPath}`);
541
+ console.error(`This directory does not appear to be an AXEL install root.`);
542
+ process.exit(1);
543
+ }
544
+
545
+ try {
546
+ const manifest = readJson(manifestPath);
547
+ if (!manifest || typeof manifest !== "object" || !manifest.package || !manifest.package.name) {
548
+ throw new Error("Manifest missing required package.name field");
549
+ }
550
+ } catch (err) {
551
+ console.error(`Refusing to uninstall: axel-manifest.json at ${manifestPath} is invalid: ${err.message}`);
552
+ process.exit(1);
553
+ }
554
+ }
555
+
514
556
  function runUninstall(argv) {
515
557
  const { apply, installRoot, manifestPath, target } = resolveRuntime(argv, "uninstall");
558
+
559
+ // Hard guards before any destructive operation
560
+ assertSafeInstallRoot(installRoot, apply);
561
+ assertManifestValid(manifestPath, installRoot, apply);
562
+
516
563
  const manifest = readInstalledManifest(manifestPath, target);
517
564
 
518
565
  if (!manifest) {
package/bootstrap.sh CHANGED
@@ -249,31 +249,53 @@ run() {
249
249
  fi
250
250
  }
251
251
 
252
+ # Escape a value for safe interpolation into the replacement side of a sed
253
+ # `s|...|...|` command. The base template ships safe-by-default; the only place
254
+ # permissions are elevated is settings_for_profile() below, explicitly and
255
+ # visibly. User-supplied values, however, can contain sed metacharacters
256
+ # (`\`, `|`, `&`) that would corrupt the substitution — escape them here.
257
+ sed_escape() {
258
+ # Order matters: escape backslashes first, then the delimiter and `&`.
259
+ printf '%s' "$1" | sed -e 's/[\\]/\\\\/g' -e 's/|/\\|/g' -e 's/&/\\&/g'
260
+ }
261
+
252
262
  settings_for_profile() {
253
263
  local src="$1"
254
264
  local dest="$2"
255
265
 
256
- if [ "$PROFILE" = "personal" ] || [ "$PROFILE" = "full" ]; then
266
+ # The base template (templates/settings.json) is SAFE BY DEFAULT:
267
+ # defaultMode "acceptEdits", no "Bash(*)" in allow, and
268
+ # skipDangerousModePermissionPrompt false. Most profiles ship it verbatim.
269
+ if [ "$PROFILE" != "personal" ] && [ "$PROFILE" != "full" ]; then
257
270
  cp "$src" "$dest"
258
271
  return
259
272
  fi
260
273
 
274
+ # personal/full ELEVATE permissions explicitly and visibly: full autonomy
275
+ # bypass mode, Bash(*) allowance, and the dangerous-mode prompt suppressed.
276
+ # This keeps the distributed file safe while preserving per-profile behavior.
261
277
  jq '
262
- .permissions.defaultMode = "acceptEdits" |
263
- .permissions.allow = ((.permissions.allow // []) | map(select(. != "Bash(*)"))) |
264
- .skipDangerousModePermissionPrompt = false
278
+ .permissions.defaultMode = "bypassPermissions" |
279
+ .permissions.allow = ((.permissions.allow // []) + (if ((.permissions.allow // []) | index("Bash(*)")) then [] else ["Bash(*)"] end)) |
280
+ .skipDangerousModePermissionPrompt = true
265
281
  ' "$src" > "$dest"
266
282
  }
267
283
 
268
284
  write_processed_skill() {
269
285
  local src="$1"
270
286
  local dest="$2"
287
+ local posthog_context assistant_language user_name user_context
288
+
289
+ posthog_context=$(sed_escape "$POSTHOG_PROJECT_CONTEXT")
290
+ assistant_language=$(sed_escape "$ASSISTANT_LANGUAGE")
291
+ user_name=$(sed_escape "$USER_NAME")
292
+ user_context=$(sed_escape "$USER_CONTEXT")
271
293
 
272
294
  sed \
273
- -e "s|{{POSTHOG_PROJECT_CONTEXT}}|$POSTHOG_PROJECT_CONTEXT|g" \
274
- -e "s|{{ASSISTANT_LANGUAGE}}|$ASSISTANT_LANGUAGE|g" \
275
- -e "s|{{USER_NAME}}|$USER_NAME|g" \
276
- -e "s|{{USER_CONTEXT}}|$USER_CONTEXT|g" \
295
+ -e "s|{{POSTHOG_PROJECT_CONTEXT}}|$posthog_context|g" \
296
+ -e "s|{{ASSISTANT_LANGUAGE}}|$assistant_language|g" \
297
+ -e "s|{{USER_NAME}}|$user_name|g" \
298
+ -e "s|{{USER_CONTEXT}}|$user_context|g" \
277
299
  "$src" > "$dest"
278
300
  }
279
301
 
@@ -343,7 +365,11 @@ add_or_upgrade() {
343
365
  cp "$src" "$UPGRADES_DIR/$upgrade_subdir/$(basename "$dest")"
344
366
  fi
345
367
  upgrade "$label"
346
- eval "$upgraded_var=\$((\$$upgraded_var + 1))"
368
+ # Increment the named counter without eval. printf -v writes to the
369
+ # variable named in $upgraded_var; ${!upgraded_var} reads its value.
370
+ # This stays portable to Bash 3.2 (macOS default), where `local -n`
371
+ # namerefs are unavailable.
372
+ printf -v "$upgraded_var" '%s' "$(( ${!upgraded_var} + 1 ))"
347
373
  fi
348
374
  # Same content — nothing to do
349
375
  else
@@ -352,7 +378,7 @@ add_or_upgrade() {
352
378
  else
353
379
  cp "$src" "$dest"
354
380
  fi
355
- eval "$added_var=\$((\$$added_var + 1))"
381
+ printf -v "$added_var" '%s' "$(( ${!added_var} + 1 ))"
356
382
  fi
357
383
  }
358
384
 
@@ -630,11 +656,15 @@ for hook_file in "$SCRIPT_DIR/hooks/"*; do
630
656
 
631
657
  # Hooks need placeholder substitution, so we use a temp file for comparison.
632
658
  # Keep the sed list in sync with any new {{PLACEHOLDER}} added to hook files.
659
+ # User values are escaped so sed metacharacters (\ | &) can't corrupt the sub.
633
660
  PROCESSED=$(mktemp)
661
+ HOOK_USER_NAME=$(sed_escape "$USER_NAME")
662
+ HOOK_USER_CONTEXT=$(sed_escape "$USER_CONTEXT")
663
+ HOOK_ASSISTANT_LANGUAGE=$(sed_escape "$ASSISTANT_LANGUAGE")
634
664
  sed \
635
- -e "s|{{USER_NAME}}|$USER_NAME|g" \
636
- -e "s|{{USER_CONTEXT}}|$USER_CONTEXT|g" \
637
- -e "s|{{ASSISTANT_LANGUAGE}}|$ASSISTANT_LANGUAGE|g" \
665
+ -e "s|{{USER_NAME}}|$HOOK_USER_NAME|g" \
666
+ -e "s|{{USER_CONTEXT}}|$HOOK_USER_CONTEXT|g" \
667
+ -e "s|{{ASSISTANT_LANGUAGE}}|$HOOK_ASSISTANT_LANGUAGE|g" \
638
668
  "$hook_file" > "$PROCESSED"
639
669
 
640
670
  if [ -f "$DEST" ]; then
@@ -915,9 +945,12 @@ if ! $SKIP_MONITOR && [[ "$OSTYPE" == "darwin"* ]]; then
915
945
  info "launchd agent skipped by --no-launchd or profile"
916
946
  elif ! $DRY_RUN; then
917
947
  mkdir -p "$(dirname "$PLIST_DEST")"
918
- sed -e "s|{{USERNAME}}|${USERNAME:-$(whoami)}|g" \
919
- -e "s|{{HOME}}|$HOME|g" \
920
- -e "s|{{NODE_PATH}}|$NODE_BIN|g" \
948
+ PLIST_USERNAME=$(sed_escape "${USERNAME:-$(whoami)}")
949
+ PLIST_HOME=$(sed_escape "$HOME")
950
+ PLIST_NODE_BIN=$(sed_escape "$NODE_BIN")
951
+ sed -e "s|{{USERNAME}}|$PLIST_USERNAME|g" \
952
+ -e "s|{{HOME}}|$PLIST_HOME|g" \
953
+ -e "s|{{NODE_PATH}}|$PLIST_NODE_BIN|g" \
921
954
  "$PLIST_SRC" > "$PLIST_DEST"
922
955
  launchctl load "$PLIST_DEST" 2>/dev/null && \
923
956
  log " Usage monitor started at http://localhost:9119" || \
@@ -12,10 +12,10 @@
12
12
  // as additionalContext, which the agent sees in its conversation
13
13
  //
14
14
  // Thresholds:
15
- // WARNING (remaining <= 35%): Agent should wrap up current task
16
- // CRITICAL (remaining <= 25%): Agent should stop immediately and save state
15
+ // WARNING (remaining <= 15%): Agent should wrap up current task
16
+ // CRITICAL (remaining <= 8%): Agent should stop immediately and save state
17
17
  //
18
- // Debounce: 5 tool uses between warnings to avoid spam
18
+ // Debounce: 10 tool uses between warnings to avoid spam
19
19
  // Severity escalation bypasses debounce (WARNING -> CRITICAL fires immediately)
20
20
 
21
21
  const fs = require('fs');
@@ -27,35 +27,41 @@ case "$TICKET_PATTERN" in "{{"*"}}"|"") TICKET_PATTERN="[A-Z]+-[0-9]+" ;; esac
27
27
  LINEAR_TEAM="{{LINEAR_TEAM}}"
28
28
  case "$LINEAR_TEAM" in "{{"*"}}"|"") LINEAR_TEAM="your team" ;; esac
29
29
 
30
+ # Name of the Linear state for "PR opened, awaiting review". Some teams call it
31
+ # "In Review", others "PR In Review". Bootstrap substitutes the placeholder; the
32
+ # case fallback keeps the script working when run without bootstrap.
33
+ PR_REVIEW_STATE="{{PR_REVIEW_STATE}}"
34
+ case "$PR_REVIEW_STATE" in "{{"*"}}"|"") PR_REVIEW_STATE="In Review" ;; esac
35
+
30
36
  # Guard against recursive invocation (this script spawns claude -p)
31
37
  if [ -n "$CLAUDE_LINEAR_SYNC" ]; then exit 0; fi
32
38
 
33
39
  INPUT=$(cat)
34
- COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
35
- CWD=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null)
40
+ COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
41
+ CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null)
36
42
 
37
43
  # Only run in configured repos (skip if filter is empty — runs everywhere)
38
- if [ -n "$REPO_PATH_FILTER" ] && ! echo "$CWD" | grep -qE "$REPO_PATH_FILTER"; then exit 0; fi
44
+ if [ -n "$REPO_PATH_FILTER" ] && ! printf '%s' "$CWD" | grep -qE "$REPO_PATH_FILTER"; then exit 0; fi
39
45
 
40
46
  # --- Detect action type ---
41
47
  ACTION=""
42
48
  TICKETS=""
43
49
 
44
- if echo "$COMMAND" | grep -qE '(^|[[:space:]&;|(])git[[:space:]]+commit([[:space:]]|$)'; then
50
+ if printf '%s' "$COMMAND" | grep -qE '(^|[[:space:]&;|(])git[[:space:]]+commit([[:space:]]|$)'; then
45
51
  ACTION="in_progress"
46
- TICKETS=$(echo "$COMMAND" | grep -oE "$TICKET_PATTERN" | sort -u | tr '\n' ' ' | sed 's/ $//')
52
+ TICKETS=$(printf '%s' "$COMMAND" | grep -oE "$TICKET_PATTERN" | sort -u | tr '\n' ' ' | sed 's/ $//')
47
53
 
48
- elif echo "$COMMAND" | grep -qE '(^|[[:space:]&;|(])gh[[:space:]]+pr[[:space:]]+create'; then
54
+ elif printf '%s' "$COMMAND" | grep -qE '(^|[[:space:]&;|(])gh[[:space:]]+pr[[:space:]]+create'; then
49
55
  ACTION="in_review"
50
- TICKETS=$(echo "$COMMAND" | grep -oE "$TICKET_PATTERN" | sort -u | tr '\n' ' ' | sed 's/ $//')
56
+ TICKETS=$(printf '%s' "$COMMAND" | grep -oE "$TICKET_PATTERN" | sort -u | tr '\n' ' ' | sed 's/ $//')
51
57
  if [ -z "$TICKETS" ]; then
52
58
  TICKETS=$(git -C "$CWD" log --not --remotes --pretty=format:"%s %b" 2>/dev/null \
53
59
  | grep -oE "$TICKET_PATTERN" | sort -u | tr '\n' ' ' | sed 's/ $//')
54
60
  fi
55
61
 
56
- elif echo "$COMMAND" | grep -qE '(^|[[:space:]&;|(])gh[[:space:]]+pr[[:space:]]+merge'; then
62
+ elif printf '%s' "$COMMAND" | grep -qE '(^|[[:space:]&;|(])gh[[:space:]]+pr[[:space:]]+merge'; then
57
63
  ACTION="done"
58
- TICKETS=$(echo "$COMMAND" | grep -oE "$TICKET_PATTERN" | sort -u | tr '\n' ' ' | sed 's/ $//')
64
+ TICKETS=$(printf '%s' "$COMMAND" | grep -oE "$TICKET_PATTERN" | sort -u | tr '\n' ' ' | sed 's/ $//')
59
65
  if [ -z "$TICKETS" ]; then
60
66
  TICKETS=$(git -C "$CWD" log --not --remotes --pretty=format:"%s %b" 2>/dev/null \
61
67
  | grep -oE "$TICKET_PATTERN" | sort -u | tr '\n' ' ' | sed 's/ $//')
@@ -77,7 +83,7 @@ touch "$RATE_FILE"
77
83
  # --- Determine target state label ---
78
84
  case "$ACTION" in
79
85
  in_progress) TARGET="In Progress" ;;
80
- in_review) TARGET="In Review" ;;
86
+ in_review) TARGET="$PR_REVIEW_STATE" ;;
81
87
  done) TARGET="Done" ;;
82
88
  *) exit 0 ;;
83
89
  esac
@@ -92,8 +98,8 @@ Instructions:
92
98
  1. Call list_issue_statuses to find the state ID for '$TARGET' in the $LINEAR_TEAM team.
93
99
  2. For each ticket, call get_issue to check its current state name.
94
100
  3. Skip rules:
95
- - If target is 'In Progress' and current state is already In Progress, In Review, or Done → skip.
96
- - If target is 'In Review' and current state is already In Review or Done → skip.
101
+ - If target is 'In Progress' and current state is already In Progress, $PR_REVIEW_STATE, or Done → skip.
102
+ - If target is '$PR_REVIEW_STATE' and current state is already $PR_REVIEW_STATE or Done → skip.
97
103
  - If target is 'Done' and current state is already Done → skip.
98
104
  4. For tickets that need updating, call save_issue with the new stateId.
99
105
  5. Output format (one line per ticket):
@@ -103,7 +109,7 @@ Instructions:
103
109
  HOOK_TMP=$(mktemp -d 2>/dev/null)
104
110
  REAL_TMP=$(cd "$HOOK_TMP" 2>/dev/null && pwd -P)
105
111
  RESULT=$(cd "$HOOK_TMP" 2>/dev/null && printf '%s' "$PROMPT" | CLAUDE_LINEAR_SYNC=1 claude -p --model haiku 2>/dev/null)
106
- GHOST_SLUG=$(echo "$REAL_TMP" | sed 's|[^a-zA-Z0-9]|-|g')
112
+ GHOST_SLUG=$(printf '%s' "$REAL_TMP" | sed 's|[^a-zA-Z0-9]|-|g')
107
113
  rm -rf "$HOOK_TMP" "$HOME/.claude/projects/${GHOST_SLUG}" 2>/dev/null
108
114
 
109
115
  mkdir -p "$HOME/.claude/logs"
@@ -1,17 +1,23 @@
1
- #!/bin/zsh
2
- # PostToolUse hook: After a git commit, outputs a reminder for AXEL to
1
+ #!/bin/bash
2
+ # PostToolUse hook (Bash): after a git commit, outputs a reminder for AXEL to
3
3
  # launch excelsior-verifier on the committed files.
4
4
  # This is a lightweight trigger — the actual verification runs as a subagent.
5
+ #
6
+ # Contract: the hook payload arrives as JSON on stdin. PostToolUse does NOT
7
+ # include an exit code, so commit success is inferred from the tool output
8
+ # (absence of common git failure signals).
9
+
10
+ INPUT=$(cat)
11
+ CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
12
+ OUT=$(printf '%s' "$INPUT" | jq -r '.tool_response.text // .tool_response // empty' 2>/dev/null)
5
13
 
6
14
  # Only trigger on Bash tool calls that contain 'git commit'
7
- TOOL_INPUT="${TOOL_INPUT:-}"
8
- if [[ "$TOOL_INPUT" != *"git commit"* ]]; then
15
+ if [[ "$CMD" != *"git commit"* ]]; then
9
16
  exit 0
10
17
  fi
11
18
 
12
- # Check if the commit actually succeeded (exit code 0 from the tool)
13
- TOOL_IS_ERROR="${TOOL_RESULT_IS_ERROR:-0}"
14
- if [[ "$TOOL_IS_ERROR" == "1" ]] || [[ "$TOOL_IS_ERROR" == "true" ]]; then
19
+ # Infer failure from the tool output: if git refused the commit, bail out.
20
+ if printf '%s' "$OUT" | grep -qiE "nothing to commit|no changes added|error:|fatal:|hook declined|rejected"; then
15
21
  exit 0
16
22
  fi
17
23
 
@@ -21,7 +27,7 @@ if [ -z "$CHANGED_FILES" ]; then
21
27
  exit 0
22
28
  fi
23
29
 
24
- FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l | tr -d ' ')
30
+ FILE_COUNT=$(printf '%s' "$CHANGED_FILES" | wc -l | tr -d ' ')
25
31
  COMMIT_MSG=$(git log --oneline -1 2>/dev/null)
26
32
 
27
33
  # Only trigger for non-trivial commits (2+ files or test/feature commits)
@@ -32,7 +38,7 @@ fi
32
38
  # Output advisory to AXEL to launch verifier
33
39
  cat << EOF
34
40
  Post-commit verification advisory: Commit "$COMMIT_MSG" modified $FILE_COUNT files:
35
- $(echo "$CHANGED_FILES" | sed 's/^/ - /')
41
+ $(printf '%s' "$CHANGED_FILES" | sed 's/^/ - /')
36
42
 
37
43
  Consider launching excelsior-verifier as a background agent to verify this commit.
38
44
  Command: Agent({ subagent_type: "excelsior-verifier", run_in_background: true, prompt: "Verify commit: $COMMIT_MSG. Files: $CHANGED_FILES" })
@@ -1,15 +1,9 @@
1
1
  #!/bin/zsh
2
- # PostToolUse hook: Auto-lint/fix files after Edit or Write.
3
- # Uses HOOK_TOOL_INPUT (JSON stdin) for richer context when available,
4
- # falls back to TOOL_INPUT_FILE_PATH env var.
2
+ # PostToolUse hook (Edit|Write): auto-lint/fix files after Edit or Write.
3
+ # Reads the hook payload as JSON from stdin and extracts .tool_input.file_path.
5
4
 
6
- # Try to extract file path from JSON stdin first, then env var
7
- FILE=""
8
- if [ -n "$TOOL_INPUT_FILE_PATH" ]; then
9
- FILE="$TOOL_INPUT_FILE_PATH"
10
- elif [ -n "$HOOK_TOOL_INPUT" ]; then
11
- FILE=$(echo "$HOOK_TOOL_INPUT" | python3 -c "import sys,json; print(json.loads(sys.stdin.read()).get('file_path',''))" 2>/dev/null)
12
- fi
5
+ INPUT=$(cat)
6
+ FILE=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
13
7
 
14
8
  if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then
15
9
  exit 0
@@ -3,19 +3,30 @@
3
3
  # Philosophy: ANY failure that can be auto-resolved SHOULD be auto-resolved.
4
4
  # This hook handles the fast, known patterns. For everything else,
5
5
  # the CLAUDE.md Excelsior principle tells the model to investigate and resolve.
6
-
7
- TOOL_OUTPUT="${TOOL_OUTPUT:-}"
8
- EXIT_CODE="${TOOL_EXIT_CODE:-0}"
9
-
10
- # Only act on failures
11
- [ "$EXIT_CODE" = "0" ] && exit 0
6
+ #
7
+ # Contract: the hook payload arrives as JSON on stdin. PostToolUse does NOT
8
+ # include an exit code, so instead of gating on a status code we act whenever
9
+ # the tool output text contains a known error signal.
10
+
11
+ INPUT=$(cat)
12
+ TOOL_OUTPUT=$(printf '%s' "$INPUT" | jq -r '.tool_response.text // .tool_response // empty' 2>/dev/null)
13
+
14
+ # Nothing to inspect → nothing to do.
15
+ [ -z "$TOOL_OUTPUT" ] && exit 0
16
+
17
+ # Cheap early-out: only proceed if the output looks like it contains an error.
18
+ # This keeps the hook a no-op on successful commands without relying on an
19
+ # exit code (which the PostToolUse contract does not provide).
20
+ if ! printf '%s' "$TOOL_OUTPUT" | grep -qiE "error|refused|not connect|cannot connect|cannot find|no module|not found|does not exist|already in use|pending|failed|denied"; then
21
+ exit 0
22
+ fi
12
23
 
13
24
  RESOLVED=""
14
25
 
15
26
  # === SERVICE DETECTION & AUTO-START ===
16
27
 
17
28
  # Docker (any Docker-related error)
18
- if echo "$TOOL_OUTPUT" | grep -qiE "docker daemon|Cannot connect to the Docker|docker\.sock|Is the docker daemon running|docker:.*command not found"; then
29
+ if printf '%s' "$TOOL_OUTPUT" | grep -qiE "docker daemon|Cannot connect to the Docker|docker\.sock|Is the docker daemon running|docker:.*command not found"; then
19
30
  if [ "$(uname)" = "Darwin" ] && ! docker info >/dev/null 2>&1; then
20
31
  open -a "Docker" 2>/dev/null
21
32
  for i in $(seq 1 15); do docker info >/dev/null 2>&1 && break; sleep 1; done
@@ -28,7 +39,7 @@ if echo "$TOOL_OUTPUT" | grep -qiE "docker daemon|Cannot connect to the Docker|d
28
39
  fi
29
40
 
30
41
  # PostgreSQL (any PG connection error)
31
- if echo "$TOOL_OUTPUT" | grep -qiE "PG::|postgresql.*refused|could not connect.*5432\|could not connect.*5433\|connection to server.*failed.*postgres"; then
42
+ if printf '%s' "$TOOL_OUTPUT" | grep -qiE "PG::|postgresql.*refused|could not connect.*5432|could not connect.*5433|connection to server.*failed.*postgres"; then
32
43
  if [ "$(uname)" = "Darwin" ]; then
33
44
  brew services start postgresql@16 2>/dev/null || brew services start postgresql 2>/dev/null
34
45
  STOPPED_PG=$(docker ps -a --filter "ancestor=postgres" --filter "status=exited" --format "{{.Names}}" 2>/dev/null | head -1)
@@ -38,19 +49,19 @@ if echo "$TOOL_OUTPUT" | grep -qiE "PG::|postgresql.*refused|could not connect.*
38
49
  fi
39
50
 
40
51
  # Redis
41
- if echo "$TOOL_OUTPUT" | grep -qiE "Redis.*refused|ECONNREFUSED.*6379|Redis::CannotConnectError|redis.*not connect"; then
52
+ if printf '%s' "$TOOL_OUTPUT" | grep -qiE "Redis.*refused|ECONNREFUSED.*6379|Redis::CannotConnectError|redis.*not connect"; then
42
53
  brew services start redis 2>/dev/null && RESOLVED="Redis started via Homebrew."
43
54
  fi
44
55
 
45
56
  # MySQL
46
- if echo "$TOOL_OUTPUT" | grep -qiE "mysql.*refused|ECONNREFUSED.*3306|Access denied for user.*mysql"; then
57
+ if printf '%s' "$TOOL_OUTPUT" | grep -qiE "mysql.*refused|ECONNREFUSED.*3306|Access denied for user.*mysql"; then
47
58
  brew services start mysql 2>/dev/null && RESOLVED="MySQL started via Homebrew."
48
59
  fi
49
60
 
50
61
  # === DEPENDENCY RESOLUTION ===
51
62
 
52
63
  # Node modules missing
53
- if echo "$TOOL_OUTPUT" | grep -qiE "Cannot find module|MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND" && [ -f "package.json" ]; then
64
+ if printf '%s' "$TOOL_OUTPUT" | grep -qiE "Cannot find module|MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND" && [ -f "package.json" ]; then
54
65
  if [ -f "pnpm-lock.yaml" ]; then
55
66
  RESOLVED="HINT: Run 'pnpm install' — missing node_modules detected."
56
67
  elif [ -f "yarn.lock" ]; then
@@ -61,12 +72,12 @@ if echo "$TOOL_OUTPUT" | grep -qiE "Cannot find module|MODULE_NOT_FOUND|ERR_MODU
61
72
  fi
62
73
 
63
74
  # Ruby gems missing
64
- if echo "$TOOL_OUTPUT" | grep -qiE "Could not find gem|Bundler::GemNotFound|bundle install|Gem::MissingSpecError"; then
75
+ if printf '%s' "$TOOL_OUTPUT" | grep -qiE "Could not find gem|Bundler::GemNotFound|bundle install|Gem::MissingSpecError"; then
65
76
  RESOLVED="HINT: Run 'bundle install' — missing gems detected."
66
77
  fi
67
78
 
68
79
  # Python deps missing
69
- if echo "$TOOL_OUTPUT" | grep -qiE "ModuleNotFoundError|No module named|ImportError.*No module"; then
80
+ if printf '%s' "$TOOL_OUTPUT" | grep -qiE "ModuleNotFoundError|No module named|ImportError.*No module"; then
70
81
  if [ -f "requirements.txt" ]; then
71
82
  RESOLVED="HINT: Run 'pip install -r requirements.txt' — missing Python module."
72
83
  elif [ -f "pyproject.toml" ]; then
@@ -77,21 +88,21 @@ fi
77
88
  # === ENVIRONMENT ISSUES ===
78
89
 
79
90
  # Port in use — identify the blocker
80
- if echo "$TOOL_OUTPUT" | grep -qiE "EADDRINUSE|Address already in use|port.*already.*use|bind.*address already"; then
81
- PORT=$(echo "$TOOL_OUTPUT" | grep -oE "[0-9]{4,5}" | head -1)
91
+ if printf '%s' "$TOOL_OUTPUT" | grep -qiE "EADDRINUSE|Address already in use|port.*already.*use|bind.*address already"; then
92
+ PORT=$(printf '%s' "$TOOL_OUTPUT" | grep -oE "[0-9]{4,5}" | head -1)
82
93
  [ -n "$PORT" ] && PID=$(lsof -ti ":$PORT" 2>/dev/null | head -1)
83
94
  [ -n "$PID" ] && PROC=$(ps -p "$PID" -o comm= 2>/dev/null)
84
95
  [ -n "$PROC" ] && RESOLVED="HINT: Port $PORT blocked by $PROC (PID $PID). Kill with: kill $PID"
85
96
  fi
86
97
 
87
98
  # Database not created
88
- if echo "$TOOL_OUTPUT" | grep -qiE "database.*does not exist|Unknown database|FATAL.*database.*not exist"; then
89
- DB=$(echo "$TOOL_OUTPUT" | grep -oE '"[^"]*"' | head -1 | tr -d '"')
99
+ if printf '%s' "$TOOL_OUTPUT" | grep -qiE "database.*does not exist|Unknown database|FATAL.*database.*not exist"; then
100
+ DB=$(printf '%s' "$TOOL_OUTPUT" | grep -oE '"[^"]*"' | head -1 | tr -d '"')
90
101
  RESOLVED="HINT: Database '$DB' doesn't exist. Create it: rails db:create or createdb $DB"
91
102
  fi
92
103
 
93
104
  # Migrations pending
94
- if echo "$TOOL_OUTPUT" | grep -qiE "Migrations are pending|pending migration|migrate.*first"; then
105
+ if printf '%s' "$TOOL_OUTPUT" | grep -qiE "Migrations are pending|pending migration|migrate.*first"; then
95
106
  RESOLVED="HINT: Pending migrations. Run: rails db:migrate RAILS_ENV=test"
96
107
  fi
97
108
 
@@ -1,29 +1,32 @@
1
1
  #!/bin/bash
2
- # Log significant tool actions during session for context persistence
3
- # Captures: file edits, bash commands, agent launches — the "what was done"
2
+ # PostToolUse hook: log significant tool actions during the session for context
3
+ # persistence. Captures file edits, bash commands, and agent launches — the
4
+ # "what was done". Reads the hook payload as JSON from stdin.
5
+
6
+ INPUT=$(cat)
4
7
 
5
8
  PROJECT_NAME=$(basename "$(pwd)")
6
9
  SESSION_LOG="/tmp/claude-session-log-${PROJECT_NAME}.md"
7
10
  TIMESTAMP=$(date +%H:%M)
8
11
 
9
- TOOL_NAME_VAR="${TOOL_NAME:-unknown}"
12
+ TOOL_NAME_VAR=$(printf '%s' "$INPUT" | jq -r '.tool_name // "unknown"' 2>/dev/null)
10
13
 
11
14
  # Parse tool input for meaningful context
12
15
  case "$TOOL_NAME_VAR" in
13
16
  Edit|Write)
14
- FILE_PATH=$(echo "$TOOL_INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('file_path',''))" 2>/dev/null)
17
+ FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
15
18
  if [ -n "$FILE_PATH" ]; then
16
19
  echo "- [$TIMESTAMP] **$TOOL_NAME_VAR**: \`$(basename "$FILE_PATH")\`" >> "$SESSION_LOG"
17
20
  fi
18
21
  ;;
19
22
  Bash)
20
- CMD=$(echo "$TOOL_INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('command','')[:120])" 2>/dev/null)
23
+ CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | head -c 120)
21
24
  if [ -n "$CMD" ]; then
22
25
  echo "- [$TIMESTAMP] **Bash**: \`$CMD\`" >> "$SESSION_LOG"
23
26
  fi
24
27
  ;;
25
28
  Agent)
26
- DESC=$(echo "$TOOL_INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('description',''))" 2>/dev/null)
29
+ DESC=$(printf '%s' "$INPUT" | jq -r '.tool_input.description // empty' 2>/dev/null)
27
30
  if [ -n "$DESC" ]; then
28
31
  echo "- [$TIMESTAMP] **Agent**: $DESC" >> "$SESSION_LOG"
29
32
  fi
@@ -1,23 +1,20 @@
1
1
  #!/bin/bash
2
- # Log each user prompt during the session for context persistence
3
- # Appends to a temp file that session-save.sh reads at the end
2
+ # UserPromptSubmit hook: log each user prompt during the session for context
3
+ # persistence. Appends to a temp file that session-save.sh reads at the end.
4
+ # Reads the hook payload as JSON from stdin and extracts .prompt.
5
+
6
+ INPUT=$(cat)
4
7
 
5
8
  PROJECT_NAME=$(basename "$(pwd)")
6
9
  SESSION_LOG="/tmp/claude-session-log-${PROJECT_NAME}.md"
7
10
 
8
- # Read user prompt from stdin (Claude Code passes it as JSON via $PROMPT)
9
- USER_TEXT="$PROMPT"
10
-
11
- if [ -z "$USER_TEXT" ]; then
12
- # Try reading from hook input
13
- USER_TEXT=$(echo "$TOOL_INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('prompt',''))" 2>/dev/null)
14
- fi
11
+ USER_TEXT=$(printf '%s' "$INPUT" | jq -r '.prompt // empty' 2>/dev/null)
15
12
 
16
13
  if [ -n "$USER_TEXT" ]; then
17
14
  TIMESTAMP=$(date +%H:%M)
18
- # Truncate long prompts to keep log manageable
19
- TRUNCATED=$(echo "$USER_TEXT" | head -c 500)
20
- echo "### [$TIMESTAMP] Usuario" >> "$SESSION_LOG"
15
+ # Truncate long prompts to keep the log manageable
16
+ TRUNCATED=$(printf '%s' "$USER_TEXT" | head -c 500)
17
+ echo "### [$TIMESTAMP] User" >> "$SESSION_LOG"
21
18
  echo "$TRUNCATED" >> "$SESSION_LOG"
22
19
  echo "" >> "$SESSION_LOG"
23
20
  fi
@@ -13,7 +13,7 @@ if [ -z "$SESSIONS" ]; then
13
13
  exit 0
14
14
  fi
15
15
 
16
- LATEST=$(echo "$SESSIONS" | head -1)
16
+ LATEST=$(printf '%s' "$SESSIONS" | head -1)
17
17
  SESSION_DATE=$(grep "^date:" "$LATEST" 2>/dev/null | cut -d' ' -f2)
18
18
  SESSION_TIME=$(grep "^time:" "$LATEST" 2>/dev/null | cut -d' ' -f2)
19
19
 
@@ -27,14 +27,19 @@ sed -n '/^---$/,/^---$/!p' "$LATEST" | head -80
27
27
  echo ""
28
28
 
29
29
  # Show previous sessions as one-liners
30
- OTHERS=$(echo "$SESSIONS" | tail -n +2)
30
+ OTHERS=$(printf '%s' "$SESSIONS" | tail -n +2)
31
31
  if [ -n "$OTHERS" ]; then
32
32
  echo "### Sesiones anteriores:"
33
33
  for f in $OTHERS; do
34
34
  DATE=$(grep "^date:" "$f" 2>/dev/null | cut -d' ' -f2)
35
35
  TIME=$(grep "^time:" "$f" 2>/dev/null | cut -d' ' -f2)
36
- # Extract first user prompt as summary
37
- SUMMARY=$(grep -A1 "Usuario" "$f" 2>/dev/null | grep -v "Usuario" | grep -v "^--$" | head -1 | head -c 100)
36
+ # Language-agnostic summary: take the body after the frontmatter, drop the
37
+ # leading H1 title and any markdown headings/blank lines, then keep the
38
+ # first real content line. Works regardless of the summary language.
39
+ SUMMARY=$(sed -n '/^---$/,/^---$/!p' "$f" 2>/dev/null \
40
+ | grep -vE '^[[:space:]]*$|^#' \
41
+ | head -1 \
42
+ | head -c 100)
38
43
  echo "- **$DATE $TIME**: $SUMMARY"
39
44
  done
40
45
  fi
@@ -1,38 +1,40 @@
1
1
  #!/bin/bash
2
- # Validates commit message format: tipo (Modelo/Archivo): Mensaje
3
- # Reads $TOOL_INPUT as JSON, extracts the command, then validates the commit message
2
+ # PreToolUse hook (Bash): validates commit message format before the command runs.
3
+ # Reads the hook payload as JSON from stdin, extracts .tool_input.command,
4
+ # then validates the commit message.
4
5
  # Valid types: feat|fix|chore|refactor|test|docs|style|perf|ci|build|revert
6
+ # Exit codes: 0 = allow, 2 = block (stderr shown to the model).
5
7
 
6
- TOOL_JSON="$TOOL_INPUT"
8
+ INPUT=$(cat)
9
+ CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
7
10
 
8
- # Extract the command field from the Bash tool JSON input
9
- CMD=$(echo "$TOOL_JSON" | jq -r '.command // empty' 2>/dev/null)
10
- [ -z "$CMD" ] && CMD="$TOOL_JSON"
11
+ # Fallback: if stdin was not JSON, treat the whole payload as the command.
12
+ [ -z "$CMD" ] && CMD="$INPUT"
11
13
 
12
14
  # Only check git commit commands
13
- echo "$CMD" | grep -qE 'git commit' || exit 0
15
+ printf '%s' "$CMD" | grep -qE 'git commit' || exit 0
14
16
 
15
17
  # Extract commit message from -m flag
16
18
  # Handles: -m "msg", -m 'msg', -m "$(cat <<'EOF'\nmsg\nEOF\n)"
17
- MSG=$(echo "$CMD" | sed -n 's/.*-m[[:space:]]*["'\'']\{0,1\}\(.*\)/\1/p' | sed 's/["'\'']\{0,1\}[[:space:]]*$//' | head -1)
19
+ MSG=$(printf '%s' "$CMD" | sed -n 's/.*-m[[:space:]]*["'\'']\{0,1\}\(.*\)/\1/p' | sed 's/["'\'']\{0,1\}[[:space:]]*$//' | head -1)
18
20
 
19
21
  # For heredoc pattern: extract the first content line
20
- if echo "$MSG" | grep -q '<<'; then
21
- MSG=$(echo "$CMD" | tr '\n' '|' | sed 's/.*<<[^|]*|//' | sed 's/|.*//' | sed 's/^[[:space:]]*//')
22
+ if printf '%s' "$MSG" | grep -q '<<'; then
23
+ MSG=$(printf '%s' "$CMD" | tr '\n' '|' | sed 's/.*<<[^|]*|//' | sed 's/|.*//' | sed 's/^[[:space:]]*//')
22
24
  fi
23
25
 
24
26
  # If we couldn't extract a message, skip validation (might be --amend or interactive)
25
27
  [ -z "$MSG" ] && exit 0
26
28
 
27
29
  # Validate format: tipo (Scope): message
28
- if ! echo "$MSG" | grep -qE '^(feat|fix|chore|refactor|test|docs|style|perf|ci|build|revert)\s*\('; then
30
+ if ! printf '%s' "$MSG" | grep -qE '^(feat|fix|chore|refactor|test|docs|style|perf|ci|build|revert)\s*\('; then
29
31
  echo "BLOCKED: Commit format is incorrect." >&2
30
32
  echo "Required: type (Scope): Descriptive message" >&2
31
33
  echo "Canonical example: feat (AuthController): add OAuth2 login flow" >&2
32
34
  echo "Valid types: feat|fix|chore|refactor|test|docs|style|perf|ci|build|revert" >&2
33
35
  echo "Do not use --no-verify to bypass this check. Fix the message instead." >&2
34
36
  echo "Got: $MSG" >&2
35
- exit 1
37
+ exit 2
36
38
  fi
37
39
 
38
40
  exit 0
package/install.sh CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env sh
2
2
  set -eu
3
3
 
4
- AXEL_PACKAGE="${AXEL_SETUP_PACKAGE:-axel-setup@latest}"
4
+ AXEL_PACKAGE="${AXEL_SETUP_PACKAGE:-axel-setup@0.4.0}"
5
5
  AXEL_NPX="${AXEL_SETUP_NPX:-npx}"
6
6
 
7
7
  die() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "axel-setup",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Claude Code-first team configuration package with hooks, agents, commands, skills, plugins, and repeatable bootstrap tooling.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -23,11 +23,14 @@
23
23
  ($event): (
24
24
  ($existing_hooks[$event] // []) as $existing_entries |
25
25
  ($axel_hooks[$event] // []) as $axel_entries |
26
- ($existing_entries | map(.hooks[0].command // "") | map(select(. != ""))) as $existing_cmds |
26
+ # Identify each entry by the FULL ordered list of its hook commands, not
27
+ # just the first one. This keeps dedup correct for multi-command entries
28
+ # (two entries that share a first command but differ later are distinct).
29
+ ($existing_entries | map((.hooks // []) | map(.command // ""))) as $existing_cmds |
27
30
  ($axel_entries | map(
28
31
  select(
29
- (.hooks[0].command // "") as $cmd |
30
- ($existing_cmds | map(. == $cmd) | any | not)
32
+ ((.hooks // []) | map(.command // "")) as $cmds |
33
+ ($existing_cmds | map(. == $cmds) | any | not)
31
34
  )
32
35
  )) as $new_entries |
33
36
  $existing_entries + $new_entries
@@ -7,7 +7,6 @@
7
7
  },
8
8
  "permissions": {
9
9
  "allow": [
10
- "Bash(*)",
11
10
  "Read(*)",
12
11
  "Edit(*)",
13
12
  "Write(*)",
@@ -20,7 +19,7 @@
20
19
  "Bash(rm -rf *)",
21
20
  "Bash(dd *)"
22
21
  ],
23
- "defaultMode": "bypassPermissions"
22
+ "defaultMode": "acceptEdits"
24
23
  },
25
24
  "hooks": {
26
25
  "PreToolUse": [
@@ -29,7 +28,7 @@
29
28
  "hooks": [
30
29
  {
31
30
  "type": "command",
32
- "command": "echo \"$TOOL_INPUT\" | grep -qE '\\-\\-no-verify' && echo 'BLOCKED: --no-verify is prohibited' >&2 && exit 2 || exit 0"
31
+ "command": "CMD=$(jq -r '.tool_input.command // empty'); printf '%s' \"$CMD\" | grep -qE '\\-\\-no-verify' && echo 'BLOCKED: --no-verify is prohibited' >&2 && exit 2 || exit 0"
33
32
  }
34
33
  ]
35
34
  },
@@ -47,7 +46,7 @@
47
46
  "hooks": [
48
47
  {
49
48
  "type": "command",
50
- "command": "echo \"$TOOL_INPUT\" | grep -qE 'RAILS_ENV=staging' && echo 'WARNING: staging = PRODUCTION. Confirm with the user.' >&2 && exit 1 || exit 0"
49
+ "command": "CMD=$(jq -r '.tool_input.command // empty'); printf '%s' \"$CMD\" | grep -qE 'RAILS_ENV=staging' && echo 'BLOCKED: staging = PRODUCTION. Confirm with the user before running.' >&2 && exit 2 || exit 0"
51
50
  }
52
51
  ]
53
52
  },
@@ -251,5 +250,5 @@
251
250
  "autoMemoryDirectory": "~/.claude/memory",
252
251
  "autoDreamEnabled": true,
253
252
  "showThinkingSummaries": true,
254
- "skipDangerousModePermissionPrompt": true
253
+ "skipDangerousModePermissionPrompt": false
255
254
  }