Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions loopx/claude_goal_mode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,32 @@ UTF-8) an unpinned gate would decode the event into mojibake, miss the project
goal and answer `{}` — no gate at all — and `/loopx status` would raise on its own
state glyphs. `tests/test_claude_goal_mode_stdio_utf8.py` guards all three.

Claude Code also runs those baked command strings through a shell - `/bin/sh`
on macOS/Linux, Git Bash on Windows when it is installed, PowerShell on a
Windows host without Git Bash (hooks reference, "Exec form and shell form";
Claude Code switched Windows hooks from `cmd.exe` to Git Bash in 2.1.47 and
added the PowerShell fallback in 2.1.120). The installer detects that same
choice and encodes every path it bakes - the interpreter and the script it
starts - as one literal token for it: `shlex.quote`'s single-quoted form under
`/bin/sh` and Git Bash, a single-quoted `& '...'` token under PowerShell. A
Windows path is always quoted, because Git Bash eats the backslashes of a bare
`C:\Users\me\Python\python.exe`. Unencoded, the shell splits the path at the
first space, expands `$NAME`, drops backslashes, or closes the token at an
inner `"`: the `--harden` gate fails OPEN, the statusline renders nothing and
`/loopx` never runs. Two boundaries stay explicit: the `/loopx` `!` line runs
through the Bash tool, so it is always POSIX-encoded and a Windows host without
Git Bash cannot run it at all; and the shell choice is made when the installer
runs - installing or removing Git Bash later, or pointing Claude Code at one
with `CLAUDE_CODE_GIT_BASH_PATH` in an environment the installer cannot see,
changes which shell Claude Code picks, so re-run `install.py --harden` after
that change. Claude Code honors that override only for a file named `bash.exe`,
`sh.exe`, `bash` or `sh`; anything else, existing or not, is ignored and
detection proceeds as if it were unset, and the installer follows the same
rule.
`tests/test_claude_goal_install_python_launcher.py` executes the installed
hook, statusline and `/loopx` commands through the selected shell for a plain
path, an ordinary spaced path and literal `$`/quote paths.

```bash
python3 loopx/claude_goal_mode/scripts/install.py --scope project --project /path/to/project --harden
```
Expand Down
113 changes: 107 additions & 6 deletions loopx/claude_goal_mode/scripts/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

import argparse
import json
import os
import shlex
import shutil
import subprocess
import sys
Expand All @@ -34,6 +36,99 @@ def _p(*parts) -> str:
return str(PLUGIN_ROOT.joinpath(*parts)).replace("\\", "/")


def _is_git_bash(path: str) -> bool:
"""True when `path` is the MSYS/Cygwin bash Claude Code dispatches to.

The WSL `bash.exe` that sits on some PATHs is a Linux shell, not the Git
Bash Claude Code spawns for a Windows command string, and its `uname` says
`Linux`; the System32 alias is rejected by name and everything else by
asking the candidate for its `uname`."""
system32 = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32"
resolved = os.path.normcase(os.path.realpath(path))
system32_resolved = os.path.normcase(os.path.realpath(system32))
if resolved == system32_resolved or resolved.startswith(system32_resolved + os.sep):
return False
try:
probe = subprocess.run([path, "-c", "uname -s"], capture_output=True, timeout=10)
except (OSError, subprocess.SubprocessError):
return False
return probe.returncode == 0 and probe.stdout.decode("utf-8", "replace").startswith(
("MINGW", "MSYS", "CYGWIN")
)


# Claude Code honors `CLAUDE_CODE_GIT_BASH_PATH` only when the file name is one
# of these (env-vars reference, 2.1.219+); any other name is ignored and Git
# Bash detection continues as if the variable were unset - even when the file
# exists and runs.
_GIT_BASH_OVERRIDE_NAMES = frozenset({"bash.exe", "sh.exe", "bash", "sh"})


