intent_engine/setup/
claude_code.rs

1//! Claude Code setup module
2
3use super::common::*;
4use super::{ConnectivityResult, SetupModule, SetupOptions, SetupResult, SetupScope};
5use crate::error::{IntentError, Result};
6use serde_json::json;
7use std::env;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11pub struct ClaudeCodeSetup;
12
13impl ClaudeCodeSetup {
14    /// Get the user-level .claude directory
15    fn get_user_claude_dir() -> Result<PathBuf> {
16        let home = get_home_dir()?;
17        Ok(home.join(".claude"))
18    }
19
20    /// Get the project-level .claude directory
21    fn get_project_claude_dir() -> Result<PathBuf> {
22        let current_dir = env::current_dir().map_err(IntentError::IoError)?;
23        Ok(current_dir.join(".claude"))
24    }
25
26    /// Create Claude Code settings JSON configuration
27    ///
28    /// Generates the hooks configuration for SessionStart event.
29    /// This configuration is shared between user-level and project-level setups.
30    ///
31    /// # Arguments
32    /// * `hook_path` - Absolute path to the SessionStart hook script
33    fn create_claude_settings(hook_path: &Path) -> serde_json::Value {
34        json!({
35            "hooks": {
36                "SessionStart": [{
37                    "hooks": [{
38                        "type": "command",
39                        "command": hook_path.to_string_lossy()
40                    }]
41                }]
42            }
43        })
44    }
45
46    /// Common setup logic for hooks and settings
47    ///
48    /// Sets up the session-start hook script and settings.json in the given Claude directory.
49    /// This function is shared between user-level and project-level setup.
50    ///
51    /// # Arguments
52    /// * `claude_dir` - The .claude directory (user-level or project-level)
53    /// * `opts` - Setup options (includes force flag)
54    /// * `files_modified` - Mutable vector to track modified files
55    fn setup_hooks_and_settings(
56        claude_dir: &Path,
57        opts: &SetupOptions,
58        files_modified: &mut Vec<PathBuf>,
59    ) -> Result<()> {
60        let hooks_dir = claude_dir.join("hooks");
61        let hook_script = hooks_dir.join("session-start.sh");
62
63        // Create hooks directory
64        fs::create_dir_all(&hooks_dir).map_err(IntentError::IoError)?;
65        println!("✓ Created {}", hooks_dir.display());
66
67        // Check if hook script already exists
68        if hook_script.exists() && !opts.force {
69            return Err(IntentError::InvalidInput(format!(
70                "Hook script already exists: {}. Use --force to overwrite",
71                hook_script.display()
72            )));
73        }
74
75        // Install session-start hook script
76        let hook_content = include_str!("../../templates/session-start.sh");
77        fs::write(&hook_script, hook_content).map_err(IntentError::IoError)?;
78        set_executable(&hook_script)?;
79        files_modified.push(hook_script.clone());
80        println!("✓ Installed {}", hook_script.display());
81
82        // Setup settings.json with absolute paths
83        let settings_file = claude_dir.join("settings.json");
84        let hook_abs_path = resolve_absolute_path(&hook_script)?;
85
86        // Check if settings file already exists
87        if settings_file.exists() && !opts.force {
88            return Err(IntentError::InvalidInput(format!(
89                "Settings file already exists: {}. Use --force to overwrite",
90                settings_file.display()
91            )));
92        }
93
94        let settings = Self::create_claude_settings(&hook_abs_path);
95
96        write_json_config(&settings_file, &settings)?;
97        files_modified.push(settings_file.clone());
98        println!("✓ Created {}", settings_file.display());
99
100        Ok(())
101    }
102
103    /// Setup for user-level installation
104    fn setup_user_level(&self, opts: &SetupOptions) -> Result<SetupResult> {
105        let mut files_modified = Vec::new();
106
107        println!("📦 Setting up user-level Claude Code integration...\n");
108
109        // Setup hooks and settings in user-level .claude directory
110        let claude_dir = Self::get_user_claude_dir()?;
111        Self::setup_hooks_and_settings(&claude_dir, opts, &mut files_modified)?;
112
113        Ok(SetupResult {
114            success: true,
115            message: "User-level Claude Code setup complete!".to_string(),
116            files_modified,
117            connectivity_test: None,
118        })
119    }
120
121    /// Setup for project-level installation
122    fn setup_project_level(&self, opts: &SetupOptions) -> Result<SetupResult> {
123        println!("📦 Setting up project-level Claude Code integration...\n");
124        println!("⚠️  Note: Project-level setup is for advanced users.\n");
125
126        let mut files_modified = Vec::new();
127
128        // Setup hooks and settings in project-level .claude directory
129        let claude_dir = Self::get_project_claude_dir()?;
130        Self::setup_hooks_and_settings(&claude_dir, opts, &mut files_modified)?;
131
132        Ok(SetupResult {
133            success: true,
134            message: "Project-level setup complete!".to_string(),
135            files_modified,
136            connectivity_test: None,
137        })
138    }
139}
140
141impl SetupModule for ClaudeCodeSetup {
142    fn name(&self) -> &str {
143        "claude-code"
144    }
145
146    fn setup(&self, opts: &SetupOptions) -> Result<SetupResult> {
147        match opts.scope {
148            SetupScope::User => self.setup_user_level(opts),
149            SetupScope::Project => self.setup_project_level(opts),
150            SetupScope::Both => {
151                // First user-level, then project-level
152                let user_result = self.setup_user_level(opts)?;
153                let project_result = self.setup_project_level(opts)?;
154
155                // Combine results
156                let mut files = user_result.files_modified;
157                files.extend(project_result.files_modified);
158
159                Ok(SetupResult {
160                    success: true,
161                    message: "User and project setup complete!".to_string(),
162                    files_modified: files,
163                    connectivity_test: user_result.connectivity_test,
164                })
165            },
166        }
167    }
168
169    fn test_connectivity(&self) -> Result<ConnectivityResult> {
170        // Test 1: Can we execute session-restore?
171        println!("Testing session-restore command...");
172        let output = std::process::Command::new("ie")
173            .args(["session-restore", "--workspace", "."])
174            .output();
175
176        match output {
177            Ok(result) => {
178                if result.status.success() {
179                    Ok(ConnectivityResult {
180                        passed: true,
181                        details: "session-restore command executed successfully".to_string(),
182                    })
183                } else {
184                    let stderr = String::from_utf8_lossy(&result.stderr);
185                    Ok(ConnectivityResult {
186                        passed: false,
187                        details: format!("session-restore failed: {}", stderr),
188                    })
189                }
190            },
191            Err(e) => Ok(ConnectivityResult {
192                passed: false,
193                details: format!("Failed to execute session-restore: {}", e),
194            }),
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use std::path::PathBuf;
203    use tempfile::TempDir;
204
205    // ========== create_claude_settings tests ==========
206
207    #[test]
208    fn test_create_claude_settings_structure() {
209        let hook_path = PathBuf::from("/tmp/session-start.sh");
210
211        let settings = ClaudeCodeSetup::create_claude_settings(&hook_path);
212
213        // Verify hooks key exists
214        assert!(settings.get("hooks").is_some());
215
216        // Verify SessionStart hook
217        let hooks = &settings["hooks"];
218        assert!(hooks.get("SessionStart").is_some());
219        let session_start = &hooks["SessionStart"];
220        assert!(session_start.is_array());
221        assert_eq!(session_start.as_array().unwrap().len(), 1);
222    }
223
224    #[test]
225    fn test_create_claude_settings_session_start_hook() {
226        let hook_path = PathBuf::from("/home/user/.claude/hooks/session-start.sh");
227
228        let settings = ClaudeCodeSetup::create_claude_settings(&hook_path);
229
230        let session_start = &settings["hooks"]["SessionStart"][0];
231        assert!(session_start.get("hooks").is_some());
232
233        let hooks_array = session_start["hooks"].as_array().unwrap();
234        assert_eq!(hooks_array.len(), 1);
235
236        let hook = &hooks_array[0];
237        assert_eq!(hook["type"], "command");
238        assert_eq!(hook["command"], "/home/user/.claude/hooks/session-start.sh");
239    }
240
241    // ========== Directory path tests ==========
242
243    #[test]
244    fn test_get_user_claude_dir() {
245        // This test depends on HOME environment variable
246        let result = ClaudeCodeSetup::get_user_claude_dir();
247        assert!(result.is_ok());
248
249        let dir = result.unwrap();
250        assert!(dir.ends_with(".claude"));
251    }
252
253    #[test]
254    fn test_get_project_claude_dir() {
255        let result = ClaudeCodeSetup::get_project_claude_dir();
256        assert!(result.is_ok());
257
258        let dir = result.unwrap();
259        assert!(dir.ends_with(".claude"));
260    }
261
262    #[test]
263    fn test_claude_code_setup_name() {
264        let setup = ClaudeCodeSetup;
265        assert_eq!(setup.name(), "claude-code");
266    }
267
268    // ========== JSON structure validation tests ==========
269
270    #[test]
271    fn test_create_claude_settings_paths_preserved() {
272        // Test with special characters in path
273        let hook_path = PathBuf::from("/home/user name/with spaces/.claude/hooks/session-start.sh");
274
275        let settings = ClaudeCodeSetup::create_claude_settings(&hook_path);
276
277        // Paths should be preserved as strings
278        let session_start_cmd = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
279            .as_str()
280            .unwrap();
281        assert!(session_start_cmd.contains("with spaces"));
282    }
283
284    // ========== File system tests with tempdir ==========
285
286    #[test]
287    fn test_setup_hooks_and_settings_creates_directories() {
288        let temp_dir = TempDir::new().unwrap();
289        let claude_dir = temp_dir.path().join(".claude");
290
291        let opts = SetupOptions {
292            force: false,
293            scope: SetupScope::User,
294            config_path: None,
295        };
296        let mut files_modified = Vec::new();
297
298        let result =
299            ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified);
300
301        assert!(result.is_ok());
302
303        // Verify directories created
304        assert!(claude_dir.join("hooks").exists());
305
306        // Verify hook script created and executable
307        let hook_script = claude_dir.join("hooks/session-start.sh");
308        assert!(hook_script.exists());
309
310        // Verify settings.json created
311        let settings_file = claude_dir.join("settings.json");
312        assert!(settings_file.exists());
313
314        // Verify files tracked
315        assert_eq!(files_modified.len(), 2);
316    }
317
318    #[test]
319    fn test_setup_hooks_and_settings_force_overwrites() {
320        let temp_dir = TempDir::new().unwrap();
321        let claude_dir = temp_dir.path().join(".claude");
322
323        // First setup without force
324        let opts = SetupOptions {
325            force: false,
326            scope: SetupScope::User,
327            config_path: None,
328        };
329        let mut files_modified = Vec::new();
330        ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified).unwrap();
331
332        // Second setup without force should fail
333        let result =
334            ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified);
335        assert!(result.is_err());
336
337        // Third setup with force should succeed
338        let opts_force = SetupOptions {
339            force: true,
340            scope: SetupScope::User,
341            config_path: None,
342        };
343        let mut files_modified2 = Vec::new();
344        let result = ClaudeCodeSetup::setup_hooks_and_settings(
345            &claude_dir,
346            &opts_force,
347            &mut files_modified2,
348        );
349        assert!(result.is_ok());
350    }
351
352    #[test]
353    fn test_setup_hooks_and_settings_hook_content() {
354        let temp_dir = TempDir::new().unwrap();
355        let claude_dir = temp_dir.path().join(".claude");
356
357        let opts = SetupOptions {
358            force: false,
359            scope: SetupScope::User,
360            config_path: None,
361        };
362        let mut files_modified = Vec::new();
363
364        ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified).unwrap();
365
366        // Read and verify hook script content
367        let hook_script = claude_dir.join("hooks/session-start.sh");
368        let content = std::fs::read_to_string(&hook_script).unwrap();
369
370        // Should contain shebang and ie command
371        assert!(content.contains("#!/"));
372        assert!(content.contains("ie ") || content.contains("session-restore"));
373    }
374
375    #[test]
376    fn test_setup_hooks_and_settings_json_valid() {
377        let temp_dir = TempDir::new().unwrap();
378        let claude_dir = temp_dir.path().join(".claude");
379
380        let opts = SetupOptions {
381            force: false,
382            scope: SetupScope::User,
383            config_path: None,
384        };
385        let mut files_modified = Vec::new();
386
387        ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified).unwrap();
388
389        // Read and parse settings.json
390        let settings_file = claude_dir.join("settings.json");
391        let content = std::fs::read_to_string(&settings_file).unwrap();
392        let settings: serde_json::Value = serde_json::from_str(&content).unwrap();
393
394        // Verify structure
395        assert!(settings.get("hooks").is_some());
396        assert!(settings["hooks"].get("SessionStart").is_some());
397    }
398}