1use crate::error::Error;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5pub const AGENTS_SNIPPET: &str = "\
9## Shell commands
10Prefix all shell commands with `oo`. Use `oo recall \"<query>\"` to search large outputs.\
11";
12
13pub 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
43pub 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
61pub 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
72pub 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 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 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 #[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 #[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 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 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 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 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 #[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 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 assert_eq!(find_root(dir.path()), dir.path());
258 }
259
260 #[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 #[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 let custom = r#"{"hooks":[],"custom":true}"#;
297 fs::write(&hooks_path, custom).unwrap();
298
299 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 let content = fs::read_to_string(dir.path().join(".claude").join("hooks.json")).unwrap();
317 assert_eq!(content, HOOKS_JSON);
318 }
319}