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        // Setup MCP configuration
114        let mcp_result = self.setup_mcp_config(opts, &mut files_modified)?;
115
116        Ok(SetupResult {
117            success: true,
118            message: "User-level Claude Code setup complete!".to_string(),
119            files_modified,
120            connectivity_test: Some(mcp_result),
121        })
122    }
123
124    /// Setup MCP server configuration
125    fn setup_mcp_config(
126        &self,
127        opts: &SetupOptions,
128        files_modified: &mut Vec<PathBuf>,
129    ) -> Result<ConnectivityResult> {
130        let config_path = if let Some(ref path) = opts.config_path {
131            path.clone()
132        } else {
133            let home = get_home_dir()?;
134            home.join(".claude.json")
135        };
136
137        // Find binary
138        let binary_path = find_ie_binary()?;
139        println!("✓ Found binary: {}", binary_path.display());
140
141        // Read or create config
142        let mut config = read_json_config(&config_path)?;
143
144        // Check if already configured
145        if let Some(mcp_servers) = config.get("mcpServers") {
146            if mcp_servers.get("intent-engine").is_some() && !opts.force {
147                return Ok(ConnectivityResult {
148                    passed: false,
149                    details: "intent-engine already configured in MCP config".to_string(),
150                });
151            }
152        }
153
154        // Add intent-engine configuration
155        if config.get("mcpServers").is_none() {
156            config["mcpServers"] = json!({});
157        }
158
159        config["mcpServers"]["intent-engine"] = json!({
160            "command": binary_path.to_string_lossy(),
161            "args": ["mcp-server"],
162            "description": "Strategic intent and task workflow management"
163        });
164
165        write_json_config(&config_path, &config)?;
166        files_modified.push(config_path.clone());
167        println!("✓ Updated {}", config_path.display());
168
169        Ok(ConnectivityResult {
170            passed: true,
171            details: format!("MCP configured at {}", config_path.display()),
172        })
173    }
174
175    /// Setup for project-level installation
176    fn setup_project_level(&self, opts: &SetupOptions) -> Result<SetupResult> {
177        println!("📦 Setting up project-level Claude Code integration...\n");
178        println!("⚠️  Note: Project-level setup is for advanced users.");
179        println!("    MCP config will still be in ~/.claude.json (user-level)\n");
180
181        let mut files_modified = Vec::new();
182
183        // Setup hooks and settings in project-level .claude directory
184        let claude_dir = Self::get_project_claude_dir()?;
185        Self::setup_hooks_and_settings(&claude_dir, opts, &mut files_modified)?;
186
187        // MCP config still goes to user-level
188        let mcp_result = self.setup_mcp_config(opts, &mut files_modified)?;
189
190        Ok(SetupResult {
191            success: true,
192            message: "Project-level setup complete!".to_string(),
193            files_modified,
194            connectivity_test: Some(mcp_result),
195        })
196    }
197}
198
199impl SetupModule for ClaudeCodeSetup {
200    fn name(&self) -> &str {
201        "claude-code"
202    }
203
204    fn setup(&self, opts: &SetupOptions) -> Result<SetupResult> {
205        match opts.scope {
206            SetupScope::User => self.setup_user_level(opts),
207            SetupScope::Project => self.setup_project_level(opts),
208            SetupScope::Both => {
209                // First user-level, then project-level
210                let user_result = self.setup_user_level(opts)?;
211                let project_result = self.setup_project_level(opts)?;
212
213                // Combine results
214                let mut files = user_result.files_modified;
215                files.extend(project_result.files_modified);
216
217                Ok(SetupResult {
218                    success: true,
219                    message: "User and project setup complete!".to_string(),
220                    files_modified: files,
221                    connectivity_test: user_result.connectivity_test,
222                })
223            },
224        }
225    }
226
227    fn test_connectivity(&self) -> Result<ConnectivityResult> {
228        // Test 1: Can we execute session-restore?
229        println!("Testing session-restore command...");
230        let output = std::process::Command::new("ie")
231            .args(["session-restore", "--workspace", "."])
232            .output();
233
234        match output {
235            Ok(result) => {
236                if result.status.success() {
237                    Ok(ConnectivityResult {
238                        passed: true,
239                        details: "session-restore command executed successfully".to_string(),
240                    })
241                } else {
242                    let stderr = String::from_utf8_lossy(&result.stderr);
243                    Ok(ConnectivityResult {
244                        passed: false,
245                        details: format!("session-restore failed: {}", stderr),
246                    })
247                }
248            },
249            Err(e) => Ok(ConnectivityResult {
250                passed: false,
251                details: format!("Failed to execute session-restore: {}", e),
252            }),
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use std::path::PathBuf;
261    use tempfile::TempDir;
262
263    // ========== create_claude_settings tests ==========
264
265    #[test]
266    fn test_create_claude_settings_structure() {
267        let hook_path = PathBuf::from("/tmp/session-start.sh");
268
269        let settings = ClaudeCodeSetup::create_claude_settings(&hook_path);
270
271        // Verify hooks key exists
272        assert!(settings.get("hooks").is_some());
273
274        // Verify SessionStart hook
275        let hooks = &settings["hooks"];
276        assert!(hooks.get("SessionStart").is_some());
277        let session_start = &hooks["SessionStart"];
278        assert!(session_start.is_array());
279        assert_eq!(session_start.as_array().unwrap().len(), 1);
280    }
281
282    #[test]
283    fn test_create_claude_settings_session_start_hook() {
284        let hook_path = PathBuf::from("/home/user/.claude/hooks/session-start.sh");
285
286        let settings = ClaudeCodeSetup::create_claude_settings(&hook_path);
287
288        let session_start = &settings["hooks"]["SessionStart"][0];
289        assert!(session_start.get("hooks").is_some());
290
291        let hooks_array = session_start["hooks"].as_array().unwrap();
292        assert_eq!(hooks_array.len(), 1);
293
294        let hook = &hooks_array[0];
295        assert_eq!(hook["type"], "command");
296        assert_eq!(hook["command"], "/home/user/.claude/hooks/session-start.sh");
297    }
298
299    // ========== Directory path tests ==========
300
301    #[test]
302    fn test_get_user_claude_dir() {
303        // This test depends on HOME environment variable
304        let result = ClaudeCodeSetup::get_user_claude_dir();
305        assert!(result.is_ok());
306
307        let dir = result.unwrap();
308        assert!(dir.ends_with(".claude"));
309    }
310
311    #[test]
312    fn test_get_project_claude_dir() {
313        let result = ClaudeCodeSetup::get_project_claude_dir();
314        assert!(result.is_ok());
315
316        let dir = result.unwrap();
317        assert!(dir.ends_with(".claude"));
318    }
319
320    #[test]
321    fn test_claude_code_setup_name() {
322        let setup = ClaudeCodeSetup;
323        assert_eq!(setup.name(), "claude-code");
324    }
325
326    // ========== JSON structure validation tests ==========
327
328    #[test]
329    fn test_create_claude_settings_paths_preserved() {
330        // Test with special characters in path
331        let hook_path = PathBuf::from("/home/user name/with spaces/.claude/hooks/session-start.sh");
332
333        let settings = ClaudeCodeSetup::create_claude_settings(&hook_path);
334
335        // Paths should be preserved as strings
336        let session_start_cmd = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
337            .as_str()
338            .unwrap();
339        assert!(session_start_cmd.contains("with spaces"));
340    }
341
342    // ========== File system tests with tempdir ==========
343
344    #[test]
345    fn test_setup_hooks_and_settings_creates_directories() {
346        let temp_dir = TempDir::new().unwrap();
347        let claude_dir = temp_dir.path().join(".claude");
348
349        let opts = SetupOptions {
350            force: false,
351            scope: SetupScope::User,
352            config_path: None,
353        };
354        let mut files_modified = Vec::new();
355
356        let result =
357            ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified);
358
359        assert!(result.is_ok());
360
361        // Verify directories created
362        assert!(claude_dir.join("hooks").exists());
363
364        // Verify hook script created and executable
365        let hook_script = claude_dir.join("hooks/session-start.sh");
366        assert!(hook_script.exists());
367
368        // Verify settings.json created
369        let settings_file = claude_dir.join("settings.json");
370        assert!(settings_file.exists());
371
372        // Verify files tracked
373        assert_eq!(files_modified.len(), 2);
374    }
375
376    #[test]
377    fn test_setup_hooks_and_settings_force_overwrites() {
378        let temp_dir = TempDir::new().unwrap();
379        let claude_dir = temp_dir.path().join(".claude");
380
381        // First setup without force
382        let opts = SetupOptions {
383            force: false,
384            scope: SetupScope::User,
385            config_path: None,
386        };
387        let mut files_modified = Vec::new();
388        ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified).unwrap();
389
390        // Second setup without force should fail
391        let result =
392            ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified);
393        assert!(result.is_err());
394
395        // Third setup with force should succeed
396        let opts_force = SetupOptions {
397            force: true,
398            scope: SetupScope::User,
399            config_path: None,
400        };
401        let mut files_modified2 = Vec::new();
402        let result = ClaudeCodeSetup::setup_hooks_and_settings(
403            &claude_dir,
404            &opts_force,
405            &mut files_modified2,
406        );
407        assert!(result.is_ok());
408    }
409
410    #[test]
411    fn test_setup_hooks_and_settings_hook_content() {
412        let temp_dir = TempDir::new().unwrap();
413        let claude_dir = temp_dir.path().join(".claude");
414
415        let opts = SetupOptions {
416            force: false,
417            scope: SetupScope::User,
418            config_path: None,
419        };
420        let mut files_modified = Vec::new();
421
422        ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified).unwrap();
423
424        // Read and verify hook script content
425        let hook_script = claude_dir.join("hooks/session-start.sh");
426        let content = std::fs::read_to_string(&hook_script).unwrap();
427
428        // Should contain shebang and ie command
429        assert!(content.contains("#!/"));
430        assert!(content.contains("ie ") || content.contains("session-restore"));
431    }
432
433    #[test]
434    fn test_setup_hooks_and_settings_json_valid() {
435        let temp_dir = TempDir::new().unwrap();
436        let claude_dir = temp_dir.path().join(".claude");
437
438        let opts = SetupOptions {
439            force: false,
440            scope: SetupScope::User,
441            config_path: None,
442        };
443        let mut files_modified = Vec::new();
444
445        ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified).unwrap();
446
447        // Read and parse settings.json
448        let settings_file = claude_dir.join("settings.json");
449        let content = std::fs::read_to_string(&settings_file).unwrap();
450        let settings: serde_json::Value = serde_json::from_str(&content).unwrap();
451
452        // Verify structure
453        assert!(settings.get("hooks").is_some());
454        assert!(settings["hooks"].get("SessionStart").is_some());
455    }
456
457    #[test]
458    fn test_setup_mcp_config_creates_new_file() {
459        let temp_dir = TempDir::new().unwrap();
460        let config_path = temp_dir.path().join(".claude.json");
461
462        let opts = SetupOptions {
463            force: false,
464            scope: SetupScope::User,
465            config_path: Some(config_path.clone()),
466        };
467        let mut files_modified = Vec::new();
468
469        let setup = ClaudeCodeSetup;
470        let result = setup.setup_mcp_config(&opts, &mut files_modified);
471
472        assert!(result.is_ok());
473        assert!(config_path.exists());
474
475        // Verify content
476        let content = std::fs::read_to_string(&config_path).unwrap();
477        let config: serde_json::Value = serde_json::from_str(&content).unwrap();
478        assert!(config["mcpServers"]["intent-engine"].is_object());
479    }
480
481    #[test]
482    fn test_setup_mcp_config_preserves_existing() {
483        let temp_dir = TempDir::new().unwrap();
484        let config_path = temp_dir.path().join(".claude.json");
485
486        // Create existing config with other servers
487        let existing_config = json!({
488            "mcpServers": {
489                "other-server": {
490                    "command": "other-cmd"
491                }
492            }
493        });
494        std::fs::write(
495            &config_path,
496            serde_json::to_string_pretty(&existing_config).unwrap(),
497        )
498        .unwrap();
499
500        let opts = SetupOptions {
501            force: true,
502            scope: SetupScope::User,
503            config_path: Some(config_path.clone()),
504        };
505        let mut files_modified = Vec::new();
506
507        let setup = ClaudeCodeSetup;
508        setup.setup_mcp_config(&opts, &mut files_modified).unwrap();
509
510        // Verify both servers exist
511        let content = std::fs::read_to_string(&config_path).unwrap();
512        let config: serde_json::Value = serde_json::from_str(&content).unwrap();
513        assert!(config["mcpServers"]["other-server"].is_object());
514        assert!(config["mcpServers"]["intent-engine"].is_object());
515    }
516
517    #[test]
518    fn test_setup_mcp_config_no_force_skips_existing() {
519        let temp_dir = TempDir::new().unwrap();
520        let config_path = temp_dir.path().join(".claude.json");
521
522        // Create existing config with intent-engine
523        let existing_config = json!({
524            "mcpServers": {
525                "intent-engine": {
526                    "command": "old-cmd"
527                }
528            }
529        });
530        std::fs::write(
531            &config_path,
532            serde_json::to_string_pretty(&existing_config).unwrap(),
533        )
534        .unwrap();
535
536        let opts = SetupOptions {
537            force: false,
538            scope: SetupScope::User,
539            config_path: Some(config_path.clone()),
540        };
541        let mut files_modified = Vec::new();
542
543        let setup = ClaudeCodeSetup;
544        let result = setup.setup_mcp_config(&opts, &mut files_modified).unwrap();
545
546        // Should return false (already configured)
547        assert!(!result.passed);
548        assert!(result.details.contains("already configured"));
549    }
550}