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
Fix: Windows hook commands PowerShell could not parse (#859, #848)
Grok runs a hook's `command` and Codex runs `commandWindows` in the session
shell, which is PowerShell by default on Windows. Impeccable wrote the POSIX
guard for Grok and cmd.exe `if exist (...)` syntax for Codex, and `hooks on`
wrote a bare quoted path; PowerShell rejects all three, so the hook never ran.

Every writer now emits `cmd /c if exist "<path>\impeccable.cmd"
"<path>\impeccable.cmd" <verb>`: one Rust builder shared by install and
`hooks on`, and its twin in the build's hooks.js. Unix output is unchanged
and Grok gets no `commandWindows` key. `.codex/hooks.json` is regenerated
because hook-build.test.mjs deep-equals it against the builder.

A Windows-only test spawns the string the way each harness does (PowerShell
5.1 and 7, Git Bash, cmd.exe). It has never executed; the Windows CI job is
its first run.

Prepared with AI assistance (Claude Code).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
  • Loading branch information
abdulwahabone and claude committed Oct 2, 2026
commit e0dd6f929fbe89aa2502cc4810de8f6392c13b21
4 changes: 2 additions & 2 deletions .codex/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
{
"type": "command",
"command": "[ ! -f \".codex/skills/impeccable/scripts/impeccable\" ] || \".codex/skills/impeccable/scripts/impeccable\" hook",
"commandWindows": "if exist \".codex/skills/impeccable/scripts/impeccable.cmd\" (\".codex/skills/impeccable/scripts/impeccable.cmd\" hook & exit /b)",
"commandWindows": "cmd /c if exist \".codex\\skills\\impeccable\\scripts\\impeccable.cmd\" \".codex\\skills\\impeccable\\scripts\\impeccable.cmd\" hook",
Comment thread
abdulwahabone marked this conversation as resolved.
"timeout": 5,
"statusMessage": "Checking UI changes"
}
Expand All @@ -20,7 +20,7 @@
{
"type": "command",
"command": "[ ! -f \".codex/skills/impeccable/scripts/impeccable\" ] || \".codex/skills/impeccable/scripts/impeccable\" hook",
"commandWindows": "if exist \".codex/skills/impeccable/scripts/impeccable.cmd\" (\".codex/skills/impeccable/scripts/impeccable.cmd\" hook & exit /b)",
"commandWindows": "cmd /c if exist \".codex\\skills\\impeccable\\scripts\\impeccable.cmd\" \".codex\\skills\\impeccable\\scripts\\impeccable.cmd\" hook",
"timeout": 30,
"statusMessage": "Design deep pass"
}
Expand Down
24 changes: 24 additions & 0 deletions crates/context/src/hook_markers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,25 @@ pub fn is_launcher_hook_command(command: &str) -> bool {
LAUNCHER_HOOK_MARKER.is_match(&normalize_hook_separators(command))
}

/// The Windows hook string for a harness that runs it in the session shell
/// (Codex `commandWindows`, Grok `command`; #848, #859): the `.cmd` shim of
/// `launcher` behind an `if exist` guard. Built here, beside the recognizers,
/// so install and `hooks on` write one string (`transformers/hooks.js`
/// `windowsLauncherCommand` is the build's twin). That shell is PowerShell by
/// default, where a bare `if exist (...)` or POSIX guard is a parse error;
/// `cmd /c` is an ordinary command there, in cmd.exe, and in Git Bash with
/// MSYS path conversion off (how Grok runs it), and hands the guard to
/// cmd.exe. A missing shim exits 0. A failing one exits non-zero, though
/// PowerShell's `-Command` reports any native failure as 1. Backslash
/// separators because PowerShell drops the quotes around a space-free argument
/// and cmd.exe reads a bare `/` as a switch. `GROK_SHELL=cmd` stays
/// unsupported: Grok escapes the quotes as `\"` on the way to cmd.exe, which
/// no quoted path survives.
pub fn windows_launcher_hook_command(launcher: &str, verb: &str) -> String {
let shim = format!("\"{}.cmd\"", launcher.replace('/', "\\"));
format!("cmd /c if exist {shim} {shim} {verb}")
Comment thread
abdulwahabone marked this conversation as resolved.
Comment thread
abdulwahabone marked this conversation as resolved.
}

/// The launcher-era markers `context` and `doctor` treat as the design hook
/// proper (the per-edit hook and Cursor's before-edit gate). Legacy siblings
/// like `hook-probe` are admin-only and do not count as an installed hook.
Expand Down Expand Up @@ -153,6 +172,7 @@ mod tests {
"\"$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/impeccable\" hook",
"[ ! -f '/x/.claude/skills/impeccable/scripts/impeccable' ] || '/x/.claude/skills/impeccable/scripts/impeccable' hook",
"if exist \".agents/skills/impeccable/scripts/impeccable.cmd\" (\".agents/skills/impeccable/scripts/impeccable.cmd\" hook & exit /b)",
r#"cmd /c if exist ".grok\skills\impeccable\scripts\impeccable.cmd" ".grok\skills\impeccable\scripts\impeccable.cmd" hook"#,
] {
assert!(is_impeccable_hook_command(cmd), "{cmd}");
assert!(is_design_hook_command(cmd), "{cmd}");
Expand Down Expand Up @@ -212,6 +232,10 @@ mod tests {
hook_program_token(".agents/skills/impeccable/scripts/impeccable hook").as_deref(),
Some(".agents/skills/impeccable/scripts/impeccable")
);
assert_eq!(
hook_program_token(&windows_launcher_hook_command(r"C:\Users\a b\.agents\skills\impeccable\scripts\impeccable", "hook")).as_deref(),
Some("C:/Users/a b/.agents/skills/impeccable/scripts/impeccable.cmd")
);
assert_eq!(hook_program_token("'/x/it'\\''s/.claude/skills/impeccable/scripts/impeccable' hook"), None);
assert_eq!(hook_program_token("echo hi"), None);
}
Expand Down
3 changes: 1 addition & 2 deletions crates/hook/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ fn stop_manifest_entry_with_windows(command: &str, windows: &str) -> Value {
/// resolves on every teammate's checkout.
const CLAUDE_HOOK_COMMAND: &str = "\"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/impeccable\" hook";
const AGENTS_HOOK_COMMAND: &str = "\".agents/skills/impeccable/scripts/impeccable\" hook";
const AGENTS_HOOK_COMMAND_WINDOWS: &str = "\".agents/skills/impeccable/scripts/impeccable.cmd\" hook";
const CURSOR_HOOK_COMMAND: &str = "\".cursor/skills/impeccable/scripts/impeccable\" hook-before-edit";
const GITHUB_HOOK_COMMAND: &str = "\"$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/impeccable\" hook";

Expand Down Expand Up @@ -138,7 +137,7 @@ fn claude_manifest() -> Value {

fn agents_manifest() -> Value {
let cmd = AGENTS_HOOK_COMMAND;
let win = AGENTS_HOOK_COMMAND_WINDOWS;
let win = &impeccable_context::hook_markers::windows_launcher_hook_command(".agents/skills/impeccable/scripts/impeccable", "hook");
obj(vec![(
"hooks",
obj(vec![
Expand Down
6 changes: 3 additions & 3 deletions crates/hook/tests/hook_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2199,14 +2199,14 @@ fn admin_on_writes_launcher_manifests_for_every_harness() {
let codex: Value = serde_json::from_str(&t.read(".codex/hooks.json")).unwrap();
let entry = &codex["hooks"]["PostToolUse"][0]["hooks"][0];
assert_eq!(entry["command"], json!("\".agents/skills/impeccable/scripts/impeccable\" hook"));
assert_eq!(entry["commandWindows"], json!("\".agents/skills/impeccable/scripts/impeccable.cmd\" hook"));
assert_eq!(entry["commandWindows"], json!(r#"cmd /c if exist ".agents\skills\impeccable\scripts\impeccable.cmd" ".agents\skills\impeccable\scripts\impeccable.cmd" hook"#));
assert_eq!(
entry.as_object().unwrap().keys().cloned().collect::<Vec<_>>(),
vec!["type", "command", "commandWindows", "timeout", "statusMessage"]
);
let stop = &codex["hooks"]["Stop"][0]["hooks"][0];
assert_eq!(stop["command"], json!("\".agents/skills/impeccable/scripts/impeccable\" hook"));
assert_eq!(stop["commandWindows"], json!("\".agents/skills/impeccable/scripts/impeccable.cmd\" hook"));
assert_eq!(stop["commandWindows"], json!(r#"cmd /c if exist ".agents\skills\impeccable\scripts\impeccable.cmd" ".agents\skills\impeccable\scripts\impeccable.cmd" hook"#));
assert_eq!(stop["timeout"], json!(30));

let cursor: Value = serde_json::from_str(&t.read(".cursor/hooks.json")).unwrap();
Expand Down Expand Up @@ -2268,7 +2268,7 @@ fn admin_on_repairs_legacy_mjs_manifests_to_the_launcher_form() {
assert_eq!(codex["hooks"]["PostToolUse"].as_array().unwrap().len(), 1);
assert_eq!(
codex["hooks"]["PostToolUse"][0]["hooks"][0]["commandWindows"],
json!("\".agents/skills/impeccable/scripts/impeccable.cmd\" hook")
json!(r#"cmd /c if exist ".agents\skills\impeccable\scripts\impeccable.cmd" ".agents\skills\impeccable\scripts\impeccable.cmd" hook"#)
);

// A launcher-form manifest written by another checkout is recognized as
Expand Down
35 changes: 20 additions & 15 deletions crates/skills/src/hook_manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@
//! writes the launcher form `[ ! -f "<skill>/scripts/impeccable" ] ||
//! "<skill>/scripts/impeccable" hook` (`hook-before-edit` for Cursor), the
//! shape the public build's `transformers/hooks.js` puts in the bundle; the
//! Codex `commandWindows` sibling runs `impeccable.cmd` behind `if exist`.
//! Codex `commandWindows` sibling and a Windows Grok `command` run
//! `impeccable.cmd` behind `cmd /c if exist`.
//! `impeccable hooks on` (`impeccable_hook::admin`) writes the same launcher
//! invocation for a project install (without the existence guard). Recognition of an
//! invocation for a project install (no existence guard on the POSIX `command`). Recognition of an
//! Impeccable-owned entry (either the JS `.mjs` generation or the launcher
//! generation) is shared with the hook crate, `context`, and `doctor` through
//! `impeccable_context::hook_markers::is_impeccable_hook_command`, so the two
//! writers and the three readers can never disagree on what counts as ours.

use impeccable_context::hook_markers::{is_impeccable_hook_command, is_launcher_hook_command, parse_manifest_jsonc};
use impeccable_context::hook_markers::{is_impeccable_hook_command, is_launcher_hook_command, parse_manifest_jsonc, windows_launcher_hook_command};
use serde_json::{Map, Value};

use crate::providers::Sys;
Expand Down Expand Up @@ -135,8 +136,8 @@ pub struct QuotedPath {
pub posix: String,
/// Double-quoted (cmd.exe treats `'` as literal, issue #533).
pub win32: String,
/// The `.cmd` shim, double-quoted, for Codex's `commandWindows`.
pub win32_cmd: String,
/// Unquoted, for the forms that do their own quoting.
pub raw: String,
}

/// JS: the `quotedPath` computed in rewriteHookCommandsForSkillRoot: the
Expand All @@ -149,7 +150,7 @@ pub fn quoted_launcher_path(skill_root: &str, provider: &str, absolute: bool) ->
Some(QuotedPath {
posix: if absolute { sh_single_quote(&path) } else { json_string(&path) },
win32: json_string(&path),
win32_cmd: json_string(&format!("{path}.cmd")),
raw: path,
})
}

Expand All @@ -162,12 +163,17 @@ pub fn quoted_launcher_path(skill_root: &str, provider: &str, absolute: bool) ->
/// the double-quoted path (cmd.exe treats `'` as literal, issue #533); the JS
/// `node -e` existence wrapper has no launcher equivalent, so the POSIX guard
/// stands there too (Claude Code on Windows runs hooks through Git Bash).
/// Grok is another exception: it has no per-OS field and runs `command` in the
/// session shell (PowerShell by default on Windows), so a Windows Grok install
/// takes the `windows_hook_command` form.
pub fn hook_command(quoted: &QuotedPath, provider: &str, win32: bool) -> String {
let verb = hook_verb(provider);
if provider == ".gemini" {
return gemini_hook_command(quoted, verb, win32);
}
let q = if provider != ".agents" && win32 { &quoted.win32 } else { &quoted.posix };
let q = match (provider, win32) {
(".gemini", _) => return gemini_hook_command(quoted, verb, win32),
(".grok", true) => return windows_hook_command(quoted, provider),
Comment thread
abdulwahabone marked this conversation as resolved.
(".agents", _) | (_, false) => &quoted.posix,
(_, true) => &quoted.win32,
};
format!("[ ! -f {q} ] || {q} {verb}")
}

Expand All @@ -180,7 +186,7 @@ pub fn hook_command(quoted: &QuotedPath, provider: &str, win32: bool) -> String
/// the PowerShell form reads `$env:GEMINI_PROJECT_DIR`, which that
/// substitution does not touch.
fn gemini_hook_command(quoted: &QuotedPath, verb: &str, win32: bool) -> String {
let path: String = serde_json::from_str(&quoted.win32).unwrap_or_default();
let path = quoted.raw.clone();
let relative = path.starts_with("$GEMINI_PROJECT_DIR");
if !win32 {
let q = if relative { path } else { quoted.posix.clone() };
Expand All @@ -196,11 +202,10 @@ fn gemini_hook_command(quoted: &QuotedPath, verb: &str, win32: bool) -> String {
}

/// JS: windowsHookCommand(quotedPath), launcher edition (`transformers/hooks.js`
/// `windowsLauncherCommand`): the `.cmd` shim behind a cmd.exe `if exist`
/// guard; `exit /b` forwards the launcher's errorlevel.
/// `windowsLauncherCommand`): `windows_launcher_hook_command` over this
/// install's launcher path.
pub fn windows_hook_command(quoted: &QuotedPath, provider: &str) -> String {
let q = &quoted.win32_cmd;
format!("if exist {q} ({q} {} & exit /b)", hook_verb(provider))
windows_launcher_hook_command(&quoted.raw, hook_verb(provider))
}

/// JS: rewriteHookCommandsForSkillRoot(value, provider, {skillRoot, absolute})
Expand Down
133 changes: 132 additions & 1 deletion crates/skills/tests/hook_manifest_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ fn codex_gets_command_windows_sibling_pointing_at_cmd_shim() {
);
assert_eq!(
entry["commandWindows"],
"if exist \".agents/skills/impeccable/scripts/impeccable.cmd\" (\".agents/skills/impeccable/scripts/impeccable.cmd\" hook & exit /b)"
r#"cmd /c if exist ".agents\skills\impeccable\scripts\impeccable.cmd" ".agents\skills\impeccable\scripts\impeccable.cmd" hook"#
);
// JS key order: existing keys, then the appended commandWindows.
let keys: Vec<&String> = entry.as_object().unwrap().keys().collect();
Expand Down Expand Up @@ -166,6 +166,137 @@ fn github_manifests_pass_through_and_grok_is_rewritten() {
);
// Grok is not Codex: no commandWindows sibling is added.
assert!(rel["hooks"]["PostToolUse"][0]["hooks"][0].get("commandWindows").is_none());
// Grok has no per-OS field and runs `command` in the session shell
// (PowerShell by default on Windows), so a Windows install writes the
// `cmd /c` form into `command` itself (#859).
let win_rel = rewrite_hook_commands_for_platform(&grok, ".grok", "/proj", false, true);
assert_eq!(
win_rel["hooks"]["PostToolUse"][0]["hooks"][0]["command"],
r#"cmd /c if exist ".grok\skills\impeccable\scripts\impeccable.cmd" ".grok\skills\impeccable\scripts\impeccable.cmd" hook"#
);
assert!(win_rel["hooks"]["PostToolUse"][0]["hooks"][0].get("commandWindows").is_none());
assert!(value_has_impeccable_hook_marker(&win_rel));
let win_abs = rewrite_hook_commands_for_platform(&grok, ".grok", "/home/u", true, true);
let w = format!("{}.cmd", p.replace('/', "\\"));
assert_eq!(
win_abs["hooks"]["PostToolUse"][0]["hooks"][0]["command"],
serde_json::Value::String(format!("cmd /c if exist \"{w}\" \"{w}\" hook"))
);
}

/// The Windows form only counts if the shells the harnesses hand it to run it
/// (#848, #859), so spawn it the way they do: PowerShell 5.1 and 7 with
/// `-Command` (Grok, Codex), Git Bash with MSYS path conversion off (Grok),
/// and cmd.exe behind Codex's raw `/C "<line>"` fallback. The stub shim saves
/// the event piped to it and exits 3. It runs only on a Windows host.
#[cfg(windows)]
#[test]
fn windows_form_runs_the_shim_in_each_harness_shell() {
use std::io::Write;
use std::os::windows::process::CommandExt;
use std::process::{Command, Stdio};

struct Shell {
name: &'static str,
spawn: fn(&str) -> Command,
/// pwsh and Git Bash are not on every Windows host; CI has both, so
/// a missing one fails there instead of skipping.
optional: bool,
/// What a shim exiting 3 comes back as; `None` where the shell never
/// reaches the shim.
exit: Option<i32>,
}
fn powershell(program: &str, line: &str) -> Command {
let mut c = Command::new(program);
c.args(["-NoProfile", "-NonInteractive", "-Command", line]);
c
}
let shells = [
// `-Command` reports any native failure as 1.
Shell { name: "powershell", spawn: |line| powershell("powershell.exe", line), optional: false, exit: Some(1) },
Shell { name: "pwsh", spawn: |line| powershell("pwsh", line), optional: true, exit: Some(1) },
Shell {
name: "git-bash",
spawn: |line| {
let mut c = Command::new("C:\\Program Files\\Git\\bin\\bash.exe");
c.args(["-c", line]).env("MSYS_NO_PATHCONV", "1").env("MSYS2_ARG_CONV_EXCL", "*");
c
},
optional: true,
exit: Some(3),
},
Shell {
name: "cmd (Codex fallback)",
spawn: |line| {
let mut c = Command::new("cmd.exe");
c.arg("/C").raw_arg(format!("\"{line}\""));
c
},
optional: false,
exit: Some(3),
},
// `GROK_SHELL=cmd`: Grok passes the line as a plain argument, so its
// quotes reach cmd.exe as `\"` and the guard never finds the shim.
Shell {
name: "cmd (GROK_SHELL=cmd)",
spawn: |line| {
let mut c = Command::new("cmd");
c.args(["/C", line]);
c
},
optional: false,
exit: None,
},
];

// A space in the install root, which the absolute form has to survive.
let root = std::env::temp_dir().join(format!("imp hook {}", std::process::id()));
let scripts = root.join(".grok").join("skills").join("impeccable").join("scripts");
let seen = scripts.join("seen-hook.txt");
std::fs::create_dir_all(&root).unwrap();
let bundle = json!({ "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": "\".grok/skills/impeccable/scripts/impeccable\" hook" }] }] } });
for present in [false, true] {
if present {
std::fs::create_dir_all(&scripts).unwrap();
std::fs::write(scripts.join("impeccable.cmd"), "@echo off\r\nfindstr \"^\" > \"%~dp0seen-%1.txt\"\r\nexit /b 3\r\n").unwrap();
}
for absolute in [false, true] {
let out = rewrite_hook_commands_for_platform(&bundle, ".grok", root.to_str().unwrap(), absolute, true);
let line = out["hooks"]["Stop"][0]["hooks"][0]["command"].as_str().unwrap();
for shell in &shells {
let _ = std::fs::remove_file(&seen);
let at = format!("{} absolute={absolute} present={present}: {line}", shell.name);
let spawned = (shell.spawn)(line).current_dir(&root).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn();
let mut child = match spawned {
Ok(child) => child,
Err(_) if shell.optional && std::env::var_os("CI").is_none() => continue,
Err(e) => panic!("{at}: {e}"),
};
let _ = child.stdin.take().unwrap().write_all(b"{\"probe\":1}\n");
// A shell that kept stdin from the shim would leave it reading forever.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let code = loop {
if let Some(status) = child.try_wait().unwrap() {
break status.code();
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
panic!("timed out: {at}");
}
std::thread::sleep(std::time::Duration::from_millis(50));
};
if present && shell.exit.is_some() {
assert_eq!(std::fs::read_to_string(&seen).unwrap_or_default().trim(), "{\"probe\":1}", "{at}");
assert_eq!(code, shell.exit, "{at}");
} else {
assert!(!seen.exists(), "{at}");
// A missing shim is a silent no-op wherever the form works.
assert!(shell.exit.is_none() || code == Some(0), "{at}: {code:?}");
}
}
}
}
let _ = std::fs::remove_dir_all(&root);
}

#[test]
Expand Down
Loading
Loading