Skip to content

Support non-bash hook interpreters for pack hooks - #368

Merged
bguidolim merged 4 commits into
mainfrom
bruno/hook-interpreters
Sep 2, 2026
Merged

Support non-bash hook interpreters for pack hooks#368
bguidolim merged 4 commits into
mainfrom
bruno/hook-interpreters

Conversation

@bguidolim

Copy link
Copy Markdown
Collaborator

Summary

Pack hooks could only run under bash. The registered command was hardcoded to bash <path>, so a pack shipping a .js or .ts hook had to either add a wrapper script that execs the real interpreter, or hand-write the settings entry itself and give up doctor verification. Packs can now declare the interpreter, or let the file extension imply it.

Changes

  • Hook components accept hookInterpreter — a bare command, an absolute path, or a command with arguments such as node --experimental-strip-types --disable-warning=ExperimentalWarning. Left unset, the interpreter follows the file extension (.js → node, .py → python3, .sh and extensionless → bash). TypeScript infers nothing on purpose, since node's flags, tsx, bun and deno are all plausible and guessing wrong produces a hook that dies at runtime.
  • Removing a pack now clears its hook entry from global settings whatever the interpreter. Previously only entries beginning bash were recognised as ours, so a non-bash entry survived unconfigure and kept firing at a deleted script. This also covers entries carrying trailing arguments, which the old prefix match missed.
  • mcs doctor checks that each pack's interpreter binary resolves, and warns when it resolves only through a version manager — a path that works in your terminal but often not in the environment Claude Code hands its hooks. mcs pack validate warns when no brew component installs the runtime, when a TypeScript hook declares no interpreter, and when a hookEventExists check asserts an interpreter its own component doesn't use.
  • mcs export carries a non-default interpreter back into the generated manifest rather than re-emitting the hook under bash. Manifest errors raised during decoding — an unknown hookEvent, or hook metadata without hookEvent — now surface their actual explanation instead of Foundation's generic "the data couldn't be read".

Test plan

  • swift test passes locally
  • swiftformat --lint . and swiftlint pass without violations
  • Affected commands verified with a real pack (e.g. mcs sync, mcs doctor)

mcs pack validate was exercised against real scratch packs; mcs sync and mcs doctor were covered by sandboxed integration tests rather than a live run, because NSHomeDirectory() ignores $HOME and a live run would write the developer's own ~/.claude and ~/.mcs. Worth a live pass before merge.

To verify by hand:

  1. Add a pack with three hook components — one .ts with hookInterpreter: node --experimental-strip-types, one .js with no interpreter, one .sh — then run mcs sync → expect three entries in .claude/settings.local.json reading node --experimental-strip-types …/gate.ts, node …/fmt.js and bash …/legacy.sh.
  2. Run mcs doctor → expect one interpreter check per distinct binary, passing where the runtime is installed.
  3. Deselect the pack with mcs sync → expect all three entries and their files gone.
  4. Repeat 1–3 with --global and confirm the non-bash entries are gone from ~/.claude/settings.json; this is the case that previously orphaned.
  5. Make the .ts hook write a marker file and start a Claude Code session → expect the marker. An empty hook log is indistinguishable from success, so a side effect is the only real proof it fires.
Checklist for engine changes
  • Any fix() implementation does cleanup/migration only — never installs or registers resources
  • Integration tests updated for new features (LifecycleIntegrationTests or DoctorRunnerIntegrationTests)
  • Docs updated if behavior changed (CLAUDE.md, docs/, techpack.yaml schema in ExternalPackManifest.swift)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Interpreter trust tracking, command validation, cleanup ownership, and export discovery contain unresolved security and correctness issues.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds configurable and inferred hook interpreters across synchronization, validation, export, doctor checks, and documentation.

Changes:

  • Adds interpreter resolution, validation, command parsing, and manifest support.
  • Updates sync cleanup, export, diagnostics, and pack heuristics.
  • Adds unit and lifecycle coverage plus schema documentation.
