Skip to main content

chub_core/team/
hooks.rs

1//! Agent hook installation and git hook generation.
2//!
3//! Generates hook configurations for supported AI coding agents
4//! and git hooks for commit-level session linking.
5
6use 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// ---------------------------------------------------------------------------
15// Supported agents
16// ---------------------------------------------------------------------------
17
18#[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
60/// Auto-detect which agents are present in the project.
61pub 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// ---------------------------------------------------------------------------
83// Hook installation results
84// ---------------------------------------------------------------------------
85
86#[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// ---------------------------------------------------------------------------
103// Claude Code hooks (.claude/settings.json)
104// ---------------------------------------------------------------------------
105
106#[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
147/// Resolve the chub binary name. Uses the bare command name so hook configs
148/// are portable across machines — chub must be on PATH.
149fn 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            // 2>/dev/null || true — never block the IDE if chub is missing or fails
159            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    // Check if already installed
188    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    // Remove existing chub hooks if force
203    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    // Add chub hooks (resolve binary path so it works even if chub isn't on PATH)
213    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    // Track all tool uses
228    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    // Don't duplicate
280    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// ---------------------------------------------------------------------------
326// Cursor hooks (.cursor/hooks.json)
327// ---------------------------------------------------------------------------
328
329#[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// ---------------------------------------------------------------------------
479// Gemini CLI hooks (.gemini/settings.json)
480// ---------------------------------------------------------------------------
481
482#[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// ---------------------------------------------------------------------------
658// Copilot hooks (.github/hooks/chub-tracking.json)
659// ---------------------------------------------------------------------------
660
661#[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
759// ---------------------------------------------------------------------------
760// Codex CLI hooks (appended to config.toml)
761// ---------------------------------------------------------------------------
762
763pub fn install_codex_hooks(project_root: &Path, force: bool) -> HookInstallResult {
764    // Codex uses ~/.codex/config.toml or project-level config.
765    // We write a project-level .codex/config.toml with [[hooks]] entries.
766    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    // If file exists and doesn't have our marker, append; otherwise write fresh
807    let content = if config_path.exists() {
808        let existing = fs::read_to_string(&config_path).unwrap_or_default();
809        if force {
810            // Remove old chub section and re-add
811            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                        // Skip until next non-hook section
820                        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        // Remove chub hooks section
865        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
894// ---------------------------------------------------------------------------
895// Git hooks (prepare-commit-msg, post-commit)
896// ---------------------------------------------------------------------------
897
898const 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    // prepare-commit-msg — add Chub-Session and Chub-Checkpoint trailers
912    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    // post-commit — snapshot session summary
926    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    // pre-push — sync session data branch to remote
940    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    // Check if our hook is already installed
960    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            // Existing hook that's not ours — back it up and chain
970            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                    // Restore backup if it exists
1030                    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    // On Windows, files are executable by default
1060}
1061
1062// ---------------------------------------------------------------------------
1063// Combined install/uninstall
1064// ---------------------------------------------------------------------------
1065
1066/// Install hooks for a specific agent (or all detected agents).
1067pub 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            // Default to Claude Code
1086            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    // Always install git hooks
1104    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
1116/// Remove all chub hooks.
1117pub 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// ---------------------------------------------------------------------------
1133// Hook stdin parsing — reads data from agent hooks
1134// ---------------------------------------------------------------------------
1135
1136/// Data passed by Claude Code hooks via stdin.
1137#[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/// Data passed by Cursor hooks via stdin.
1156#[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
1180/// Parse stdin JSON from an agent hook. Returns generic key-value map.
1181pub fn parse_hook_stdin() -> Option<serde_json::Value> {
1182    use std::io::Read;
1183    let mut input = String::new();
1184    // Non-blocking: try to read stdin, but don't hang if there's no input
1185    let stdin = std::io::stdin();
1186    let mut handle = stdin.lock();
1187
1188    // Read with a small buffer — hooks should send data quickly
1189    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
1196/// Extract tool name from Claude Code's tool_input or tool_use context.
1197pub fn extract_tool_name(hook_input: &serde_json::Value) -> Option<String> {
1198    // Claude Code PreToolUse passes tool_name at top level in some versions,
1199    // or we can extract it from the tool_input structure
1200    hook_input
1201        .get("tool_name")
1202        .and_then(|v| v.as_str())
1203        .map(|s| s.to_string())
1204}
1205
1206/// Extract file path from tool input (Write/Edit tools).
1207/// Returns a relative path when the file is inside the project root.
1208pub 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
1216/// Convert an absolute path to a relative one if it falls under the project root or CWD.
1217pub 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    // Try project root first
1223    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    // Fall back to CWD
1229    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}