Skip to main content

double_o/
init.rs

1use crate::error::Error;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5/// The AGENTS.md snippet printed to stdout on `oo init`.
6///
7/// Kept as a constant so unit tests can assert against the exact string.
8pub const AGENTS_SNIPPET: &str = "\
9## Shell commands
10Prefix all shell commands with `oo`. Use `oo recall \"<query>\"` to search large outputs.\
11";
12
13/// Minimal Claude Code PreToolUse hooks configuration.
14///
15/// Intercepts `--help`/`-h` Bash calls so agents use `oo help <cmd>` instead.
16///
17/// Schema: `hooks` is an object keyed by event name; each event maps to an array
18/// of hook configs with `matcher` (string tool name) and `hooks` (array of commands).
19/// `exit 2` in a Claude Code hook blocks the tool call and shows the message to Claude.
20/// Content filtering (`--help`/`-h`) is done inside the command script, not via a schema
21/// field, because Claude Code has no `command_pattern` key in this schema version.
22///
23/// Claude Code sends hook input as JSON on stdin (not via env vars). The script reads
24/// stdin with `cat`, extracts `.tool_input.command` with `jq`, checks for help flags,
25/// then either blocks (exit 2, message to stderr) or passes through (echo input, exit 0).
26pub const HOOKS_JSON: &str = r#"{
27  "hooks": {
28    "PreToolUse": [
29      {
30        "matcher": "Bash",
31        "hooks": [
32          {
33            "type": "command",
34            "command": "input=$(cat); cmd=$(echo \"$input\" | jq -r '.tool_input.command // \"\"' 2>/dev/null); if echo \"$cmd\" | grep -qE '\\-\\-help| -h$| -h '; then echo 'Use: oo help <cmd> for a token-efficient command reference' >&2; exit 2; fi; echo \"$input\""
35          }
36        ]
37      }
38    ]
39  }
40}
41"#;
42
43/// Resolve the directory in which to create `.claude/`.
44///
45/// Walks upward from `cwd` looking for a `.git` directory — this is the git
46/// root and the natural home for agent configuration.  Falls back to `cwd`
47/// when no git repo is found, so the command works outside repos too.
48pub fn find_root(cwd: &Path) -> PathBuf {
49    let mut dir = cwd.to_path_buf();
50    loop {
51        if dir.join(".git").exists() {
52            return dir;
53        }
54        match dir.parent() {
55            Some(parent) => dir = parent.to_path_buf(),
56            None => return cwd.to_path_buf(),
57        }
58    }
59}
60
61/// Run `oo init`: create `.claude/hooks.json` and print the AGENTS.md snippet.
62///
63/// Idempotent — if `hooks.json` already exists it warns and skips the write.
64/// Uses the current working directory as the starting point for git-root detection.
65pub fn run() -> Result<(), Error> {
66    let cwd = std::env::current_dir()
67        .map_err(|e| Error::Init(format!("cannot determine working directory: {e}")))?;
68
69    run_in(&cwd)
70}
71
72/// Inner implementation that accepts an explicit root — used by unit tests.
73pub fn run_in(cwd: &Path) -> Result<(), Error> {
74    let root = find_root(cwd);
75    let claude_dir = root.join(".claude");
76    let hooks_path = claude_dir.join("hooks.json");
77
78    // create_dir_all is idempotent — no TOCTOU guard needed.
79    fs::create_dir_all(&claude_dir)
80        .map_err(|e| Error::Init(format!("cannot create {}: {e}", claude_dir.display())))?;
81
82    if hooks_path.exists() {
83        // Warn but do NOT overwrite — caller's config is authoritative.
84        eprintln!(
85            "oo init: {} already exists — skipping (delete it to regenerate)",
86            hooks_path.display()
87        );
88    } else {
89        fs::write(&hooks_path, HOOKS_JSON)
90            .map_err(|e| Error::Init(format!("cannot write {}: {e}", hooks_path.display())))?;
91        println!("Created {}", hooks_path.display());
92    }
93
94    println!();
95    println!("Add this to your AGENTS.md:");
96    println!();
97    println!("{AGENTS_SNIPPET}");
98
99    Ok(())
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use tempfile::TempDir;
106
107    // -----------------------------------------------------------------------
108    // AGENTS_SNIPPET content
109    // -----------------------------------------------------------------------
110
111    #[test]
112    fn snippet_contains_oo_prefix_instruction() {
113        assert!(
114            AGENTS_SNIPPET.contains("Prefix all shell commands with `oo`"),
115            "snippet must instruct agents to prefix commands with oo"
116        );
117    }
118
119    #[test]
120    fn snippet_contains_recall_instruction() {
121        assert!(
122            AGENTS_SNIPPET.contains("oo recall"),
123            "snippet must mention oo recall for large outputs"
124        );
125    }
126
127    #[test]
128    fn snippet_has_shell_commands_heading() {
129        assert!(
130            AGENTS_SNIPPET.starts_with("## Shell commands"),
131            "snippet must start with ## Shell commands heading"
132        );
133    }
134
135    // -----------------------------------------------------------------------
136    // HOOKS_JSON validity
137    // -----------------------------------------------------------------------
138
139    #[test]
140    fn hooks_json_is_valid_json() {
141        let parsed: serde_json::Value =
142            serde_json::from_str(HOOKS_JSON).expect("HOOKS_JSON must be valid JSON");
143        assert!(
144            parsed.get("hooks").is_some(),
145            "hooks.json must have a top-level 'hooks' key"
146        );
147    }
148
149    #[test]
150    fn hooks_json_has_pretooluse_event() {
151        // Schema: hooks is an object keyed by event name.
152        let parsed: serde_json::Value = serde_json::from_str(HOOKS_JSON).unwrap();
153        let pre_tool_use = parsed["hooks"].get("PreToolUse");
154        assert!(
155            pre_tool_use.is_some(),
156            "hooks object must have a PreToolUse key"
157        );
158        assert!(
159            pre_tool_use.unwrap().as_array().is_some(),
160            "PreToolUse must be an array of hook configs"
161        );
162    }
163
164    #[test]
165    fn hooks_json_references_bash_tool() {
166        // matcher is a string tool name (not an object) in the current Claude Code schema.
167        let parsed: serde_json::Value = serde_json::from_str(HOOKS_JSON).unwrap();
168        let configs = parsed["hooks"]["PreToolUse"].as_array().unwrap();
169        let has_bash = configs
170            .iter()
171            .any(|c| c.get("matcher").and_then(|m| m.as_str()) == Some("Bash"));
172        assert!(has_bash, "at least one PreToolUse config must target Bash");
173    }
174
175    #[test]
176    fn hooks_json_hook_command_mentions_oo_help() {
177        // Each config has a "hooks" array (plural) of command objects.
178        let parsed: serde_json::Value = serde_json::from_str(HOOKS_JSON).unwrap();
179        let configs = parsed["hooks"]["PreToolUse"].as_array().unwrap();
180        let mentions_oo_help = configs.iter().any(|c| {
181            c.get("hooks")
182                .and_then(|hs| hs.as_array())
183                .is_some_and(|hs| {
184                    hs.iter().any(|h| {
185                        h.get("command")
186                            .and_then(|cmd| cmd.as_str())
187                            .is_some_and(|s| s.contains("oo help"))
188                    })
189                })
190        });
191        assert!(
192            mentions_oo_help,
193            "a hook command must mention 'oo help' so agents know the alternative"
194        );
195    }
196
197    #[test]
198    fn hooks_json_command_reads_stdin_not_env_var() {
199        // Claude Code sends hook input as JSON on stdin, not via $TOOL_INPUT env var.
200        // This test verifies the command uses the correct contract:
201        //   - `cat` to read stdin
202        //   - `jq` to parse JSON
203        //   - `.tool_input.command` to extract the right field
204        //   - `echo "$input"` to pass through on the allow path (exit 0)
205        let parsed: serde_json::Value = serde_json::from_str(HOOKS_JSON).unwrap();
206        let configs = parsed["hooks"]["PreToolUse"].as_array().unwrap();
207        let command_str = configs
208            .iter()
209            .find_map(|c| {
210                c.get("hooks")
211                    .and_then(|hs| hs.as_array())
212                    .and_then(|hs| hs.first())
213                    .and_then(|h| h.get("command"))
214                    .and_then(|cmd| cmd.as_str())
215            })
216            .expect("must have at least one hook command");
217
218        assert!(
219            command_str.contains("cat"),
220            "hook must read stdin with `cat`, not rely on env vars"
221        );
222        assert!(command_str.contains("jq"), "hook must parse JSON with `jq`");
223        assert!(
224            command_str.contains("tool_input.command"),
225            "hook must extract `.tool_input.command` — the field Claude Code sends"
226        );
227        assert!(
228            command_str.contains("echo \"$input\""),
229            "hook must echo original stdin JSON on the allow path (exit 0)"
230        );
231        assert!(
232            !command_str.contains("$TOOL_INPUT"),
233            "hook must NOT use $TOOL_INPUT env var — Claude Code does not set it"
234        );
235    }
236
237    // -----------------------------------------------------------------------
238    // find_root
239    // -----------------------------------------------------------------------
240
241    #[test]
242    fn find_root_returns_git_root() {
243        let dir = TempDir::new().unwrap();
244        let git_dir = dir.path().join(".git");
245        fs::create_dir_all(&git_dir).unwrap();
246        let sub = dir.path().join("sub");
247        fs::create_dir_all(&sub).unwrap();
248
249        // find_root from subdirectory should resolve to the git root.
250        assert_eq!(find_root(&sub), dir.path());
251    }
252
253    #[test]
254    fn find_root_falls_back_to_cwd_when_no_git() {
255        let dir = TempDir::new().unwrap();
256        // No .git → cwd is returned as-is.
257        assert_eq!(find_root(dir.path()), dir.path());
258    }
259
260    // -----------------------------------------------------------------------
261    // run_in — happy path
262    // -----------------------------------------------------------------------
263
264    #[test]
265    fn run_in_creates_claude_dir_and_hooks_json() {
266        let dir = TempDir::new().unwrap();
267        run_in(dir.path()).expect("run_in must succeed in empty dir");
268
269        let hooks_path = dir.path().join(".claude").join("hooks.json");
270        assert!(hooks_path.exists(), ".claude/hooks.json must be created");
271    }
272
273    #[test]
274    fn run_in_writes_valid_json_to_hooks_file() {
275        let dir = TempDir::new().unwrap();
276        run_in(dir.path()).unwrap();
277
278        let content = fs::read_to_string(dir.path().join(".claude").join("hooks.json")).unwrap();
279        let parsed: serde_json::Value =
280            serde_json::from_str(&content).expect("written hooks.json must be valid JSON");
281        assert!(parsed.get("hooks").is_some());
282    }
283
284    // -----------------------------------------------------------------------
285    // run_in — idempotency
286    // -----------------------------------------------------------------------
287
288    #[test]
289    fn run_in_does_not_overwrite_existing_hooks_json() {
290        let dir = TempDir::new().unwrap();
291        let claude_dir = dir.path().join(".claude");
292        fs::create_dir_all(&claude_dir).unwrap();
293        let hooks_path = claude_dir.join("hooks.json");
294
295        // Pre-existing content written by a human.
296        let custom = r#"{"hooks":[],"custom":true}"#;
297        fs::write(&hooks_path, custom).unwrap();
298
299        // run_in must leave the file untouched.
300        run_in(dir.path()).unwrap();
301
302        let after = fs::read_to_string(&hooks_path).unwrap();
303        assert_eq!(
304            after, custom,
305            "pre-existing hooks.json must not be overwritten"
306        );
307    }
308
309    #[test]
310    fn run_in_is_idempotent_twice() {
311        let dir = TempDir::new().unwrap();
312        run_in(dir.path()).expect("first run must succeed");
313        run_in(dir.path()).expect("second run must also succeed without error");
314
315        // Content should be the canonical HOOKS_JSON from the first run.
316        let content = fs::read_to_string(dir.path().join(".claude").join("hooks.json")).unwrap();
317        assert_eq!(content, HOOKS_JSON);
318    }
319}