File summaries
File Description
Sources/mcs/TechPack/HookInterpreter.swift Implements interpreter handling.
Sources/mcs/TechPack/Component.swift Composes interpreter-aware hook commands.
Sources/mcs/Sync/SyncStrategy.swift Displays non-default interpreters.
Sources/mcs/Sync/SyncScope.swift Separates hook directories from interpreters.
Sources/mcs/Sync/ProjectSyncStrategy.swift Uses interpreter-aware project commands.
Sources/mcs/Sync/GlobalSyncStrategy.swift Updates global composition and cleanup.
Sources/mcs/Sync/ConfiguratorSupport.swift Composes and reports hook commands.
Sources/mcs/Sync/Configurator.swift Removes interpreter-aware artifacts.
Sources/mcs/ExternalPack/PackHeuristics.swift Adds hook runtime heuristics.
Sources/mcs/ExternalPack/ExternalPackManifest.swift Adds manifest decoding and validation.
Sources/mcs/ExternalPack/ExternalPackLoader.swift Sanitizes values and improves errors.
Sources/mcs/Export/ManifestBuilder.swift Exports interpreter metadata.
Sources/mcs/Export/ConfigurationDiscovery.swift Recovers interpreters from settings.
Sources/mcs/Doctor/DoctorRunner.swift Adds interpreter checks.
Sources/mcs/Doctor/CoreDoctorChecks.swift Verifies interpreter resolution.
Sources/mcs/Core/ShellRunner.swift Returns resolved executable paths.
Sources/mcs/Core/Constants.swift Defines hook command components.
Tests/MCSTests/PackHeuristicsTests.swift Tests runtime heuristics.
Tests/MCSTests/ManifestBuilderTests.swift Tests export round-tripping.
Tests/MCSTests/LifecycleIntegrationTests.swift Tests hook lifecycle behavior.
Tests/MCSTests/HookInterpreterTests.swift Tests interpreter utilities.
Tests/MCSTests/GlobalSyncTests.swift Tests global cleanup.
Tests/MCSTests/ExternalPackManifestTests.swift Tests manifest behavior.
Tests/MCSTests/CoreDoctorCheckTests.swift Tests interpreter diagnostics.
Tests/MCSTests/ConfigurationDiscoveryTests.swift Tests interpreter discovery.
Tests/MCSTests/ComponentTests.swift Tests command composition.
skills/techpack-creator/SKILL.md Documents interpreter authoring.
skills/techpack-creator/references/techpack-schema.md Extends the skill schema.
skills/techpack-creator/references/stack-detection.md Updates hook guidance.
skills/techpack-creator/references/examples/node-web.yaml Adds a JavaScript hook example.
docs/troubleshooting.md Adds runtime troubleshooting.
docs/techpack-schema.md Documents the public schema.
docs/creating-tech-packs.md Updates pack creation guidance.
docs/architecture.md Describes interpreter architecture.
CLAUDE.md Updates repository guidance.
Review details
  • Files reviewed: 35/35 changed files
  • Comments generated: 8
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Sources/mcs/ExternalPack/ExternalPackManifest.swift
Comment thread Sources/mcs/TechPack/HookInterpreter.swift
Comment thread Sources/mcs/Export/ConfigurationDiscovery.swift
Comment thread Sources/mcs/ExternalPack/PackHeuristics.swift Outdated
Comment thread Sources/mcs/ExternalPack/PackHeuristics.swift Outdated
Comment thread Sources/mcs/Sync/GlobalSyncStrategy.swift
Comment thread Sources/mcs/TechPack/HookInterpreter.swift
Comment thread Sources/mcs/TechPack/HookInterpreter.swift Outdated
- Resolve a hook's interpreter from `hookInterpreter` or the file extension, defaulting to bash
- Recognise managed hook entries by path token so non-bash hooks are cleaned up on removal
- Verify interpreter binaries in doctor, and warn at pack-validate on undeclared runtimes
- Reject control characters in `hookInterpreter`, which split away as token whitespace but survive into the command as a shell separator
- Bring a non-default interpreter into the pack trust surface, so changing it forces renewed trust even when the script is unchanged
- Discover hooks in namespaced subdirectories on export, look through `env` when verifying a binary, and stop two heuristics producing false warnings
@bguidolim
bguidolim force-pushed the bruno/hook-interpreters branch from a96f674 to 3afccc5 Compare September 2, 2026 13:06
@bguidolim
bguidolim requested a balanced review from Copilot September 2, 2026 13:09
- Replace the blanket no-amend/no-force-push rules with the actual preference: rebase onto main for linear history, `--force-with-lease` on your own branch
- Note that CI takes Homebrew's latest while a local install from another manager can shadow it, and name the stale-install symptom

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Trust downgrade handling and several validation/export edge cases remain incorrect.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

