1use std::fs;
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::{Error, Result};
12use crate::team::project::find_project_root;
13
14#[derive(Debug, Clone, PartialEq)]
19pub enum AgentKind {
20 ClaudeCode,
21 Cursor,
22 Copilot,
23 GeminiCli,
24 CodexCli,
25}
26
27impl AgentKind {
28 pub fn parse(s: &str) -> Option<Self> {
29 match s.to_lowercase().as_str() {
30 "claude-code" | "claude" | "claudecode" => Some(Self::ClaudeCode),
31 "cursor" => Some(Self::Cursor),
32 "copilot" | "copilot-cli" | "github-copilot" => Some(Self::Copilot),
33 "gemini" | "gemini-cli" => Some(Self::GeminiCli),
34 "codex" | "codex-cli" | "openai-codex" => Some(Self::CodexCli),
35 _ => None,
36 }
37 }
38
39 pub fn name(&self) -> &'static str {
40 match self {
41 Self::ClaudeCode => "claude-code",
42 Self::Cursor => "cursor",
43 Self::Copilot => "copilot",
44 Self::GeminiCli => "gemini-cli",
45 Self::CodexCli => "codex",
46 }
47 }
48
49 pub fn all() -> &'static [AgentKind] {
50 &[
51 AgentKind::ClaudeCode,
52 AgentKind::Cursor,
53 AgentKind::Copilot,
54 AgentKind::GeminiCli,
55 AgentKind::CodexCli,
56 ]
57 }
58}
59
60pub fn detect_agents(project_root: &Path) -> Vec<AgentKind> {
62 let mut found = Vec::new();
63 if project_root.join(".claude").is_dir() || project_root.join(".claude/settings.json").exists()
64 {
65 found.push(AgentKind::ClaudeCode);
66 }
67 if project_root.join(".cursor").is_dir() {
68 found.push(AgentKind::Cursor);
69 }
70 if project_root.join(".github").is_dir() {
71 found.push(AgentKind::Copilot);
72 }
73 if project_root.join(".gemini").is_dir() {
74 found.push(AgentKind::GeminiCli);
75 }
76 if project_root.join(".codex").is_dir() {
77 found.push(AgentKind::CodexCli);
78 }
79 found
80}
81
82#[derive(Debug, Clone)]
87pub struct HookInstallResult {
88 pub agent: String,
89 pub config_file: String,
90 pub action: HookAction,
91}
92
93#[derive(Debug, Clone)]
94pub enum HookAction {
95 Installed,
96 AlreadyInstalled,
97 Updated,
98 Removed,
99 Error(String),
100}
101
102#[derive(Debug, Serialize, Deserialize, Default)]
107struct ClaudeSettings {
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 hooks: Option<ClaudeHooks>,
110 #[serde(flatten)]
111 other: serde_json::Map<String, serde_json::Value>,
112}
113
114#[derive(Debug, Serialize, Deserialize, Default, Clone)]
115#[serde(rename_all = "PascalCase")]
116struct ClaudeHooks {
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 session_start: Option<Vec<ClaudeHookEntry>>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 stop: Option<Vec<ClaudeHookEntry>>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 user_prompt_submit: Option<Vec<ClaudeHookEntry>>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pre_tool_use: Option<Vec<ClaudeHookEntry>>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 post_tool_use: Option<Vec<ClaudeHookEntry>>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 session_end: Option<Vec<ClaudeHookEntry>>,
129}
130
131#[derive(Debug, Serialize, Deserialize, Clone)]
132struct ClaudeHookEntry {
133 #[serde(default)]
134 matcher: String,
135 hooks: Vec<ClaudeHookCmd>,
136}
137
138#[derive(Debug, Serialize, Deserialize, Clone)]
139struct ClaudeHookCmd {
140 #[serde(rename = "type")]
141 cmd_type: String,
142 command: String,
143}
144
145const CHUB_HOOK_MARKER: &str = "track hook";
146
147fn resolve_chub_binary() -> String {
150 "chub".to_string()
151}
152
153fn claude_hook_entry(event: &str, matcher: &str, chub_bin: &str) -> ClaudeHookEntry {
154 ClaudeHookEntry {
155 matcher: matcher.to_string(),
156 hooks: vec![ClaudeHookCmd {
157 cmd_type: "command".to_string(),
158 command: format!("{} track hook {} 2>/dev/null || true", chub_bin, event),
160 }],
161 }
162}
163
164fn is_chub_hook(entry: &ClaudeHookEntry) -> bool {
165 entry
166 .hooks
167 .iter()
168 .any(|h| h.command.contains(CHUB_HOOK_MARKER))
169}
170
171pub fn install_claude_code_hooks(project_root: &Path, force: bool) -> HookInstallResult {
172 let config_dir = project_root.join(".claude");
173 let _ = fs::create_dir_all(&config_dir);
174 let config_path = config_dir.join("settings.json");
175
176 let mut settings: ClaudeSettings = if config_path.exists() {
177 match fs::read_to_string(&config_path) {
178 Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
179 Err(_) => ClaudeSettings::default(),
180 }
181 } else {
182 ClaudeSettings::default()
183 };
184
185 let mut hooks = settings.hooks.unwrap_or_default();
186
187 let already_installed = hooks
189 .session_start
190 .as_ref()
191 .map(|entries| entries.iter().any(is_chub_hook))
192 .unwrap_or(false);
193
194 if already_installed && !force {
195 return HookInstallResult {
196 agent: "claude-code".to_string(),
197 config_file: config_path.display().to_string(),
198 action: HookAction::AlreadyInstalled,
199 };
200 }
201
202 if force {
204 remove_chub_entries(&mut hooks.session_start);
205 remove_chub_entries(&mut hooks.stop);
206 remove_chub_entries(&mut hooks.user_prompt_submit);
207 remove_chub_entries(&mut hooks.pre_tool_use);
208 remove_chub_entries(&mut hooks.post_tool_use);
209 remove_chub_entries(&mut hooks.session_end);
210 }
211
212 let chub_bin = resolve_chub_binary();
214 append_hook_entry(
215 &mut hooks.session_start,
216 claude_hook_entry("session-start", "", &chub_bin),
217 );
218 append_hook_entry(
219 &mut hooks.session_end,
220 claude_hook_entry("stop", "", &chub_bin),
221 );
222 append_hook_entry(&mut hooks.stop, claude_hook_entry("stop", "", &chub_bin));
223 append_hook_entry(
224 &mut hooks.user_prompt_submit,
225 claude_hook_entry("prompt", "", &chub_bin),
226 );
227 append_hook_entry(
229 &mut hooks.pre_tool_use,
230 claude_hook_entry("pre-tool", "", &chub_bin),
231 );
232 append_hook_entry(
233 &mut hooks.post_tool_use,
234 claude_hook_entry("post-tool", "", &chub_bin),
235 );
236
237 settings.hooks = Some(hooks);
238
239 let json = match serde_json::to_string_pretty(&settings) {
240 Ok(j) => j,
241 Err(e) => {
242 return HookInstallResult {
243 agent: "claude-code".to_string(),
244 config_file: config_path.display().to_string(),
245 action: HookAction::Error(e.to_string()),
246 }
247 }
248 };
249
250 match crate::util::atomic_write(&config_path, json.as_bytes()) {
251 Ok(_) => HookInstallResult {
252 agent: "claude-code".to_string(),
253 config_file: config_path.display().to_string(),
254 action: if already_installed {
255 HookAction::Updated
256 } else {
257 HookAction::Installed
258 },
259 },
260 Err(e) => HookInstallResult {
261 agent: "claude-code".to_string(),
262 config_file: config_path.display().to_string(),
263 action: HookAction::Error(e.to_string()),
264 },
265 }
266}
267
268fn remove_chub_entries(entries: &mut Option<Vec<ClaudeHookEntry>>) {
269 if let Some(ref mut v) = entries {
270 v.retain(|e| !is_chub_hook(e));
271 if v.is_empty() {
272 *entries = None;
273 }
274 }
275}
276
277fn append_hook_entry(entries: &mut Option<Vec<ClaudeHookEntry>>, entry: ClaudeHookEntry) {
278 let v = entries.get_or_insert_with(Vec::new);
279 if !v.iter().any(is_chub_hook) {
281 v.push(entry);
282 }
283}
284
285pub fn uninstall_claude_code_hooks(project_root: &Path) -> HookInstallResult {
286 let config_path = project_root.join(".claude/settings.json");
287 if !config_path.exists() {
288 return HookInstallResult {
289 agent: "claude-code".to_string(),
290 config_file: config_path.display().to_string(),
291 action: HookAction::Removed,
292 };
293 }
294
295 let mut settings: ClaudeSettings = match fs::read_to_string(&config_path) {
296 Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
297 Err(_) => {
298 return HookInstallResult {
299 agent: "claude-code".to_string(),
300 config_file: config_path.display().to_string(),
301 action: HookAction::Removed,
302 }
303 }
304 };
305
306 if let Some(ref mut hooks) = settings.hooks {
307 remove_chub_entries(&mut hooks.session_start);
308 remove_chub_entries(&mut hooks.stop);
309 remove_chub_entries(&mut hooks.user_prompt_submit);
310 remove_chub_entries(&mut hooks.pre_tool_use);
311 remove_chub_entries(&mut hooks.post_tool_use);
312 remove_chub_entries(&mut hooks.session_end);
313 }
314
315 let json = serde_json::to_string_pretty(&settings).unwrap_or_default();
316 let _ = crate::util::atomic_write(&config_path, json.as_bytes());
317
318 HookInstallResult {
319 agent: "claude-code".to_string(),
320 config_file: config_path.display().to_string(),
321 action: HookAction::Removed,
322 }
323}
324
325#[derive(Debug, Serialize, Deserialize, Default)]
330struct CursorHooksFile {
331 #[serde(default)]
332 version: u32,
333 #[serde(default)]
334 hooks: CursorHooks,
335}
336
337#[derive(Debug, Serialize, Deserialize, Default)]
338#[serde(rename_all = "camelCase")]
339struct CursorHooks {
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 session_start: Option<Vec<CursorHookCmd>>,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
343 session_end: Option<Vec<CursorHookCmd>>,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 before_submit_prompt: Option<Vec<CursorHookCmd>>,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
347 stop: Option<Vec<CursorHookCmd>>,
348}
349
350#[derive(Debug, Serialize, Deserialize, Clone)]
351struct CursorHookCmd {
352 command: String,
353}
354
355pub fn install_cursor_hooks(project_root: &Path, force: bool) -> HookInstallResult {
356 let config_dir = project_root.join(".cursor");
357 let _ = fs::create_dir_all(&config_dir);
358 let config_path = config_dir.join("hooks.json");
359
360 let mut file: CursorHooksFile = if config_path.exists() {
361 match fs::read_to_string(&config_path) {
362 Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
363 Err(_) => CursorHooksFile::default(),
364 }
365 } else {
366 CursorHooksFile::default()
367 };
368 file.version = 1;
369
370 let already_installed = file
371 .hooks
372 .session_start
373 .as_ref()
374 .map(|cmds| cmds.iter().any(|c| c.command.contains(CHUB_HOOK_MARKER)))
375 .unwrap_or(false);
376
377 if already_installed && !force {
378 return HookInstallResult {
379 agent: "cursor".to_string(),
380 config_file: config_path.display().to_string(),
381 action: HookAction::AlreadyInstalled,
382 };
383 }
384
385 let chub_bin = resolve_chub_binary();
386 let chub_cmd = |event: &str| CursorHookCmd {
387 command: format!("{} track hook {} --agent cursor", chub_bin, event),
388 };
389
390 let append_cursor = |cmds: &mut Option<Vec<CursorHookCmd>>, cmd: CursorHookCmd| {
391 let v = cmds.get_or_insert_with(Vec::new);
392 if !v.iter().any(|c| c.command.contains(CHUB_HOOK_MARKER)) {
393 v.push(cmd);
394 }
395 };
396
397 if force {
398 for v in [
399 &mut file.hooks.session_start,
400 &mut file.hooks.session_end,
401 &mut file.hooks.before_submit_prompt,
402 &mut file.hooks.stop,
403 ]
404 .into_iter()
405 .flatten()
406 {
407 v.retain(|c| !c.command.contains(CHUB_HOOK_MARKER));
408 }
409 }
410
411 append_cursor(&mut file.hooks.session_start, chub_cmd("session-start"));
412 append_cursor(&mut file.hooks.session_end, chub_cmd("stop"));
413 append_cursor(&mut file.hooks.before_submit_prompt, chub_cmd("prompt"));
414 append_cursor(&mut file.hooks.stop, chub_cmd("stop"));
415
416 let json = serde_json::to_string_pretty(&file).unwrap_or_default();
417 match crate::util::atomic_write(&config_path, json.as_bytes()) {
418 Ok(_) => HookInstallResult {
419 agent: "cursor".to_string(),
420 config_file: config_path.display().to_string(),
421 action: if already_installed {
422 HookAction::Updated
423 } else {
424 HookAction::Installed
425 },
426 },
427 Err(e) => HookInstallResult {
428 agent: "cursor".to_string(),
429 config_file: config_path.display().to_string(),
430 action: HookAction::Error(e.to_string()),
431 },
432 }
433}
434
435pub fn uninstall_cursor_hooks(project_root: &Path) -> HookInstallResult {
436 let config_path = project_root.join(".cursor/hooks.json");
437 if !config_path.exists() {
438 return HookInstallResult {
439 agent: "cursor".to_string(),
440 config_file: config_path.display().to_string(),
441 action: HookAction::Removed,
442 };
443 }
444
445 let mut file: CursorHooksFile = match fs::read_to_string(&config_path) {
446 Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
447 Err(_) => {
448 return HookInstallResult {
449 agent: "cursor".to_string(),
450 config_file: config_path.display().to_string(),
451 action: HookAction::Removed,
452 }
453 }
454 };
455
456 for v in [
457 &mut file.hooks.session_start,
458 &mut file.hooks.session_end,
459 &mut file.hooks.before_submit_prompt,
460 &mut file.hooks.stop,
461 ]
462 .into_iter()
463 .flatten()
464 {
465 v.retain(|c| !c.command.contains(CHUB_HOOK_MARKER));
466 }
467
468 let json = serde_json::to_string_pretty(&file).unwrap_or_default();
469 let _ = crate::util::atomic_write(&config_path, json.as_bytes());
470
471 HookInstallResult {
472 agent: "cursor".to_string(),
473 config_file: config_path.display().to_string(),
474 action: HookAction::Removed,
475 }
476}
477
478#[derive(Debug, Serialize, Deserialize, Default)]
483struct GeminiSettings {
484 #[serde(default, skip_serializing_if = "Option::is_none")]
485 hooks: Option<GeminiHooks>,
486 #[serde(flatten)]
487 other: serde_json::Map<String, serde_json::Value>,
488}
489
490#[derive(Debug, Serialize, Deserialize, Default, Clone)]
491#[serde(rename_all = "PascalCase")]
492struct GeminiHooks {
493 #[serde(default, skip_serializing_if = "Option::is_none")]
494 session_start: Option<Vec<GeminiHookEntry>>,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
496 session_end: Option<Vec<GeminiHookEntry>>,
497 #[serde(default, skip_serializing_if = "Option::is_none")]
498 before_tool: Option<Vec<GeminiHookEntry>>,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
500 after_tool: Option<Vec<GeminiHookEntry>>,
501}
502
503#[derive(Debug, Serialize, Deserialize, Clone)]
504struct GeminiHookEntry {
505 command: String,
506 #[serde(default, skip_serializing_if = "Option::is_none")]
507 matcher: Option<String>,
508}
509
510fn gemini_hook_entry(event: &str, chub_bin: &str) -> GeminiHookEntry {
511 GeminiHookEntry {
512 command: format!(
513 "{} track hook {} --agent gemini-cli 2>/dev/null || true",
514 chub_bin, event
515 ),
516 matcher: None,
517 }
518}
519
520fn is_chub_gemini_hook(entry: &GeminiHookEntry) -> bool {
521 entry.command.contains(CHUB_HOOK_MARKER)
522}
523
524pub fn install_gemini_hooks(project_root: &Path, force: bool) -> HookInstallResult {
525 let config_dir = project_root.join(".gemini");
526 let _ = fs::create_dir_all(&config_dir);
527 let config_path = config_dir.join("settings.json");
528
529 let mut settings: GeminiSettings = if config_path.exists() {
530 match fs::read_to_string(&config_path) {
531 Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
532 Err(_) => GeminiSettings::default(),
533 }
534 } else {
535 GeminiSettings::default()
536 };
537
538 let mut hooks = settings.hooks.unwrap_or_default();
539
540 let already_installed = hooks
541 .session_start
542 .as_ref()
543 .map(|entries| entries.iter().any(is_chub_gemini_hook))
544 .unwrap_or(false);
545
546 if already_installed && !force {
547 return HookInstallResult {
548 agent: "gemini-cli".to_string(),
549 config_file: config_path.display().to_string(),
550 action: HookAction::AlreadyInstalled,
551 };
552 }
553
554 let chub_bin = resolve_chub_binary();
555
556 let append_gemini = |entries: &mut Option<Vec<GeminiHookEntry>>, entry: GeminiHookEntry| {
557 let v = entries.get_or_insert_with(Vec::new);
558 if !v.iter().any(is_chub_gemini_hook) {
559 v.push(entry);
560 }
561 };
562
563 if force {
564 for v in [
565 &mut hooks.session_start,
566 &mut hooks.session_end,
567 &mut hooks.before_tool,
568 &mut hooks.after_tool,
569 ]
570 .into_iter()
571 .flatten()
572 {
573 v.retain(|e| !is_chub_gemini_hook(e));
574 }
575 }
576
577 append_gemini(
578 &mut hooks.session_start,
579 gemini_hook_entry("session-start", &chub_bin),
580 );
581 append_gemini(&mut hooks.session_end, gemini_hook_entry("stop", &chub_bin));
582 append_gemini(
583 &mut hooks.before_tool,
584 gemini_hook_entry("pre-tool", &chub_bin),
585 );
586 append_gemini(
587 &mut hooks.after_tool,
588 gemini_hook_entry("post-tool", &chub_bin),
589 );
590
591 settings.hooks = Some(hooks);
592
593 let json = serde_json::to_string_pretty(&settings).unwrap_or_default();
594 match crate::util::atomic_write(&config_path, json.as_bytes()) {
595 Ok(_) => HookInstallResult {
596 agent: "gemini-cli".to_string(),
597 config_file: config_path.display().to_string(),
598 action: if already_installed {
599 HookAction::Updated
600 } else {
601 HookAction::Installed
602 },
603 },
604 Err(e) => HookInstallResult {
605 agent: "gemini-cli".to_string(),
606 config_file: config_path.display().to_string(),
607 action: HookAction::Error(e.to_string()),
608 },
609 }
610}
611
612pub fn uninstall_gemini_hooks(project_root: &Path) -> HookInstallResult {
613 let config_path = project_root.join(".gemini/settings.json");
614 if !config_path.exists() {
615 return HookInstallResult {
616 agent: "gemini-cli".to_string(),
617 config_file: config_path.display().to_string(),
618 action: HookAction::Removed,
619 };
620 }
621
622 let mut settings: GeminiSettings = match fs::read_to_string(&config_path) {
623 Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
624 Err(_) => {
625 return HookInstallResult {
626 agent: "gemini-cli".to_string(),
627 config_file: config_path.display().to_string(),
628 action: HookAction::Removed,
629 }
630 }
631 };
632
633 if let Some(ref mut hooks) = settings.hooks {
634 for v in [
635 &mut hooks.session_start,
636 &mut hooks.session_end,
637 &mut hooks.before_tool,
638 &mut hooks.after_tool,
639 ]
640 .into_iter()
641 .flatten()
642 {
643 v.retain(|e| !is_chub_gemini_hook(e));
644 }
645 }
646
647 let json = serde_json::to_string_pretty(&settings).unwrap_or_default();
648 let _ = crate::util::atomic_write(&config_path, json.as_bytes());
649
650 HookInstallResult {
651 agent: "gemini-cli".to_string(),
652 config_file: config_path.display().to_string(),
653 action: HookAction::Removed,
654 }
655}
656
657#[derive(Debug, Serialize, Deserialize)]
662struct CopilotHooksFile {
663 version: u32,
664 hooks: CopilotHooks,
665}
666
667#[derive(Debug, Serialize, Deserialize, Default)]
668#[serde(rename_all = "camelCase")]
669struct CopilotHooks {
670 #[serde(default, skip_serializing_if = "Option::is_none")]
671 session_start: Option<Vec<CopilotHookDef>>,
672 #[serde(default, skip_serializing_if = "Option::is_none")]
673 session_end: Option<Vec<CopilotHookDef>>,
674 #[serde(default, skip_serializing_if = "Option::is_none")]
675 user_prompt_submitted: Option<Vec<CopilotHookDef>>,
676 #[serde(default, skip_serializing_if = "Option::is_none")]
677 pre_tool_use: Option<Vec<CopilotHookDef>>,
678 #[serde(default, skip_serializing_if = "Option::is_none")]
679 post_tool_use: Option<Vec<CopilotHookDef>>,
680}
681
682#[derive(Debug, Serialize, Deserialize, Clone)]
683#[serde(rename_all = "camelCase")]
684struct CopilotHookDef {
685 #[serde(rename = "type")]
686 hook_type: String,
687 bash: String,
688 #[serde(default, skip_serializing_if = "Option::is_none")]
689 timeout_sec: Option<u32>,
690}
691
692fn copilot_hook_def(event: &str, chub_bin: &str) -> CopilotHookDef {
693 CopilotHookDef {
694 hook_type: "command".to_string(),
695 bash: format!(
696 "{} track hook {} --agent copilot 2>/dev/null || true",
697 chub_bin, event
698 ),
699 timeout_sec: Some(10),
700 }
701}
702
703pub fn install_copilot_hooks(project_root: &Path, force: bool) -> HookInstallResult {
704 let hooks_dir = project_root.join(".github/hooks");
705 let _ = fs::create_dir_all(&hooks_dir);
706 let config_path = hooks_dir.join("chub-tracking.json");
707
708 if config_path.exists() && !force {
709 if let Ok(content) = fs::read_to_string(&config_path) {
710 if content.contains(CHUB_HOOK_MARKER) {
711 return HookInstallResult {
712 agent: "copilot".to_string(),
713 config_file: config_path.display().to_string(),
714 action: HookAction::AlreadyInstalled,
715 };
716 }
717 }
718 }
719
720 let chub_bin = resolve_chub_binary();
721 let file = CopilotHooksFile {
722 version: 1,
723 hooks: CopilotHooks {
724 session_start: Some(vec![copilot_hook_def("session-start", &chub_bin)]),
725 session_end: Some(vec![copilot_hook_def("stop", &chub_bin)]),
726 user_prompt_submitted: Some(vec![copilot_hook_def("prompt", &chub_bin)]),
727 pre_tool_use: Some(vec![copilot_hook_def("pre-tool", &chub_bin)]),
728 post_tool_use: Some(vec![copilot_hook_def("post-tool", &chub_bin)]),
729 },
730 };
731
732 let json = serde_json::to_string_pretty(&file).unwrap_or_default();
733 match crate::util::atomic_write(&config_path, json.as_bytes()) {
734 Ok(_) => HookInstallResult {
735 agent: "copilot".to_string(),
736 config_file: config_path.display().to_string(),
737 action: HookAction::Installed,
738 },
739 Err(e) => HookInstallResult {
740 agent: "copilot".to_string(),
741 config_file: config_path.display().to_string(),
742 action: HookAction::Error(e.to_string()),
743 },
744 }
745}
746
747pub fn uninstall_copilot_hooks(project_root: &Path) -> HookInstallResult {
748 let config_path = project_root.join(".github/hooks/chub-tracking.json");
749 if config_path.exists() {
750 let _ = fs::remove_file(&config_path);
751 }
752 HookInstallResult {
753 agent: "copilot".to_string(),
754 config_file: config_path.display().to_string(),
755 action: HookAction::Removed,
756 }
757}
758
759pub fn install_codex_hooks(project_root: &Path, force: bool) -> HookInstallResult {
764 let config_dir = project_root.join(".codex");
767 let _ = fs::create_dir_all(&config_dir);
768 let config_path = config_dir.join("config.toml");
769
770 if config_path.exists() {
771 if let Ok(content) = fs::read_to_string(&config_path) {
772 if content.contains(CHUB_HOOK_MARKER) && !force {
773 return HookInstallResult {
774 agent: "codex".to_string(),
775 config_file: config_path.display().to_string(),
776 action: HookAction::AlreadyInstalled,
777 };
778 }
779 }
780 }
781
782 let chub_bin = resolve_chub_binary();
783 let hooks_block = format!(
784 r#"
785{marker}
786[[hooks]]
787event = "SessionStart"
788command = "{chub_bin} track hook session-start --agent codex 2>/dev/null || true"
789
790[[hooks]]
791event = "Stop"
792command = "{chub_bin} track hook stop --agent codex 2>/dev/null || true"
793
794[[hooks]]
795event = "UserPromptSubmit"
796command = "{chub_bin} track hook prompt --agent codex 2>/dev/null || true"
797
798[[hooks]]
799event = "AfterToolUse"
800command = "{chub_bin} track hook post-tool --agent codex 2>/dev/null || true"
801"#,
802 marker = GIT_HOOK_MARKER,
803 chub_bin = chub_bin,
804 );
805
806 let content = if config_path.exists() {
808 let existing = fs::read_to_string(&config_path).unwrap_or_default();
809 if force {
810 let cleaned: String = existing
812 .split('\n')
813 .scan(false, |in_chub, line| {
814 if line.contains(GIT_HOOK_MARKER) {
815 *in_chub = true;
816 return Some(String::new());
817 }
818 if *in_chub {
819 if line.starts_with('[') && !line.starts_with("[[hooks]]") {
821 *in_chub = false;
822 return Some(line.to_string());
823 }
824 return Some(String::new());
825 }
826 Some(line.to_string())
827 })
828 .filter(|s| !s.is_empty())
829 .collect::<Vec<_>>()
830 .join("\n");
831 format!("{}\n{}", cleaned.trim_end(), hooks_block)
832 } else {
833 format!("{}\n{}", existing.trim_end(), hooks_block)
834 }
835 } else {
836 hooks_block.trim_start().to_string()
837 };
838
839 match crate::util::atomic_write(&config_path, content.as_bytes()) {
840 Ok(_) => HookInstallResult {
841 agent: "codex".to_string(),
842 config_file: config_path.display().to_string(),
843 action: HookAction::Installed,
844 },
845 Err(e) => HookInstallResult {
846 agent: "codex".to_string(),
847 config_file: config_path.display().to_string(),
848 action: HookAction::Error(e.to_string()),
849 },
850 }
851}
852
853pub fn uninstall_codex_hooks(project_root: &Path) -> HookInstallResult {
854 let config_path = project_root.join(".codex/config.toml");
855 if !config_path.exists() {
856 return HookInstallResult {
857 agent: "codex".to_string(),
858 config_file: config_path.display().to_string(),
859 action: HookAction::Removed,
860 };
861 }
862
863 if let Ok(content) = fs::read_to_string(&config_path) {
864 let cleaned: String = content
866 .split('\n')
867 .scan(false, |in_chub, line| {
868 if line.contains(GIT_HOOK_MARKER) {
869 *in_chub = true;
870 return Some(String::new());
871 }
872 if *in_chub {
873 if line.starts_with('[') && !line.starts_with("[[hooks]]") {
874 *in_chub = false;
875 return Some(line.to_string());
876 }
877 return Some(String::new());
878 }
879 Some(line.to_string())
880 })
881 .filter(|s| !s.is_empty())
882 .collect::<Vec<_>>()
883 .join("\n");
884 let _ = crate::util::atomic_write(&config_path, cleaned.trim().as_bytes());
885 }
886
887 HookInstallResult {
888 agent: "codex".to_string(),
889 config_file: config_path.display().to_string(),
890 action: HookAction::Removed,
891 }
892}
893
894const GIT_HOOK_MARKER: &str = "# chub track hooks";
899
900pub fn install_git_hooks(project_root: &Path) -> Result<Vec<HookInstallResult>> {
901 let git_dir = project_root.join(".git");
902 if !git_dir.is_dir() {
903 return Err(Error::Config("Not a git repository.".to_string()));
904 }
905 let hooks_dir = git_dir.join("hooks");
906 let _ = fs::create_dir_all(&hooks_dir);
907 let chub_bin = resolve_chub_binary();
908
909 let mut results = Vec::new();
910
911 results.push(install_one_git_hook(
913 &hooks_dir,
914 "prepare-commit-msg",
915 &format!(
916 r#"#!/bin/sh
917{marker}
918"{chub_bin}" track hook commit-msg --input "$1" 2>/dev/null || true
919"#,
920 marker = GIT_HOOK_MARKER,
921 chub_bin = chub_bin,
922 ),
923 ));
924
925 results.push(install_one_git_hook(
927 &hooks_dir,
928 "post-commit",
929 &format!(
930 r#"#!/bin/sh
931{marker}
932"{chub_bin}" track hook post-commit 2>/dev/null || true
933"#,
934 marker = GIT_HOOK_MARKER,
935 chub_bin = chub_bin,
936 ),
937 ));
938
939 results.push(install_one_git_hook(
941 &hooks_dir,
942 "pre-push",
943 &format!(
944 r#"#!/bin/sh
945{marker}
946"{chub_bin}" track hook pre-push --input "$1" 2>/dev/null || true
947"#,
948 marker = GIT_HOOK_MARKER,
949 chub_bin = chub_bin,
950 ),
951 ));
952
953 Ok(results)
954}
955
956fn install_one_git_hook(hooks_dir: &Path, name: &str, content: &str) -> HookInstallResult {
957 let hook_path = hooks_dir.join(name);
958
959 if hook_path.exists() {
961 if let Ok(existing) = fs::read_to_string(&hook_path) {
962 if existing.contains(GIT_HOOK_MARKER) {
963 return HookInstallResult {
964 agent: "git".to_string(),
965 config_file: hook_path.display().to_string(),
966 action: HookAction::AlreadyInstalled,
967 };
968 }
969 let backup = hooks_dir.join(format!("{}.pre-chub", name));
971 let _ = fs::rename(&hook_path, &backup);
972
973 let chained = format!(
974 r#"{content}
975# Chain: run pre-existing hook
976_chub_hook_dir="$(dirname "$0")"
977if [ -x "$_chub_hook_dir/{name}.pre-chub" ]; then
978 "$_chub_hook_dir/{name}.pre-chub" "$@"
979fi
980"#
981 );
982 return match fs::write(&hook_path, chained) {
983 Ok(_) => {
984 set_executable(&hook_path);
985 HookInstallResult {
986 agent: "git".to_string(),
987 config_file: hook_path.display().to_string(),
988 action: HookAction::Installed,
989 }
990 }
991 Err(e) => HookInstallResult {
992 agent: "git".to_string(),
993 config_file: hook_path.display().to_string(),
994 action: HookAction::Error(e.to_string()),
995 },
996 };
997 }
998 }
999
1000 match fs::write(&hook_path, content) {
1001 Ok(_) => {
1002 set_executable(&hook_path);
1003 HookInstallResult {
1004 agent: "git".to_string(),
1005 config_file: hook_path.display().to_string(),
1006 action: HookAction::Installed,
1007 }
1008 }
1009 Err(e) => HookInstallResult {
1010 agent: "git".to_string(),
1011 config_file: hook_path.display().to_string(),
1012 action: HookAction::Error(e.to_string()),
1013 },
1014 }
1015}
1016
1017pub fn uninstall_git_hooks(project_root: &Path) -> Vec<HookInstallResult> {
1018 let hooks_dir = project_root.join(".git/hooks");
1019 let mut results = Vec::new();
1020
1021 for name in &["prepare-commit-msg", "post-commit", "pre-push"] {
1022 let hook_path = hooks_dir.join(name);
1023 let backup = hooks_dir.join(format!("{}.pre-chub", name));
1024
1025 if hook_path.exists() {
1026 if let Ok(content) = fs::read_to_string(&hook_path) {
1027 if content.contains(GIT_HOOK_MARKER) {
1028 let _ = fs::remove_file(&hook_path);
1029 if backup.exists() {
1031 let _ = fs::rename(&backup, &hook_path);
1032 }
1033 }
1034 }
1035 }
1036
1037 results.push(HookInstallResult {
1038 agent: "git".to_string(),
1039 config_file: hook_path.display().to_string(),
1040 action: HookAction::Removed,
1041 });
1042 }
1043
1044 results
1045}
1046
1047#[cfg(unix)]
1048fn set_executable(path: &Path) {
1049 use std::os::unix::fs::PermissionsExt;
1050 if let Ok(meta) = fs::metadata(path) {
1051 let mut perms = meta.permissions();
1052 perms.set_mode(0o755);
1053 let _ = fs::set_permissions(path, perms);
1054 }
1055}
1056
1057#[cfg(not(unix))]
1058fn set_executable(_path: &Path) {
1059 }
1061
1062pub fn install_hooks(agent: Option<&str>, force: bool) -> Result<Vec<HookInstallResult>> {
1068 let project_root = find_project_root(None).ok_or_else(|| {
1069 Error::Config("No .chub/ directory found. Run `chub init` first.".to_string())
1070 })?;
1071
1072 let mut results = Vec::new();
1073
1074 let agents = if let Some(name) = agent {
1075 let kind = AgentKind::parse(name).ok_or_else(|| {
1076 Error::Config(format!(
1077 "Unknown agent: \"{}\". Supported: claude-code, cursor, copilot, gemini-cli, codex",
1078 name
1079 ))
1080 })?;
1081 vec![kind]
1082 } else {
1083 let detected = detect_agents(&project_root);
1084 if detected.is_empty() {
1085 vec![AgentKind::ClaudeCode]
1087 } else {
1088 detected
1089 }
1090 };
1091
1092 for kind in &agents {
1093 let result = match kind {
1094 AgentKind::ClaudeCode => install_claude_code_hooks(&project_root, force),
1095 AgentKind::Cursor => install_cursor_hooks(&project_root, force),
1096 AgentKind::Copilot => install_copilot_hooks(&project_root, force),
1097 AgentKind::GeminiCli => install_gemini_hooks(&project_root, force),
1098 AgentKind::CodexCli => install_codex_hooks(&project_root, force),
1099 };
1100 results.push(result);
1101 }
1102
1103 match install_git_hooks(&project_root) {
1105 Ok(git_results) => results.extend(git_results),
1106 Err(e) => results.push(HookInstallResult {
1107 agent: "git".to_string(),
1108 config_file: ".git/hooks/".to_string(),
1109 action: HookAction::Error(e.to_string()),
1110 }),
1111 }
1112
1113 Ok(results)
1114}
1115
1116pub fn uninstall_hooks() -> Result<Vec<HookInstallResult>> {
1118 let project_root = find_project_root(None)
1119 .ok_or_else(|| Error::Config("No .chub/ directory found.".to_string()))?;
1120
1121 let mut results = vec![
1122 uninstall_claude_code_hooks(&project_root),
1123 uninstall_cursor_hooks(&project_root),
1124 uninstall_copilot_hooks(&project_root),
1125 uninstall_gemini_hooks(&project_root),
1126 uninstall_codex_hooks(&project_root),
1127 ];
1128 results.extend(uninstall_git_hooks(&project_root));
1129 Ok(results)
1130}
1131
1132#[derive(Debug, Deserialize, Default)]
1138pub struct ClaudeCodeHookInput {
1139 #[serde(default)]
1140 pub session_id: Option<String>,
1141 #[serde(default)]
1142 pub transcript_path: Option<String>,
1143 #[serde(default)]
1144 pub model: Option<String>,
1145 #[serde(default)]
1146 pub prompt: Option<String>,
1147 #[serde(default)]
1148 pub tool_use_id: Option<String>,
1149 #[serde(default)]
1150 pub tool_input: Option<serde_json::Value>,
1151 #[serde(default)]
1152 pub tool_response: Option<serde_json::Value>,
1153}
1154
1155#[derive(Debug, Deserialize, Default)]
1157pub struct CursorHookInput {
1158 #[serde(default)]
1159 pub conversation_id: Option<String>,
1160 #[serde(default)]
1161 pub generation_id: Option<String>,
1162 #[serde(default)]
1163 pub model: Option<String>,
1164 #[serde(default)]
1165 pub transcript_path: Option<String>,
1166 #[serde(default)]
1167 pub prompt: Option<String>,
1168 #[serde(default)]
1169 pub cursor_version: Option<String>,
1170 #[serde(default)]
1171 pub duration_ms: Option<u64>,
1172 #[serde(default)]
1173 pub modified_files: Option<Vec<String>>,
1174 #[serde(default)]
1175 pub context_tokens: Option<u64>,
1176 #[serde(default)]
1177 pub context_window_size: Option<u64>,
1178}
1179
1180pub fn parse_hook_stdin() -> Option<serde_json::Value> {
1182 use std::io::Read;
1183 let mut input = String::new();
1184 let stdin = std::io::stdin();
1186 let mut handle = stdin.lock();
1187
1188 match handle.read_to_string(&mut input) {
1190 Ok(0) => None,
1191 Ok(_) => serde_json::from_str(&input).ok(),
1192 Err(_) => None,
1193 }
1194}
1195
1196pub fn extract_tool_name(hook_input: &serde_json::Value) -> Option<String> {
1198 hook_input
1201 .get("tool_name")
1202 .and_then(|v| v.as_str())
1203 .map(|s| s.to_string())
1204}
1205
1206pub fn extract_file_path(tool_input: &serde_json::Value) -> Option<String> {
1209 tool_input
1210 .get("file_path")
1211 .or_else(|| tool_input.get("notebook_path"))
1212 .and_then(|v| v.as_str())
1213 .map(relativize_path)
1214}
1215
1216pub fn relativize_path(path: &str) -> String {
1218 let p = std::path::Path::new(path);
1219 if p.is_relative() {
1220 return path.to_string();
1221 }
1222 if let Some(root) = find_project_root(None) {
1224 if let Ok(rel) = p.strip_prefix(&root) {
1225 return rel.to_string_lossy().replace('\\', "/");
1226 }
1227 }
1228 if let Ok(cwd) = std::env::current_dir() {
1230 if let Ok(rel) = p.strip_prefix(&cwd) {
1231 return rel.to_string_lossy().replace('\\', "/");
1232 }
1233 }
1234 path.to_string()
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239 use super::*;
1240
1241 #[test]
1242 fn parse_agent_kind() {
1243 assert_eq!(AgentKind::parse("claude-code"), Some(AgentKind::ClaudeCode));
1244 assert_eq!(AgentKind::parse("claude"), Some(AgentKind::ClaudeCode));
1245 assert_eq!(AgentKind::parse("cursor"), Some(AgentKind::Cursor));
1246 assert_eq!(AgentKind::parse("copilot"), Some(AgentKind::Copilot));
1247 assert_eq!(AgentKind::parse("gemini"), Some(AgentKind::GeminiCli));
1248 assert_eq!(AgentKind::parse("codex"), Some(AgentKind::CodexCli));
1249 assert!(AgentKind::parse("vim").is_none());
1250 }
1251
1252 #[test]
1253 fn claude_hook_entry_contains_marker() {
1254 let entry = claude_hook_entry("session-start", "", "chub");
1255 assert!(is_chub_hook(&entry));
1256 }
1257
1258 #[test]
1259 fn extract_file_path_from_tool_input() {
1260 let input = serde_json::json!({
1261 "file_path": "/src/main.rs",
1262 "content": "fn main() {}"
1263 });
1264 assert_eq!(extract_file_path(&input), Some("/src/main.rs".to_string()));
1265 }
1266}