From 8d2ddde60757f302ee58fa30464ed495230efe0e Mon Sep 17 00:00:00 2001 From: tigers1997 Date: Tue, 25 Aug 2026 10:35:07 -0400 Subject: [PATCH] feat(hooks): PowerShell hook variants behind --hook-shell powershell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `shell: "bash"` fix covers Windows *with* Git Bash. This covers the machines without it, where "bash" has nothing to resolve to. Six hooks now ship a .ps1 sibling -- block-dangerous-bash, scan-secrets, format-on-write, stop-run-checks, pre-compact-snapshot, microbit-enforcer -- and --hook-shell powershell swaps a .sh for its sibling by name, per entry, setting "shell": "powershell" on just those hooks. Everything without a sibling stays bash, which is correct rather than lazy: check-package- availability probes apt/brew and sessionstart-drift-check is jq-driven, so both are Linux/macOS-shaped by nature. The answer persists to .claude-config.json like the rest of the intake; the default is unchanged. Three Windows-specific traps, each found by running the hooks rather than by reading about them: a) Windows PowerShell 5.1 -- still the default -- reads .ps1 as the system ANSI code page unless the file carries a BOM. A UTF-8 em-dash decodes to a cp1252 smart quote, which PowerShell accepts as a string delimiter: the string terminated mid-line and microbit-enforcer.ps1 failed to parse. All shipped .ps1 are ASCII and BOM-free, and --check enforces that, plus the .sh pairing -- an orphan .ps1 would never be installed, since the swap is by name. b) Windows ships execution policy Restricted, so naming a .ps1 directly fails with "running scripts is disabled on this system" -- a silent, machine-dependent break of exactly the kind this work exists to prevent. The generated command spawns PowerShell with -ExecutionPolicy Bypass, which applies only to that child process running a script the user installed deliberately, and never changes machine policy. c) A wrapping `powershell -Command` collapses any non-zero child exit to 1, which would have turned a PreToolUse BLOCK (exit 2) into a mere non-blocking error -- the safety hooks would have appeared to work while silently permitting everything. The command ends with `; exit $LASTEXITCODE`, and exit 2 was then verified to survive both a direct -File invocation and a -Command wrapper. The SessionStart marker-clear is not a script but an inline `rm -f … || true`, which is not valid PowerShell, so it gets an explicit translation. New test/portability/test-powershell-hooks.sh asserts the wiring on any platform -- every entry resolves to an installed file, .ps1 entries carry the bypass and the exit-code propagation, and some hooks still run bash -- and executes the hooks wherever PowerShell is present (pwsh ships on all three GitHub runner images). README and docs/03 document the flag, the policy tradeoff, and which hooks stay bash. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JVndNviHZSnbKJnWP7jFbV --- CHANGELOG.md | 2 + README.md | 7 +- configure.py | 155 ++++++++++++++++++ docs/03-commands-and-hooks.md | 5 +- templates/INDEX.md | 12 ++ .../microbit-enforcer/microbit-enforcer.ps1 | 107 ++++++++++++ .../git-workflow/hooks/format-on-write.ps1 | 60 +++++++ .../git-workflow/hooks/stop-run-checks.ps1 | 114 +++++++++++++ .../safety/hooks/block-dangerous-bash.ps1 | 68 ++++++++ templates/safety/hooks/scan-secrets.ps1 | 64 ++++++++ .../hooks/pre-compact-snapshot.ps1 | 72 ++++++++ test/portability/test-powershell-hooks.sh | 136 +++++++++++++++ 12 files changed, 800 insertions(+), 2 deletions(-) create mode 100644 templates/commands/microbit-enforcer/microbit-enforcer.ps1 create mode 100644 templates/git-workflow/hooks/format-on-write.ps1 create mode 100644 templates/git-workflow/hooks/stop-run-checks.ps1 create mode 100644 templates/safety/hooks/block-dangerous-bash.ps1 create mode 100644 templates/safety/hooks/scan-secrets.ps1 create mode 100644 templates/token-efficiency/hooks/pre-compact-snapshot.ps1 create mode 100644 test/portability/test-powershell-hooks.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 27e3d34..25556e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to this project. Format: [Keep a Changelog](https://keepacha - **feat: generate `templates/INDEX.md`, scaffold `.claude/workflows/`, and document teams / channels / routines.** Three gaps the currency audit left open. **(1)** `templates/INDEX.md` was hand-maintained and had gone stale enough to mislead — it still referenced a `configurator.html` that no longer exists and was missing half the modules. It is now **generated** from `MODULES` by `python3 configure.py --write-index`, and `--check` fails when the committed copy and the generator disagree, so it cannot drift again. **(2)** Dynamic workflows have been a first-class Claude Code surface since 2.1.154 and the configurator scaffolded nothing for them. The `multi-agent` module now ships `.claude/workflows/spec-fanout.js` (runs as `/spec-fanout`), which generates N variants of one spec into disjoint slots and then **screens each variant against the spec** before reporting. It is the workflow-native successor to the `/infinite` skill in the same module — same job, but the runtime holds the loop and the intermediate results, the run is resumable, and the screening pass is a real gate rather than a suggestion. Project workflows under `.claude/workflows/` are shared with everyone who clones the repo. `--check` gained a rule validating that every shipped workflow declares a usable `meta` block and uses no `import()` (the runtime rejects both). `workflowSizeGuideline` is stubbed in `settings.local.json.example`. **(3)** `docs/04` gains a table comparing the five ways to run work in parallel (subagent / skill / agent team / workflow / worktree session) by *who holds the plan*, and states plainly why the configurator ships no templates for agent teams, channels or routines: teams are spawned in conversation and live for a session (only `teammateMode` is worth setting, and it's a per-machine terminal preference — stubbed in settings.local); the channel gate keys `channelsEnabled` and `allowedChannelPlugins` are **managed-settings only**, so a project cannot enable them; and routines are scheduled cloud agents that run against a repo rather than from your checkout, where a `Stop` or `SessionStart` hook is the project-scoped equivalent. +- **feat(hooks): PowerShell hook variants behind `--hook-shell powershell`.** The `shell: "bash"` fix covers Windows *with* Git Bash; this covers the machines without it, where `"bash"` has nothing to resolve to. Six hooks now ship a `.ps1` sibling — `block-dangerous-bash`, `scan-secrets`, `format-on-write`, `stop-run-checks`, `pre-compact-snapshot`, `microbit-enforcer` — and `--hook-shell powershell` swaps a `.sh` for its sibling **by name, per entry**, setting `"shell": "powershell"` on just those hooks. Everything without a sibling stays bash, which is correct rather than lazy: `check-package-availability` probes apt/brew and `sessionstart-drift-check` is jq-driven, so both are Linux/macOS-shaped by nature. The answer persists to `.claude-config.json` like the rest of the intake; the default is unchanged. **Three Windows-specific traps, each found by running the hooks rather than reading about them:** **(a)** Windows PowerShell 5.1 — still the default — reads `.ps1` as the system ANSI code page unless the file has a BOM. A UTF-8 em-dash decodes to a cp1252 smart quote, which PowerShell accepts as a *string delimiter*: the string terminated mid-line and `microbit-enforcer.ps1` failed to parse. All shipped `.ps1` are ASCII and BOM-free, and `--check` enforces that (plus the `.sh` pairing, since an orphan `.ps1` would never be installed). **(b)** Windows ships execution policy `Restricted`, so naming a `.ps1` directly fails with *"running scripts is disabled on this system"* — a silent, machine-dependent break of exactly the kind this work exists to prevent. The generated command spawns PowerShell with `-ExecutionPolicy Bypass`, which applies only to that child process running a script the user installed deliberately, and never changes machine policy. **(c)** A wrapping `powershell -Command` collapses any non-zero child exit to `1`, which would have turned a `PreToolUse` **block** (exit 2) into a mere non-blocking error — the safety hooks would have appeared to work while silently permitting everything. The command ends with `; exit $LASTEXITCODE`; exit 2 was then verified to survive both a direct `-File` invocation and a `-Command` wrapper. The SessionStart marker-clear is not a script but an inline `rm -f … || true`, which is not valid PowerShell, so it gets an explicit translation. A `target_path_for` routing rule keyed to `.sh` was widened, or `microbit-enforcer.ps1` would have been routed to `.claude/skills/` and silently never installed — caught by asserting that every settings entry resolves to a file on disk. New `test/portability/test-powershell-hooks.sh` checks the wiring on any platform and executes the hooks wherever PowerShell is present (`pwsh` ships on all three GitHub runner images); README and `docs/03` document the flag, the policy tradeoff, and which hooks stay bash. + - **fix(commands): rename `/review` → `/review-branch` so it stops shadowing the bundled `/code-review`.** CC 2.1.223 made `/review` the alias of the bundled `/code-review` — Claude Code's multi-agent reviewer, including the cloud `ultra` mode. A project skill of that name wins it (verified headlessly on 2.1.241: a project skill named `review` ran for `/review`, and the same held for `plan` against the built-in `/plan`), so **every scaffolded project was silently hiding the better built-in behind this simpler single-pass skill** — overlap that turned into a real capability loss the day the alias shipped. The skill moves to `templates/commands/review-branch/` with `name: review-branch`, and its description now positions it honestly ("a quick single-pass review; Claude Code's bundled `/code-review` is the deeper multi-agent one"). Both are reachable again. Updated across `config_schema.py`, `configure.py`'s pattern-integration map, `templates/INDEX.md`, the `/investigate` and `/plan-eng-review` cross-references, docs 02/03/05/09/10/11, README, and the example project. **Migration:** the configurator has no mechanism to delete a file it previously wrote, so an upgraded project keeps the old `.claude/skills/review/` alongside the new one — and the stale copy still shadows the alias. New `/verify-setup` **check 13** detects exactly that pair and tells the user to `rm -rf .claude/skills/review`. `/plan` is left alone deliberately: it shadows a built-in *command* rather than a bundled skill, and plan mode stays reachable via Shift+Tab, so it's a name clash rather than a lost capability — README now says so and points at the rename if you'd rather keep the shortcut. - **docs: re-baseline the MCP context claims against tool search, and scope `/infinite` against dynamic workflows.** Two of the project's headline claims had been overtaken by Claude Code and were overstating what the modules buy. **MCP.** README claimed per-task profiles "drop a bloated 4-MCP baseline from ~49% context to under 5%", and `docs/04` asserted "every MCP tool is a chunk of JSON schema loaded at session start". Tool search defers MCP schemas by default (`alwaysLoad: true` is the opt-*out*), so the premise no longer holds. Measured rather than re-guessed — four local stdio servers advertising twelve tools each (48 total) against an otherwise identical one-turn session on CC 2.1.245: **26,665 tokens with no MCP servers, 27,361 deferred (+696, ~14/tool), 40,993 with `alwaysLoad: true` (+14,328, ~298/tool)** — deferral removes ~95% of the schema cost, and the numbers reproduced exactly across runs. The claim was also embedded in four *shipped* templates, which is worse than in the docs because it lands in every user's project: `check-context/SKILL.md` (its budget guardrails and the "MCP > 10%" flag), `claude-ctx.sh`'s rationale comment, `servers-cookbook.md` (which already explained deferral correctly a few sections earlier, so it contradicted itself), and the `mcp.minimal.json` profile comment. All corrected. Profiles are now documented for what they still genuinely buy — which servers *connect*: startup time, auth prompts, cold start, and the blast radius `--strict-mcp-config` enforces — and `docs/06` picks up the same correction. The dated `experiments-memory` example keeps its original result with a superseding **addendum** rather than a rewrite, because an experiment log records what was true when it ran. **`/infinite`.** Dynamic workflows now do staged, resumable, budgeted fan-out with structured output between stages; hand-rolled wave batching is the weaker instrument for that job. The skill opens with a decision table sending staged / merge-heavy / resumable work to a workflow, and keeps the one case it is genuinely good at — N variants of a single spec into disjoint slots with no cross-iteration coordination. README's module row and a new `docs/04` section say the same. diff --git a/README.md b/README.md index d7e2c34..c473e01 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Headless CLI that generates Claude Code project scaffolding — `CLAUDE.md`, `.c | --- | --- | | **Linux** (Debian/Ubuntu/Arch/Fedora/…) | Primary target. Everything works out of the box. | | **macOS** (12+) | Works, and exercised in CI (`portability (macos-latest)`). Bash 3.2 from the system is sufficient — no shipped script uses a bash 4+ construct. GNU `timeout` isn't present by default, so the `safety` package-availability gate runs its probes unbounded unless you have coreutils (`brew install coreutils` gives `gtimeout`, which the hook also accepts). | -| **Windows** | Claude Code 2.1.120+ runs natively on Windows — when Git Bash is absent, Claude Code falls back to PowerShell as its shell tool. The `.sh` hook scripts this project ships still need a bash interpreter, so every shipped hook entry declares `"shell": "bash"` (honored by Claude Code 2.1.81+): with Git for Windows installed the hooks run under Git Bash even when Claude Code's own shell tool fell back to PowerShell, and without it Claude Code prompts to install Git Bash instead of failing silently. Alternatives: use WSL, or translate a hook to PowerShell and set `"shell": "powershell"` on its entry. The template directory uses `dot-claude/` (rewritten to `.claude/` at install) so the templates browse and sync cleanly on filesystems and tools that special-case dotfiles. Scaffolding from Windows is exercised in CI (`portability (windows-latest)`): generated files are written with LF on every platform and the scaffold appends a `.gitattributes` block pinning it, so a Windows-authored `.claude/` still runs on a teammate's Linux/macOS checkout. Git can't carry the executable bit the same way — after your first `git add`, run `git update-index --chmod=+x claude-ctx` (the generated `CLAUDE.md` says so too). | +| **Windows** | Claude Code 2.1.120+ runs natively on Windows — when Git Bash is absent, Claude Code falls back to PowerShell as its shell tool. The `.sh` hook scripts this project ships still need a bash interpreter, so every shipped hook entry declares `"shell": "bash"` (honored by Claude Code 2.1.81+): with Git for Windows installed the hooks run under Git Bash even when Claude Code's own shell tool fell back to PowerShell, and without it Claude Code prompts to install Git Bash instead of failing silently. For a Windows machine with no Git Bash at all, `--hook-shell powershell` installs PowerShell variants of the six core hooks (`block-dangerous-bash`, `scan-secrets`, `format-on-write`, `stop-run-checks`, `pre-compact-snapshot`, `microbit-enforcer`) and sets `"shell": "powershell"` on just those entries — the rest stay bash, since they're Linux/macOS-shaped anyway (apt/brew probing, jq-driven drift diffing). WSL remains the option that gets you everything. The template directory uses `dot-claude/` (rewritten to `.claude/` at install) so the templates browse and sync cleanly on filesystems and tools that special-case dotfiles. Scaffolding from Windows is exercised in CI (`portability (windows-latest)`): generated files are written with LF on every platform and the scaffold appends a `.gitattributes` block pinning it, so a Windows-authored `.claude/` still runs on a teammate's Linux/macOS checkout. Git can't carry the executable bit the same way — after your first `git add`, run `git update-index --chmod=+x claude-ctx` (the generated `CLAUDE.md` says so too). | ## Install @@ -202,6 +202,11 @@ All five preflight checks are silent on a clean default scaffold; informational --force Kill-switch: skip the deep-merge AND the collision strategy. Every existing file is overwritten with .bak- (the pre-Tier-2 behavior). Implies --on-collision=overwrite. +--hook-shell SHELL bash (default) or powershell. powershell installs the + .ps1 variants of the hooks that have one and sets + "shell": "powershell" on those entries; hooks without a + .ps1 sibling stay bash. For Windows machines with no + Git Bash. Persists to .claude-config.json. --write-index Regenerate templates/INDEX.md from MODULES (maintainer tool; --check fails when the committed index is stale). --save-config FILE Save answers to FILE (plus scaffolding) diff --git a/configure.py b/configure.py index 0a4e8d3..31e659b 100755 --- a/configure.py +++ b/configure.py @@ -603,6 +603,10 @@ def compute_merged_settings(form_values: dict, selected: set, module_flags: dict # Final pass: strip any `//`-prefixed keys at any nesting depth. Catches # nested doc labels / stubs that escape the shallow per-merge filters. settings = _strip_doc_labels(settings) + # Point hook entries at the .ps1 variants when the project opted into + # PowerShell hooks. Per-entry, so hooks with no .ps1 sibling keep bash. + settings = apply_hook_shell_to_settings( + settings, (form_values or {}).get("hook_shell", "bash")) return settings @@ -713,6 +717,18 @@ def _frontmatter_block(text: str) -> str: `.mcp..json` at the repo root; `mcp/servers-cookbook.md` -> `docs/mcp-servers.md`; `mcp/claude-ctx.sh` -> `claude-ctx` (executable). - Hook scripts are written executable, and with LF endings on every platform. + +## PowerShell hook variants + +Six hooks ship a `.ps1` sibling next to the `.sh`: +`block-dangerous-bash`, `scan-secrets`, `format-on-write`, `stop-run-checks`, +`pre-compact-snapshot` and `microbit-enforcer`. They are not listed above +because they are not separate module paths: `--hook-shell powershell` swaps a +`.sh` for its `.ps1` sibling by name at scaffold time and sets +`"shell": "powershell"` on those hook entries only. Hooks with no sibling stay +bash. Shipped `.ps1` files must be ASCII and BOM-free -- PowerShell 5.1 reads +them as ANSI, so a stray em-dash can terminate a string and break parsing. +`--check` enforces both that and the `.sh` pairing. """ @@ -1049,6 +1065,31 @@ def warn(source, msg): if f"include {pat}" not in text: err(f"templates/{skill_rel}", f"missing required pattern include: {pat}") + # --- 4a. PowerShell hooks stay ASCII, and pair with a .sh sibling --- + # Windows PowerShell 5.1 — still the default on Windows — reads .ps1 as the + # system ANSI code page unless the file has a BOM. A UTF-8 em-dash then + # decodes to a cp1252 smart quote, which PowerShell accepts as a string + # delimiter: the string terminates mid-line and the file fails to parse. + # Cheaper to stay ASCII than to ship BOMs, so this is enforced rather than + # remembered. (Caught the hard way while porting microbit-enforcer.) + for f in sorted(TEMPLATE_DIR.rglob("*.ps1")): + rel = f.relative_to(TEMPLATE_DIR) + blob = f.read_bytes() + if blob.startswith(b"\xef\xbb\xbf"): + err(f"templates/{rel}", "PowerShell script starts with a UTF-8 BOM — " + "keep shipped .ps1 ASCII and BOM-free") + non_ascii = sorted({b for b in blob if b > 127}) + if non_ascii: + err(f"templates/{rel}", + "PowerShell script contains non-ASCII bytes " + f"({', '.join(hex(b) for b in non_ascii[:6])}) — PowerShell 5.1 reads " + ".ps1 as ANSI, so these can silently break parsing") + sibling = f.with_suffix(".sh") + if not sibling.exists(): + err(f"templates/{rel}", + "PowerShell hook has no .sh sibling — --hook-shell swaps .sh for " + ".ps1 by name, so an orphan .ps1 is never installed") + # --- 4b. Dynamic-workflow scripts declare a usable meta block --- # The runtime rejects a script whose `meta` isn't a pure literal with name + # description, and a broken workflow fails at invocation time rather than at @@ -2276,9 +2317,110 @@ def compute_mcp_json(form_values: dict) -> str: return json.dumps({"mcpServers": servers}, indent=2) + "\n" +# --- PowerShell hook variants ------------------------------------------------- +# A handful of hooks ship a .ps1 sibling next to the .sh, for Windows machines +# with no Git Bash (where `shell: "bash"` has nothing to resolve to). Selected +# with --hook-shell powershell; hooks without a sibling stay bash and are listed +# in the README as such, because they are Linux/macOS-shaped anyway +# (apt/brew probing, jq-driven drift diffing). +# +# The SessionStart marker-clear isn't a script — it's an inline `rm -f ... || +# true`, which is not valid PowerShell — so it gets an explicit translation. +POWERSHELL_INLINE_COMMANDS = { + 'rm -f "$CLAUDE_PROJECT_DIR"/.claude/.frozen "$CLAUDE_PROJECT_DIR"/.claude/.guarded ' + '"$CLAUDE_PROJECT_DIR"/.claude/.careful || true': + 'Remove-Item -Force -ErrorAction SilentlyContinue ' + '"$env:CLAUDE_PROJECT_DIR\\.claude\\.frozen",' + '"$env:CLAUDE_PROJECT_DIR\\.claude\\.guarded",' + '"$env:CLAUDE_PROJECT_DIR\\.claude\\.careful"', +} + + +def powershell_hook_stems() -> set: + """Basenames of hooks that ship a .ps1 sibling.""" + return {p.stem for p in TEMPLATE_DIR.rglob("*.ps1")} + + +def swap_hook_shell(rel: str, hook_shell: str) -> str: + """Return the template path to actually install for `rel`. + + Under --hook-shell powershell, a .sh with a .ps1 sibling installs the .ps1; + everything else is unchanged. + """ + if hook_shell != "powershell" or not rel.endswith(".sh"): + return rel + candidate = rel[:-3] + ".ps1" + return candidate if (TEMPLATE_DIR / candidate).exists() else rel + + +def apply_hook_shell_to_settings(settings: dict, hook_shell: str) -> dict: + """Point hook entries at the .ps1 variants and flip `shell` to powershell. + + Only touches commands whose hook has a .ps1 sibling (plus the inline + marker-clear), so a mixed project keeps `shell: "bash"` on the hooks that + are still bash — Claude Code honors the key per entry. + """ + if hook_shell != "powershell": + return settings + stems = powershell_hook_stems() + hooks = settings.get("hooks") + if not isinstance(hooks, dict): + return settings + for groups in hooks.values(): + if not isinstance(groups, list): + continue + for group in groups: + for h in (group.get("hooks") or []): + if not isinstance(h, dict): + continue + cmd = h.get("command") + if not isinstance(cmd, str): + continue + if cmd in POWERSHELL_INLINE_COMMANDS: + h["command"] = POWERSHELL_INLINE_COMMANDS[cmd] + h["shell"] = "powershell" + continue + if not cmd.endswith(".sh"): + continue + stem = cmd.rsplit("/", 1)[-1][:-3] + if stem in stems: + h["command"] = powershell_hook_command(stem) + h["shell"] = "powershell" + return settings + + +def powershell_hook_command(stem: str) -> str: + """Build the command string for a .ps1 hook. + + Windows ships with the execution policy set to Restricted for scripts, so + naming the .ps1 directly fails with "running scripts is disabled on this + system" — a silent, machine-dependent break of exactly the kind this hook + set exists to avoid. Spawning a child PowerShell with -ExecutionPolicy + Bypass makes the hook work on a default Windows box without changing the + machine's policy: the bypass applies only to this process running a script + the user installed deliberately. + + Costs one extra process per invocation. A user who would rather not pay + that on PreToolUse can run + + Set-ExecutionPolicy -Scope CurrentUser RemoteSigned + + once and simplify the command to the bare path. + """ + # `; exit $LASTEXITCODE` is load-bearing: a wrapping `powershell -Command` + # collapses any non-zero child exit to 1, which would turn a PreToolUse + # BLOCK (exit 2) into a mere non-blocking error. Measured on Windows: + # without it the block signal is lost; with it, exit 2 survives both a + # direct -File invocation and a -Command wrapper. + return ('powershell -NoProfile -ExecutionPolicy Bypass -File ' + f'"$env:CLAUDE_PROJECT_DIR\\.claude\\hooks\\{stem}.ps1"' + '; exit $LASTEXITCODE') + + def collect_files(form_values: dict, selected: set, module_flags: dict = None) -> tuple: if module_flags is None: module_flags = {} + hook_shell = (form_values or {}).get("hook_shell", "bash") files = [] gitignore_lines = [] gitattributes_lines = [] @@ -2296,6 +2438,7 @@ def collect_files(form_values: dict, selected: set, module_flags: dict = None) - if fp is not None: filter_for_module = set(fp) for rel in m["paths"]: + rel = swap_hook_shell(rel, hook_shell) if filter_for_module is not None and rel not in filter_for_module: continue tgt = target_path_for(rel) @@ -2318,6 +2461,7 @@ def collect_files(form_values: dict, selected: set, module_flags: dict = None) - for flag_name, flag_def in m.get("flags", {}).items(): selected_value = module_flags.get(m["id"], {}).get(flag_name, flag_def["default"]) for rel in flag_def.get("extraPaths", {}).get(selected_value, []): + rel = swap_hook_shell(rel, hook_shell) tgt = target_path_for(rel) if not tgt: continue @@ -2843,6 +2987,12 @@ def parse_args(): help="Static validation of templates + MODULES registry (CI-friendly). " "Exits 0 on clean, 1 with a per-issue summary otherwise. " "Skips all other processing — no scaffolding, no prompts.") + p.add_argument("--hook-shell", choices=["bash", "powershell"], default=None, + help="Language for the scaffolded hook scripts. Default bash. " + "powershell installs the .ps1 variants (and sets " + "shell: \"powershell\" on those entries) for Windows " + "machines without Git Bash; hooks with no .ps1 sibling " + "stay bash.") p.add_argument("--write-index", action="store_true", help="Regenerate templates/INDEX.md from MODULES (maintainer tool). " "--check fails when the committed index and this output disagree.") @@ -3048,6 +3198,11 @@ def main(): initial.setdefault("_deprecations", []).extend(deprecations) initial.setdefault("_module_arg_warnings", []).extend(mod_warnings) + # --hook-shell is a scaffold-shaping answer, so it lives in formValues and + # persists to .claude-config.json like the rest of the intake. + if args.hook_shell: + initial.setdefault("formValues", {})["hook_shell"] = args.hook_shell + # --- interactive if needed --- # --yes / --config / --modules / --persona / --preset / --save-config-only: # skip interactive entirely (preserves v1 non-interactive paths). diff --git a/docs/03-commands-and-hooks.md b/docs/03-commands-and-hooks.md index a7056c0..2f9cc3c 100644 --- a/docs/03-commands-and-hooks.md +++ b/docs/03-commands-and-hooks.md @@ -159,7 +159,10 @@ The `$CLAUDE_PROJECT_DIR` env var is always set to the project root — use it i - Default timeouts: 600s command, 30s prompt, 60s agent. - Injected context (additionalContext, systemMessage, stdout) capped at 10,000 chars. - Multiple hooks per event run in parallel. Identical commands are deduplicated. -- `shell`: every shipped command hook declares `"shell": "bash"` (CC 2.1.81+; the key is in the settings schema). Without it, a Windows session with no Git Bash defaults hooks to PowerShell and the `.sh` entrypoint dies on a parser error; with it, Claude Code resolves Git for Windows directly and prompts to install it when missing. Translate a hook to PowerShell and set `"shell": "powershell"` only when you want to drop the bash dependency. +- `shell`: every shipped command hook declares `"shell": "bash"` (CC 2.1.81+; the key is in the settings schema). Without it, a Windows session with no Git Bash defaults hooks to PowerShell and the `.sh` entrypoint dies on a parser error; with it, Claude Code resolves Git for Windows directly and prompts to install it when missing. To drop the bash dependency entirely, scaffold with `--hook-shell powershell`: the six hooks that ship a `.ps1` sibling install as PowerShell and get `"shell": "powershell"` on their own entries, while the rest stay bash. Two Windows details the generated command handles, both found by running it on a stock box: + +- Windows ships execution policy `Restricted`, so naming a `.ps1` directly fails with *"running scripts is disabled on this system"*. The command spawns PowerShell with `-ExecutionPolicy Bypass`, which applies only to that child process — it doesn't change machine policy. Prefer `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` once and simplify the command if you'd rather not pay the extra process on `PreToolUse`. +- A wrapping `powershell -Command` collapses any non-zero child exit to `1`, which would turn a `PreToolUse` **block** (exit 2) into a mere non-blocking error. The command ends with `; exit $LASTEXITCODE` to preserve it. ### Debugging hooks diff --git a/templates/INDEX.md b/templates/INDEX.md index 9fc1afd..1c3e72e 100644 --- a/templates/INDEX.md +++ b/templates/INDEX.md @@ -215,3 +215,15 @@ every matching entry. `.mcp..json` at the repo root; `mcp/servers-cookbook.md` -> `docs/mcp-servers.md`; `mcp/claude-ctx.sh` -> `claude-ctx` (executable). - Hook scripts are written executable, and with LF endings on every platform. + +## PowerShell hook variants + +Six hooks ship a `.ps1` sibling next to the `.sh`: +`block-dangerous-bash`, `scan-secrets`, `format-on-write`, `stop-run-checks`, +`pre-compact-snapshot` and `microbit-enforcer`. They are not listed above +because they are not separate module paths: `--hook-shell powershell` swaps a +`.sh` for its `.ps1` sibling by name at scaffold time and sets +`"shell": "powershell"` on those hook entries only. Hooks with no sibling stay +bash. Shipped `.ps1` files must be ASCII and BOM-free -- PowerShell 5.1 reads +them as ANSI, so a stray em-dash can terminate a string and break parsing. +`--check` enforces both that and the `.sh` pairing. diff --git a/templates/commands/microbit-enforcer/microbit-enforcer.ps1 b/templates/commands/microbit-enforcer/microbit-enforcer.ps1 new file mode 100644 index 0000000..82d508c --- /dev/null +++ b/templates/commands/microbit-enforcer/microbit-enforcer.ps1 @@ -0,0 +1,107 @@ +# microbit-enforcer (PowerShell): PreToolUse hook for the /freeze, /guard and +# /careful microbits. PowerShell sibling of microbit-enforcer.sh. +# +# Reads the tool-call payload from stdin (Claude Code PreToolUse contract). +# Exits 0 to allow the call; exits non-zero to block. Emits +# {"action":"ask",...} on stdout for /careful matches so Claude Code surfaces a +# confirmation prompt. +# +# Marker files (project-local, session-scoped): +# .claude/.frozen -- present => block ALL Write/Edit/NotebookEdit +# .claude/.guarded -- newline-separated globs; block on match +# .claude/.careful -- newline-separated globs; prompt before match +# +# Lifecycle: a SessionStart hook (matcher startup|clear) clears all three on a +# fresh session or /clear. Markers survive --resume, compaction and /fork, so a +# long session keeps its markers. +$ErrorActionPreference = 'Stop' + +$projectDir = $env:CLAUDE_PROJECT_DIR +if ([string]::IsNullOrWhiteSpace($projectDir)) { $projectDir = (Get-Location).Path } + +$frozenFile = Join-Path $projectDir '.claude\.frozen' +$guardedFile = Join-Path $projectDir '.claude\.guarded' +$carefulFile = Join-Path $projectDir '.claude\.careful' + +$raw = '' +try { $raw = [Console]::In.ReadToEnd() } catch { exit 0 } +if ([string]::IsNullOrWhiteSpace($raw)) { exit 0 } + +try { $payload = $raw | ConvertFrom-Json } catch { exit 0 } + +$toolName = '' +if ($payload.PSObject.Properties.Name -contains 'tool_name') { $toolName = [string]$payload.tool_name } + +# Only Write/Edit/NotebookEdit are gated. Others pass through. +if ($toolName -notin @('Write', 'Edit', 'NotebookEdit')) { exit 0 } + +$targetPath = '' +if ($payload.PSObject.Properties.Name -contains 'tool_input' -and $payload.tool_input) { + foreach ($n in @('file_path', 'notebook_path')) { + if ($payload.tool_input.PSObject.Properties.Name -contains $n -and $payload.tool_input.$n) { + $targetPath = [string]$payload.tool_input.$n + break + } + } +} + +# 1. Frozen check -- overrides everything. +if (Test-Path -LiteralPath $frozenFile -PathType Leaf) { + [Console]::Error.WriteLine('[ FROZEN ] Write/Edit/NotebookEdit blocked until /unfreeze.') + exit 1 +} + +# Glob matching. The bash hook uses shell globs against the raw path; here the +# comparison runs on the forward-slash form of both sides so a pattern written +# as src/**/*.py still matches a Windows path the tool reported with +# backslashes. -like understands * and ?, which is the subset these markers use. +function Test-GlobMatch { + param([string]$Path, [string]$Pattern) + $p = $Path.Replace('\', '/') + $g = $Pattern.Trim().Replace('\', '/') + if ([string]::IsNullOrWhiteSpace($g)) { return $false } + if ($p -like $g) { return $true } + # `**/` should also match zero directories: src/**/*.py vs src/main.py + if ($g -match '\*\*/') { + $collapsed = $g -replace '\*\*/', '' + if ($p -like $collapsed) { return $true } + } + return $false +} + +function Read-Patterns { + param([string]$File) + try { + return @(Get-Content -LiteralPath $File -ErrorAction Stop | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } catch { + return @() + } +} + +# 2. Guarded check (block on match). +if ((Test-Path -LiteralPath $guardedFile -PathType Leaf) -and $targetPath) { + foreach ($pattern in (Read-Patterns $guardedFile)) { + if (Test-GlobMatch $targetPath $pattern) { + [Console]::Error.WriteLine("[ GUARDED ] $targetPath matches '$pattern' -- edit blocked.") + exit 1 + } + } +} + +# 3. Careful check (prompt on match). +if ((Test-Path -LiteralPath $carefulFile -PathType Leaf) -and $targetPath) { + foreach ($pattern in (Read-Patterns $carefulFile)) { + if (Test-GlobMatch $targetPath $pattern) { + $question = "About to write '$targetPath' (matches careful pattern '$pattern'). Proceed?" + # Build via ConvertTo-Json so a quote or backslash in the path can't + # produce malformed JSON, which the bash heredoc version can. + $obj = [ordered]@{ action = 'ask'; question = $question } + Write-Output ($obj | ConvertTo-Json -Compress) + exit 0 + } + } +} + +# Default: allow. +exit 0 diff --git a/templates/git-workflow/hooks/format-on-write.ps1 b/templates/git-workflow/hooks/format-on-write.ps1 new file mode 100644 index 0000000..0e1b89a --- /dev/null +++ b/templates/git-workflow/hooks/format-on-write.ps1 @@ -0,0 +1,60 @@ +# PostToolUse hook (PowerShell) -- autoformats files after Claude writes or +# edits them. PowerShell sibling of format-on-write.sh; wire under +# hooks.PostToolUse with matcher "Write|Edit". +# +# Every formatter is optional: a missing tool is skipped silently, exactly as +# in the bash version. This hook never fails the turn. +$ErrorActionPreference = 'Continue' + +$raw = '' +try { $raw = [Console]::In.ReadToEnd() } catch { exit 0 } +if ([string]::IsNullOrWhiteSpace($raw)) { exit 0 } + +try { $payload = $raw | ConvertFrom-Json } catch { exit 0 } + +$file = '' +if ($payload.PSObject.Properties.Name -contains 'tool_input' -and $payload.tool_input) { + foreach ($n in @('file_path', 'path')) { + if ($payload.tool_input.PSObject.Properties.Name -contains $n -and $payload.tool_input.$n) { + $file = [string]$payload.tool_input.$n + break + } + } +} + +if ([string]::IsNullOrWhiteSpace($file)) { exit 0 } +if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { exit 0 } + +# Skip generated / vendored trees. +$normalized = $file.Replace('\', '/') +foreach ($skip in @('/.git/', '/node_modules/', '/.venv/', '/dist/', '/build/')) { + if ($normalized -like "*$skip*") { exit 0 } +} + +function Have($name) { return [bool](Get-Command $name -ErrorAction SilentlyContinue) } + +$ext = [System.IO.Path]::GetExtension($file).ToLowerInvariant() + +try { + switch -Regex ($ext) { + '^\.(ts|tsx|js|jsx|mjs|cjs|json|md|css|html|yml|yaml)$' { + if (Have 'prettier') { & prettier --write --log-level=warn $file 2>$null | Out-Null } + } + '^\.py$' { + if (Have 'ruff') { + & ruff format $file 2>$null | Out-Null + & ruff check --fix --quiet $file 2>$null | Out-Null + } + } + '^\.go$' { + if (Have 'gofmt') { & gofmt -w $file 2>$null | Out-Null } + } + '^\.rs$' { + if (Have 'rustfmt') { & rustfmt --edition 2021 $file 2>$null | Out-Null } + } + } +} catch { + # A formatter that errors is not a reason to fail the turn. +} + +exit 0 diff --git a/templates/git-workflow/hooks/stop-run-checks.ps1 b/templates/git-workflow/hooks/stop-run-checks.ps1 new file mode 100644 index 0000000..8ff88c9 --- /dev/null +++ b/templates/git-workflow/hooks/stop-run-checks.ps1 @@ -0,0 +1,114 @@ +# Stop hook (PowerShell) -- runs the typecheck / lint / test commands you +# configured during cc-configure intake, and reports results to Claude via +# hookSpecificOutput.additionalContext on the next turn. Never blocks. +# PowerShell sibling of stop-run-checks.sh; keep the two in sync. +# +# Skipping rules (silent, not reported), same as the bash version: +# 1. Empty command -- you blanked the field during intake. +# 2. First binary not on PATH at runtime. +# 3. Stack manifest absent at the project root (package.json, pyproject.toml, +# Cargo.toml, go.mod, Gemfile, pom.xml, build.gradle) -- so the check loop +# stays quiet during the brainstorming/planning phase. +# 4. Background work still in flight (input carries a non-empty +# background_tasks array) -- the real stop comes later. +$ErrorActionPreference = 'Continue' + +$projectDir = $env:CLAUDE_PROJECT_DIR +if ([string]::IsNullOrWhiteSpace($projectDir)) { $projectDir = (Get-Location).Path } +Set-Location -LiteralPath $projectDir + +# Skipping rule 4. Console.In.Peek() returns -1 on a closed/empty stdin, so a +# direct terminal invocation doesn't hang waiting for input. +$raw = '' +try { + if (-not [Console]::IsInputRedirected) { $raw = '' } + else { $raw = [Console]::In.ReadToEnd() } +} catch { $raw = '' } + +if (-not [string]::IsNullOrWhiteSpace($raw)) { + try { + $payload = $raw | ConvertFrom-Json + if ($payload.PSObject.Properties.Name -contains 'background_tasks' -and $payload.background_tasks) { + if (@($payload.background_tasks).Count -gt 0) { exit 0 } + } + } catch { } +} + +# label|command (the 3rd docker-compose field the bash hook supports is not +# ported: it shells out to `docker compose exec -T`, and the container path is +# better served by running the bash hook under Git Bash or WSL. A 3-field entry +# is treated as a plain host command here, minus the service.) +$checks = @( + @{ label = 'typecheck'; command = '{{cmd_typecheck}}' }, + @{ label = 'lint'; command = '{{cmd_lint}}' }, + @{ label = 'test'; command = '{{cmd_test}}' } +) + +# Map a check command's first binary to the manifest that signals "this stack +# has been scaffolded". Mirrors manifest_for() in the .sh version. +function Get-ManifestFor { + param([string]$Binary) + switch -Regex ($Binary) { + '^(pnpm|npm|yarn|bun)$' { return 'package.json' } + '^(uv|poetry|pip|pip3)$' { return 'pyproject.toml' } + '^(cargo|rustc)$' { return 'Cargo.toml' } + '^go$' { return 'go.mod' } + '^(bundle|gem)$' { return 'Gemfile' } + '^mvn$' { return 'pom.xml' } + '^(gradle|\./gradlew)$' { return 'build.gradle' } + default { return '' } + } +} + +$report = '' + +foreach ($check in $checks) { + $label = $check.label + $cmd = [string]$check.command + + # Rule 1: user opted out of this check during intake. + if ([string]::IsNullOrWhiteSpace($cmd)) { continue } + + $first = ($cmd -split '\s+')[0] + + # Rule 2: the tool isn't installed on this machine. + if (-not (Get-Command $first -ErrorAction SilentlyContinue)) { continue } + + # Rule 3: the stack this check belongs to isn't scaffolded here yet. + $manifest = Get-ManifestFor $first + if ($manifest -and -not (Test-Path -LiteralPath (Join-Path $projectDir $manifest))) { continue } + + $output = '' + $status = 0 + try { + # cmd.exe /c keeps the configured command string intact (it may contain + # pipes or flags) and merges stderr, matching the bash hook's 2>&1. + $output = & cmd.exe /c "$cmd 2>&1" | Out-String + $status = $LASTEXITCODE + } catch { + $output = $_.Exception.Message + $status = 1 + } + + if ($status -eq 0) { + $report += "[stop-check] ${label}: OK`n" + } else { + $tail = ($output -split "`r?`n" | Select-Object -Last 30) -join "`n" + $report += "[stop-check] ${label}: FAIL (exit ${status})`n${tail}`n---`n" + } +} + +# Emit decision JSON so Claude sees the report on the next turn. Built with +# ConvertTo-Json so command output containing quotes or backslashes can't +# produce malformed JSON. +if (-not [string]::IsNullOrWhiteSpace($report)) { + $obj = [ordered]@{ + hookSpecificOutput = [ordered]@{ + hookEventName = 'Stop' + additionalContext = $report + } + } + Write-Output ($obj | ConvertTo-Json -Compress -Depth 5) +} + +exit 0 diff --git a/templates/safety/hooks/block-dangerous-bash.ps1 b/templates/safety/hooks/block-dangerous-bash.ps1 new file mode 100644 index 0000000..6e148f2 --- /dev/null +++ b/templates/safety/hooks/block-dangerous-bash.ps1 @@ -0,0 +1,68 @@ +# PreToolUse hook (PowerShell) -- blocks obviously dangerous shell commands. +# PowerShell sibling of block-dangerous-bash.sh, for Windows machines with no +# Git Bash. Scaffolded when cc-configure runs with --hook-shell powershell, +# which also sets "shell": "powershell" on the hook entry. +# +# Input (stdin): JSON with tool_input.command, plus session_id, cwd, etc. +# Output: exit 0 to allow; exit 2 to block (stderr is shown to Claude). +# +# Keep the pattern list in sync with the .sh version -- same footguns, .NET +# regex syntax ([[:space:]] -> \s). Matching is case-insensitive here because +# Windows shells are, which makes this slightly stricter than the bash hook. +$ErrorActionPreference = 'Stop' + +$raw = [Console]::In.ReadToEnd() +if ([string]::IsNullOrWhiteSpace($raw)) { exit 0 } + +try { + $payload = $raw | ConvertFrom-Json +} catch { + # Unparseable payload is not the user's fault and not our call to block on. + exit 0 +} + +$cmd = '' +if ($payload.PSObject.Properties.Name -contains 'tool_input' -and $payload.tool_input) { + if ($payload.tool_input.PSObject.Properties.Name -contains 'command') { + $cmd = [string]$payload.tool_input.command + } +} +if ([string]::IsNullOrWhiteSpace($cmd)) { exit 0 } + +# Deny list -- extend as you find footguns. Mirrors the bash hook, plus the +# PowerShell-native spellings of the same destructive intents. +$patterns = @( + 'rm\s+-rf?\s+/+($|\s)', # rm -rf / (also //) + 'rm\s+-rf?\s+~/?($|\s)', # rm -rf ~ (also ~/) + 'rm\s+-rf?\s+"?\$HOME"?/?($|\s)', # rm -rf $HOME / "$HOME" + 'rm\s+-rf?\s+\.($|\s)', # rm -rf . + 'rm\s+-rf?\s+\*', # rm -rf * + ':\(\)\{.*\|:&\};:', # fork bomb + '\bmkfs\b', # format filesystem + '\bdd\s+.*of=/dev/', # dd to device + '>\s*/dev/sda', # overwrite disk + '\bsudo\s', # sudo + 'curl[^|]+\|\s*(sh|bash|zsh|pwsh|powershell)\b', # curl | sh + 'wget[^|]+\|\s*(sh|bash|zsh|pwsh|powershell)\b', # wget | sh + '\bchmod\s+-R\s+777\b', # chmod -R 777 + '\bgit\s+push\s+.*--force\b', # force push + '\bgit\s+reset\s+--hard\b', # hard reset + # --- PowerShell / Windows equivalents of the same intents --- + 'Remove-Item\s+.*-Recurse.*-Force.*\s+[A-Za-z]:\\?($|\s)', # rmdir a whole drive + 'Remove-Item\s+.*(\$HOME|\$env:USERPROFILE)', # wipe the profile + '\bformat\s+[A-Za-z]:', # format C: + '\b(Invoke-WebRequest|Invoke-RestMethod|iwr|irm|curl\.exe)\b[^|]*\|\s*(iex|Invoke-Expression)', # download | iex + '\bicacls\b.*\/grant\s+\S+:\(?F\)?.*\/T' # recursive full-control grant +) + +foreach ($pat in $patterns) { + if ([System.Text.RegularExpressions.Regex]::IsMatch( + $cmd, $pat, + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)) { + [Console]::Error.WriteLine("[block-dangerous-bash] Blocked pattern: $pat") + [Console]::Error.WriteLine("[block-dangerous-bash] Command: $cmd") + exit 2 + } +} + +exit 0 diff --git a/templates/safety/hooks/scan-secrets.ps1 b/templates/safety/hooks/scan-secrets.ps1 new file mode 100644 index 0000000..7c5ee07 --- /dev/null +++ b/templates/safety/hooks/scan-secrets.ps1 @@ -0,0 +1,64 @@ +# PreToolUse hook (PowerShell) -- blocks Write/Edit that would land a secret in +# a file. PowerShell sibling of scan-secrets.sh; wire under hooks.PreToolUse +# with matcher "Write|Edit". +# +# Exit 0 allows, exit 2 blocks (stderr is shown to Claude). +$ErrorActionPreference = 'Stop' + +$raw = [Console]::In.ReadToEnd() +if ([string]::IsNullOrWhiteSpace($raw)) { exit 0 } + +try { $payload = $raw | ConvertFrom-Json } catch { exit 0 } + +$toolInput = $null +if ($payload.PSObject.Properties.Name -contains 'tool_input') { $toolInput = $payload.tool_input } +if (-not $toolInput) { exit 0 } + +function Get-Field { + param($Obj, [string[]]$Names) + foreach ($n in $Names) { + if ($Obj.PSObject.Properties.Name -contains $n -and $Obj.$n) { return [string]$Obj.$n } + } + return '' +} + +$pathField = Get-Field $toolInput @('file_path', 'path', 'notebook_path') +$content = Get-Field $toolInput @('content', 'new_string') + +# Block writes to sensitive files outright. Matched on the normalized path so a +# Windows backslash path is judged the same as a POSIX one. +$normalized = $pathField.Replace('\', '/') +$sensitive = @( + '\.env$', '\.env\.', '/credentials', '/id_rsa$', '/id_ed25519$', + '\.pem$', '\.key$', '\.p12$', '\.pfx$' +) +foreach ($pat in $sensitive) { + if ([regex]::IsMatch($normalized, $pat, 'IgnoreCase')) { + [Console]::Error.WriteLine("[scan-secrets] Refusing to write to sensitive file: $pathField") + exit 2 + } +} + +if ([string]::IsNullOrEmpty($content)) { exit 0 } + +# Regex patterns for common secrets. Case-sensitive on purpose: these are +# fixed-case prefixes, and folding case here produces false positives. +$patterns = @( + 'AKIA[0-9A-Z]{16}', # AWS access key + 'sk-[A-Za-z0-9]{20,}', # OpenAI / Anthropic-ish + 'ghp_[A-Za-z0-9]{20,}', # GitHub PAT + 'xox[abpr]-[A-Za-z0-9-]{10,}', # Slack + '-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----', # private keys + 'glpat-[A-Za-z0-9_\-]{20,}', # GitLab PAT + 'eyJ[A-Za-z0-9_\-]{20,}\.eyJ[A-Za-z0-9_\-]{20,}\.' # JWT-ish +) + +foreach ($pat in $patterns) { + if ([regex]::IsMatch($content, $pat)) { + [Console]::Error.WriteLine("[scan-secrets] Blocked: content matches secret pattern /$pat/") + [Console]::Error.WriteLine("[scan-secrets] File: $pathField") + exit 2 + } +} + +exit 0 diff --git a/templates/token-efficiency/hooks/pre-compact-snapshot.ps1 b/templates/token-efficiency/hooks/pre-compact-snapshot.ps1 new file mode 100644 index 0000000..8384ca2 --- /dev/null +++ b/templates/token-efficiency/hooks/pre-compact-snapshot.ps1 @@ -0,0 +1,72 @@ +# PreCompact hook (PowerShell) -- writes a summary of the session before +# compaction, so there's a durable record of what was done after compression. +# PowerShell sibling of pre-compact-snapshot.sh. +# +# Never fails the turn: any unexpected condition still emits the minimal JSON +# and exits 0. A snapshot is a convenience, not a gate. +$ErrorActionPreference = 'Continue' + +$raw = '' +try { $raw = [Console]::In.ReadToEnd() } catch { $raw = '' } + +$sessionId = '' +if (-not [string]::IsNullOrWhiteSpace($raw)) { + try { + $payload = $raw | ConvertFrom-Json + if ($payload.PSObject.Properties.Name -contains 'session_id') { + $sessionId = [string]$payload.session_id + } + } catch { } +} + +$projectDir = $env:CLAUDE_PROJECT_DIR +if ([string]::IsNullOrWhiteSpace($projectDir)) { $projectDir = (Get-Location).Path } + +try { + $logDir = Join-Path $projectDir '.claude\logs' + if (-not (Test-Path -LiteralPath $logDir)) { + New-Item -ItemType Directory -Path $logDir -Force | Out-Null + } + + $ts = [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ') + $out = Join-Path $logDir "compact-$ts.md" + + # git may be absent, or this may not be a repo: every call degrades to a note. + function Invoke-Git { + param([string[]]$GitArgs) + try { + $result = & git -C $projectDir @GitArgs 2>$null + if ($LASTEXITCODE -ne 0) { return '(git returned no output)' } + if (-not $result) { return '(none)' } + return ($result -join "`n") + } catch { + return '(git unavailable)' + } + } + + $lines = @( + "# Pre-compact snapshot $ts", + '', + "session_id: $sessionId", + '', + '## Git status', + (Invoke-Git @('status', '--short')), + '', + '## Recent commits (this session window)', + (Invoke-Git @('log', '--since=6 hours ago', '--oneline')), + '', + '## Files changed since HEAD', + (Invoke-Git @('diff', '--name-status')) + ) + + # UTF8 without BOM, LF endings: this file may be read on another platform. + $text = ($lines -join "`n") + "`n" + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($out, $text, $utf8NoBom) +} catch { + # Snapshot failed; the turn continues regardless. +} + +# Emit minimal JSON -- don't inject heavy context. +Write-Output '{"suppressOutput": true}' +exit 0 diff --git a/test/portability/test-powershell-hooks.sh b/test/portability/test-powershell-hooks.sh new file mode 100644 index 0000000..e4d2ac6 --- /dev/null +++ b/test/portability/test-powershell-hooks.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# --hook-shell powershell must produce hooks that actually run on a default +# Windows box, and a settings.json whose every entry points at an installed +# file with the right shell. +# +# The .ps1 hooks exist for Windows machines with no Git Bash, where +# `shell: "bash"` has nothing to resolve to. Two Windows-specific traps this +# guards against, both found by running it: +# * Windows ships execution policy Restricted, so naming a .ps1 directly +# fails with "running scripts is disabled on this system". The command +# spawns PowerShell with -ExecutionPolicy Bypass instead. +# * A wrapping `powershell -Command` collapses a non-zero child exit to 1, +# which would turn a PreToolUse BLOCK (exit 2) into a non-blocking error. +# The command ends with `; exit $LASTEXITCODE` to preserve it. +# +# Execution probes skip cleanly when no PowerShell is on PATH (pwsh ships on +# all three GitHub runner images; a developer machine may not have it). +set -euo pipefail + +PS="" +for candidate in pwsh powershell.exe powershell; do + if command -v "$candidate" >/dev/null 2>&1; then PS="$candidate"; break; fi +done + +tmp=$(mktemp -d) +tmp2=$(mktemp -d) +trap 'rm -rf "$tmp" "$tmp2"' EXIT + +# --- wiring ----------------------------------------------------------------- +python3 configure.py --persona solo-experienced --yes --hook-shell powershell --dir "$tmp" >/dev/null + +python3 - "$tmp" <<'PY' +import json, re, sys +from pathlib import Path + +target = Path(sys.argv[1]) +hooks = target / ".claude" / "hooks" +settings = json.loads((target / ".claude" / "settings.json").read_text(encoding="utf-8")) + +missing, ps1_entries, sh_entries = [], 0, 0 +for event, groups in settings.get("hooks", {}).items(): + for group in groups: + for h in group.get("hooks", []): + cmd, shell = h.get("command", ""), h.get("shell") + if ".ps1" in cmd: + ps1_entries += 1 + if shell != "powershell": + sys.exit(f"FAIL: {event} runs a .ps1 with shell={shell!r}") + if "-ExecutionPolicy Bypass" not in cmd: + sys.exit(f"FAIL: {event} .ps1 command lacks the execution-policy " + f"bypass; it would fail on a default Windows box: {cmd}") + if not cmd.rstrip().endswith("exit $LASTEXITCODE"): + sys.exit(f"FAIL: {event} .ps1 command does not propagate the exit " + f"code, so a hook BLOCK (exit 2) collapses to 1: {cmd}") + m = re.search(r"hooks\\([\w.-]+\.ps1)", cmd) + if not m: + sys.exit(f"FAIL: cannot find the hook filename in: {cmd}") + if not (hooks / m.group(1)).exists(): + missing.append((event, m.group(1))) + elif cmd.endswith(".sh"): + sh_entries += 1 + if shell != "bash": + sys.exit(f"FAIL: {event} runs a .sh with shell={shell!r}") + if not (hooks / cmd.rsplit("/", 1)[-1]).exists(): + missing.append((event, cmd.rsplit("/", 1)[-1])) + +if missing: + sys.exit(f"FAIL: settings reference hook files that were never installed: {missing}") +if ps1_entries == 0: + sys.exit("FAIL: --hook-shell powershell produced no .ps1 hook entries") +# The point of the per-entry design: hooks with no .ps1 sibling stay bash. +if sh_entries == 0: + sys.exit("FAIL: expected some hooks to remain bash (no .ps1 sibling)") + +# The inline SessionStart marker-clear is not a script; it must have been +# translated, because `rm -f ... || true` is not valid PowerShell. +inline = [h.get("command", "") + for g in settings["hooks"].get("SessionStart", []) + for h in g.get("hooks", []) + if "rm -f" in h.get("command", "")] +if inline: + sys.exit(f"FAIL: a bash inline command survived the powershell swap: {inline}") + +print(f" wiring OK: {ps1_entries} powershell entries, {sh_entries} bash entries, none dangling") +PY + +# --- the default must stay bash --------------------------------------------- +python3 configure.py --persona solo-experienced --yes --dir "$tmp2" >/dev/null +if ls "$tmp2/.claude/hooks/" | grep -q '\.ps1$'; then + echo "FAIL: default scaffold installed PowerShell hooks" + exit 1 +fi +echo " default scaffold is still bash-only" + +if [ -z "$PS" ]; then + echo "PASS (wiring only): no PowerShell on PATH, skipped execution probes" + exit 0 +fi + +# --- behavior --------------------------------------------------------------- +# Under Git Bash, a Windows PowerShell cannot resolve /c/Users/... paths; +# cygpath is absent on the Linux/macOS runners, where paths are already native. +winpath() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi +} +run_hook() { # $1=hook path $2=stdin json ; echoes exit code + printf '%s' "$2" | "$PS" -NoProfile -ExecutionPolicy Bypass \ + -File "$(winpath "$1")" >/dev/null 2>&1 + echo $? +} +expect() { # $1=label $2=actual $3=want + [ "$2" = "$3" ] || { echo "FAIL: $1 (exit $2, want $3)"; exit 1; } +} + +block="$tmp/.claude/hooks/block-dangerous-bash.ps1" +expect "safe command allowed" "$(run_hook "$block" '{"tool_input":{"command":"git status"}}')" 0 +expect "dangerous command blocked" "$(run_hook "$block" '{"tool_input":{"command":"sudo rm -rf /"}}')" 2 +expect "empty stdin allowed" "$(run_hook "$block" '')" 0 +expect "malformed stdin allowed" "$(run_hook "$block" 'not json')" 0 + +secrets="$tmp/.claude/hooks/scan-secrets.ps1" +expect "clean content allowed" "$(run_hook "$secrets" '{"tool_input":{"file_path":"a.py","content":"x = 1"}}')" 0 +expect "AWS key blocked" "$(run_hook "$secrets" '{"tool_input":{"file_path":"a.py","content":"AKIAIOSFODNN7EXAMPLE"}}')" 2 +expect "write to .env blocked" "$(run_hook "$secrets" '{"tool_input":{"file_path":"config/.env","content":"x"}}')" 2 + +enforcer="$tmp/.claude/hooks/microbit-enforcer.ps1" +export CLAUDE_PROJECT_DIR="$(winpath "$tmp")" +expect "unmarked write allowed" "$(run_hook "$enforcer" '{"tool_name":"Write","tool_input":{"file_path":"a.py"}}')" 0 +expect "non-write tool ignored" "$(run_hook "$enforcer" '{"tool_name":"Bash","tool_input":{"command":"ls"}}')" 0 +: > "$tmp/.claude/.frozen" +frozen_rc="$(run_hook "$enforcer" '{"tool_name":"Write","tool_input":{"file_path":"a.py"}}')" +rm -f "$tmp/.claude/.frozen" +expect "frozen marker blocks" "$frozen_rc" 1 +unset CLAUDE_PROJECT_DIR + +echo "PASS: powershell hooks wired per-entry, installed, and behaving ($PS)"