Sources/mcs/Export/ConfigurationDiscovery.swift:323

  • Discarding the recursive relative path makes hook correlation ambiguous: discoverFiles later uses command.contains(filename), so gate.ts can pick up the event/interpreter for pre-gate.ts depending on dictionary order. Settings commands reference the full namespaced path, not merely the basename. Preserve each path relative to hooksDir and match the exact managed path token before flattening the exported destination.
    /// A flat listing misses every hook mcs itself placed: `DestinationCollisionResolver` always
    /// namespaces hooks, so a synced hook never sits at the top level. Files are returned by
    /// basename, which is what a settings command references and what the exported manifest uses
    /// as its destination — so a basename appearing twice is reported and skipped rather than
  • Files reviewed: 37/37 changed files
  • Comments generated: 6
  • Review effort level: Balanced

Comment thread Sources/mcs/ExternalPack/PackHeuristics.swift Outdated
Comment thread Sources/mcs/ExternalPack/PackHeuristics.swift Outdated
Comment thread Sources/mcs/ExternalPack/PackHeuristics.swift Outdated
Comment thread Sources/mcs/ExternalPack/PackTrustManager.swift Outdated
Comment thread Sources/mcs/ExternalPack/PackTrustManager.swift Outdated
Comment thread Sources/mcs/ExternalPack/PackTrustManager.swift Outdated
- Give the manifest model one hook-eligibility rule so validation and trust stop reporting hooks sync never registers
- Detect an interpreter being dropped back to bash, and key interpreter trust by component ID rather than display name
- Correlate exported hooks on the full managed path, and compare asserted interpreters instead of prefix-testing for bash
@bguidolim

Copy link
Copy Markdown
Collaborator Author

Addressed the suppressed finding too (ConfigurationDiscovery, hook correlation), since it was a real bug rather than noise — noting it here because it had no thread to reply on.

Correlation now matches the full managed path token instead of command.contains(filename), so gate.ts can no longer pick up the event and interpreter belonging to pre-gate.ts. Regression test covers exactly that pair.

Worth recording what surfaced while fixing it: my first attempt derived the relative path by subtracting hooksDir from the enumerated absolute path, and every correlation broke, because FileManager's enumerators return symlink-resolved paths (/private/var/… for a /var/… base). It now uses subpathsOfDirectory, which yields relative paths directly. That failure mode was not test-only: anyone whose ~/.claude is a symlink — ordinary in dotfile setups — would have had mcs export silently drop every hook registration.

@bguidolim
bguidolim merged commit 5971e7a into main Sep 2, 2026
4 checks passed
@bguidolim
bguidolim deleted the bruno/hook-interpreters branch September 2, 2026 13:38
bguidolim added a commit that referenced this pull request Sep 2, 2026
- Resolve the LifecycleIntegrationTests conflict by keeping both appended
  suites: HookInterpreterLifecycleTests from #368 and ScopeDuplicationCheckTests
- Reword the duplication rationale for the interpreter change: only the hook
  directory is scope-dependent, so the two entries are still distinct

Claude-Session: https://claude.ai/code/session_01MkBJdGBUoZ2aRtchjaKXMN
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants