Skip to content

fix(hooks): quote $CLAUDE_PROJECT_DIR-prefixed hook commands#2104

Merged
dyoshikawa merged 3 commits into
mainfrom
resolve-issue-2097-quote-project-dir-hook
Jul 1, 2026
Merged

fix(hooks): quote $CLAUDE_PROJECT_DIR-prefixed hook commands#2104
dyoshikawa merged 3 commits into
mainfrom
resolve-issue-2097-quote-project-dir-hook

Conversation

@dyoshikawa

Copy link
Copy Markdown
Owner

Summary

  • Claude Code (and Factory Droid, which shares the same converter) execute hook commands via /bin/sh -c "<command>". The ./$CLAUDE_PROJECT_DIR/-style rewrite emitted an unquoted substitution, so a project path containing a space gets word-split and the hook fails with No such file or directory.
  • Wrap the generated $CLAUDE_PROJECT_DIR/... (and $FACTORY_PROJECT_DIR/...) substitution in double quotes at export time.
  • Teach the import-side stripCommandPrefix to recognize both the new quoted form and the legacy unquoted form, so round-tripping existing generated files keeps working.
  • Manually-authored override commands that already start with $ are left untouched, as before.

Test plan

  • pnpm cicheck (full lint/typecheck/test/docs/gitignore/schema/spelling/secrets suite) passes
  • Added a unit test asserting the exact quoted output for Claude Code
  • Added a unit test asserting the quoted form round-trips back to a ./-relative command on import
  • Existing Claude Code / Factory Droid hook tests and the hooks E2E spec still pass

Closes #2097

Claude Code (and Factory Droid) run hook commands via a shell, so an
unquoted $CLAUDE_PROJECT_DIR/FACTORY_PROJECT_DIR substitution word-splits
when the project path contains a space, breaking the hook. Wrap the
generated substitution in double quotes, and teach the import-side
stripping logic to recognize both the new quoted form and the legacy
unquoted form.

Closes #2097
The previous fix wrapped the entire substituted command (including any
trailing arguments) in one pair of double quotes, turning e.g.
`./script.sh --fix` into a single shell word and breaking hook commands
that take arguments, on both Claude Code and Factory Droid. Quote only
the `$CLAUDE_PROJECT_DIR`/`$FACTORY_PROJECT_DIR` variable itself so a
spaced project path stays one word while trailing arguments remain
outside the quotes and still split normally. Update the import-side
stripping and add regression tests covering argument-bearing commands
for both tools.

@dyoshikawa dyoshikawa left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

The fix correctly resolves the exact reproduction in #2097 (a bare, no-argument dot-relative script with a space in the project path), and the stripCommandPrefix refactor (early-return guard, shared escapedVar) is a nice cleanup. But I think there's a real regression here that's worth fixing before merge: quoting the whole command string instead of just the path segment breaks any hook that has trailing arguments, independent of whether the project path has spaces at all. I verified this empirically with /bin/sh -c — details in the inline comment.

(!converterConfig.prefixDotRelativeCommandsOnly || trimmedCommand.startsWith("."));

// Only the variable itself is quoted (not the whole command) so a project path
// containing a space can't be word-split by the shell, while any trailing

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This quotes the entire remainder of the command, not just the projectDirVar/path token. For a canonical hook command like .rulesync/hooks/lint.sh --fix, this now emits the single string "$CLAUDE_PROJECT_DIR/.rulesync/hooks/lint.sh --fix" (quote characters included). When the target tool runs that via /bin/sh -c "<command>", the double quotes suppress word-splitting across the whole thing, so after $CLAUDE_PROJECT_DIR expansion the shell treats path and arguments as one un-splittable word — argv[0] becomes a literal path containing a space, which doesn't exist, and the hook fails with the same class of error this PR is fixing.

I checked this against a real shell: pre-PR (unquoted), .rulesync/hooks/lint.sh --fix splits correctly into lint.sh + --fix even with no space in the path. Post-PR (quoted), the same command fails with not found. So this trades a narrow bug (spaces in the project path, no-arg commands only) for a broader one (any dot-relative command with trailing arguments, regardless of path spaces) — and issue #2097's own suggested fix called this out explicitly: quote only the $CLAUDE_PROJECT_DIR/<path> token so trailing arguments are preserved outside the quotes.

Worth noting Factory Droid (factorydroid-hooks.ts) doesn't set prefixDotRelativeCommandsOnly, so there shouldPrefix is true for any command not starting with $ — meaning this same failure mode hits a much wider set of commands there, not just dot-relative ones.

None of the new tests cover a command with trailing arguments (both only test bare, no-arg scripts), so this wouldn't be caught by pnpm cicheck as currently written. Adding a case like .rulesync/hooks/lint.sh --fix to the export test, and asserting the quotes wrap only the path (e.g. "$CLAUDE_PROJECT_DIR/.rulesync/hooks/lint.sh" --fix), would both prove the fix and guard against this regressing again.

The Claude Code hooks e2e assertion checked for the literal substring
$CLAUDE_PROJECT_DIR/, which no longer occurs now that only the
variable itself is quoted. Assert on the actual generated command
value directly instead of a JSON.stringify substring match.
@dyoshikawa
dyoshikawa merged commit b590ad0 into main Jul 1, 2026
8 checks passed
@dyoshikawa
dyoshikawa deleted the resolve-issue-2097-quote-project-dir-hook branch July 1, 2026 06:00
@dyoshikawa

Copy link
Copy Markdown
Owner Author

@dyoshikawa Thank you!

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.

Unquoted $CLAUDE_PROJECT_DIR in generated Claude hook commands breaks on project paths with spaces

2 participants