def _git_bash() -> str | None:
"""The Git Bash Claude Code runs a shell-form command string with, if any.

Claude Code passes a hook or statusline string to Git Bash on Windows when
Git Bash is installed, to PowerShell when it is not, and to `/bin/sh` on
macOS/Linux (Claude Code hooks reference, "Exec form and shell form"). It
finds Git Bash through its `CLAUDE_CODE_GIT_BASH_PATH` override, the Git
installation or a `bash` on PATH, or a default Git for Windows location;
this mirrors those sources so the encoding can follow the same choice. Like
that consumer, an override whose file name is not `bash.exe`, `sh.exe`,
`bash` or `sh` (compared case-insensitively) is ignored and detection
continues as if it were unset."""
if sys.platform != "win32":
return None
candidates: list[str] = []
override = os.environ.get("CLAUDE_CODE_GIT_BASH_PATH")
if override and Path(override).name.lower() in _GIT_BASH_OVERRIDE_NAMES:
candidates.append(override)
git = shutil.which("git")
if git:
root = Path(git).resolve().parent.parent
candidates += [str(root / "bin" / "bash.exe"), str(root / "usr" / "bin" / "bash.exe")]
on_path = shutil.which("bash")
if on_path:
candidates.append(on_path)
for base, sub in (
(os.environ.get("ProgramFiles"), "Git"),
(os.environ.get("ProgramFiles(x86)"), "Git"),
(os.environ.get("LocalAppData"), "Programs/Git"),
):
if base:
candidates.append(str(Path(base) / sub / "bin" / "bash.exe"))
for candidate in candidates:
if os.path.isfile(candidate) and _is_git_bash(candidate):
return candidate
return None


def _uses_powershell() -> bool:
"""Whether Claude Code runs these command strings through PowerShell.

A Windows host without Git Bash gets PowerShell for shell-form hooks and
the statusline; every other host runs `/bin/sh` or Git Bash."""
return sys.platform == "win32" and _git_bash() is None


def _shell_literal(value: str, *, powershell: bool) -> str:
"""Render `value` as one literal token the consumer shell reads back whole.

Double quotes are not literal in either shell: `$NAME` expands inside them
and an inner `"` can close the token. Single quotes are literal in both, so
POSIX/Git Bash targets get `shlex.quote`'s form and PowerShell a `'...'`
string with its `''` escape. A bare Windows path would lose its
backslashes under Git Bash, so those are quoted too."""
if powershell:
return "'" + value.replace("'", "''") + "'"
return shlex.quote(value)


def _shell_command(value: str, *, powershell: bool) -> str:
"""`_shell_literal` in command position: PowerShell needs its `&` operator."""
token = _shell_literal(value, powershell=powershell)
return f"& {token}" if powershell else token


def _launches_python(py: str) -> bool:
"""True when running `py` actually starts an interpreter.

Expand Down Expand Up @@ -74,13 +169,16 @@ def deep_merge(base: dict, add: dict) -> dict:

def hardening_block() -> dict:
"""The OPTIONAL per-tool gate + statusline. Not installed unless --harden."""
py = _python_cmd()
powershell = _uses_powershell()
py = _shell_command(_python_cmd(), powershell=powershell)
gate = _shell_literal(_p("hooks", "goal_policy.py"), powershell=powershell)
status = _shell_literal(_p("statusline", "goal_status.py"), powershell=powershell)
return {
"hooks": {
"PreToolUse": [{"matcher": "*", "hooks": [
{"type": "command", "command": f'{py} "{_p("hooks", "goal_policy.py")}"', "timeout": 10}]}],
{"type": "command", "command": f"{py} {gate}", "timeout": 10}]}],
},
"statusLine": {"type": "command", "command": f'{py} "{_p("statusline", "goal_status.py")}"'},
"statusLine": {"type": "command", "command": f"{py} {status}"},
}


Expand Down Expand Up @@ -216,8 +314,11 @@ def install_mcp(
# ---- /loopx command ----------------------------------------------------------

def command_md() -> str:
entry = _p("scripts", "goalmode_cmd.py")
py = _python_cmd()
# The `!` line runs through the Bash tool - `sh`/Git Bash on every host - so
# it is always a POSIX literal, never the PowerShell form; a Windows host
# without Git Bash cannot run `!` lines at all (README).
entry = _shell_literal(_p("scripts", "goalmode_cmd.py"), powershell=False)
py = _shell_literal(_python_cmd(), powershell=False)
return (
"---\n"
"description: loopx goal-mode setup (NOT Claude Code's built-in /goal). "
Expand All @@ -227,7 +328,7 @@ def command_md() -> str:
f"allowed-tools: Bash({py}:*)\n"
"---\n\n"
"Run the loopx setup helper and read its output:\n\n"
f"!`{py} \"{entry}\" $ARGUMENTS`\n\n"
f"!`{py} {entry} $ARGUMENTS`\n\n"
"The output is loopx control-plane SETUP / status info — it is the COMPLETE, user-facing "
"result. Show it to the user VERBATIM and STOP. Do NOT do any work, claim todos, write "
"files, or loop here: the WORK runs when the user types Claude Code's native `/loop` (which "
Expand Down
Loading
Loading