Skip to main content

git_worktree_manager/operations/
claude_settings.rs

1//! Inline `--settings` JSON for `gw`-spawned Claude sessions.
2//!
3//! Claude Code's `--settings <json>` flag merges its content into the user's
4//! existing settings layers (user/project/local). Array-valued keys such as
5//! `hooks` are concatenated and deduplicated across scopes, so injecting a
6//! single PreToolUse(Bash) hook here adds the gw guard without disturbing
7//! anything the user already has configured.
8
9use std::path::PathBuf;
10
11use serde_json::json;
12
13use crate::error::{CwError, Result};
14use crate::operations::spawn_spec::quote_path_for_shell;
15
16/// Build the `--settings` JSON payload that registers `gw guard` as a
17/// PreToolUse(Bash) hook.
18///
19/// The hook command invokes the **currently-running** binary (resolved via
20/// `current_exe()`) so multi-install setups (Homebrew + cargo) call back
21/// into the same `gw` that spawned the session. `CW_SPAWN_AI_BIN` overrides
22/// the path — used by integration tests to point at a cargo-built binary.
23pub fn guard_settings_json() -> Result<String> {
24    let self_exe = resolve_self_exe()?;
25    let command = format!("{} guard --tool-input -", quote_path_for_shell(&self_exe));
26
27    let payload = json!({
28        "hooks": {
29            "PreToolUse": [
30                {
31                    "matcher": "Bash",
32                    "hooks": [
33                        { "type": "command", "command": command }
34                    ]
35                }
36            ]
37        }
38    });
39
40    Ok(payload.to_string())
41}
42
43fn resolve_self_exe() -> Result<PathBuf> {
44    if let Some(s) = std::env::var_os("CW_SPAWN_AI_BIN") {
45        return Ok(PathBuf::from(s));
46    }
47    std::env::current_exe().map_err(|e| {
48        CwError::Other(format!(
49            "claude_settings: cannot resolve current executable path: {}. \
50             Set CW_SPAWN_AI_BIN to override.",
51            e
52        ))
53    })
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59    use crate::operations::test_env::{env_lock, EnvGuard};
60    use serde_json::Value;
61
62    fn parse(json: &str) -> Value {
63        serde_json::from_str(json).expect("valid json")
64    }
65
66    #[test]
67    fn payload_has_pretooluse_bash_hook() {
68        let _lock = env_lock();
69        let _guard = EnvGuard::capture(&["CW_SPAWN_AI_BIN"]);
70        std::env::set_var("CW_SPAWN_AI_BIN", "/usr/local/bin/gw");
71
72        let s = guard_settings_json().expect("ok");
73        let v = parse(&s);
74        let arr = v["hooks"]["PreToolUse"]
75            .as_array()
76            .expect("PreToolUse array");
77        assert_eq!(arr.len(), 1);
78        assert_eq!(arr[0]["matcher"], "Bash");
79        let inner = arr[0]["hooks"].as_array().expect("inner hooks");
80        assert_eq!(inner.len(), 1);
81        assert_eq!(inner[0]["type"], "command");
82        let cmd = inner[0]["command"].as_str().expect("command str");
83        assert!(cmd.ends_with(" guard --tool-input -"));
84        assert!(cmd.contains("/usr/local/bin/gw"));
85    }
86
87    #[test]
88    fn cw_spawn_ai_bin_overrides_path() {
89        let _lock = env_lock();
90        let _guard = EnvGuard::capture(&["CW_SPAWN_AI_BIN"]);
91        std::env::set_var("CW_SPAWN_AI_BIN", "/tmp/custom/gw");
92
93        let s = guard_settings_json().expect("ok");
94        let v = parse(&s);
95        let cmd = v["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
96            .as_str()
97            .expect("str");
98        assert!(cmd.contains("/tmp/custom/gw"));
99    }
100
101    #[test]
102    fn path_with_spaces_is_quoted() {
103        let _lock = env_lock();
104        let _guard = EnvGuard::capture(&["CW_SPAWN_AI_BIN"]);
105        std::env::set_var("CW_SPAWN_AI_BIN", "/tmp/dir with space/gw");
106
107        let s = guard_settings_json().expect("ok");
108        let v = parse(&s);
109        let cmd = v["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
110            .as_str()
111            .expect("str");
112        assert!(cmd.starts_with("\"/tmp/dir with space/gw\""));
113    }
114
115    #[test]
116    fn windows_backslash_path_is_quoted() {
117        let _lock = env_lock();
118        let _guard = EnvGuard::capture(&["CW_SPAWN_AI_BIN"]);
119        std::env::set_var("CW_SPAWN_AI_BIN", r"C:\Program Files\gw\gw.exe");
120
121        let s = guard_settings_json().expect("ok");
122        let v = parse(&s);
123        let cmd = v["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
124            .as_str()
125            .expect("str");
126        assert!(cmd.starts_with("\"C:\\Program Files\\gw\\gw.exe\""));
127    }
128}