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
262    // ========== create_claude_settings tests ==========
263
264    #[test]
265    fn test_create_claude_settings_structure() {
266        let hook_path = PathBuf::from("/tmp/session-start.sh");
267
268        let settings = ClaudeCodeSetup::create_claude_settings(&hook_path);
269
270        // Verify hooks key exists
271        assert!(settings.get("hooks").is_some());
272
273        // Verify SessionStart hook
274        let hooks = &settings["hooks"];
275        assert!(hooks.get("SessionStart").is_some());
276        let session_start = &hooks["SessionStart"];
277        assert!(session_start.is_array());
278        assert_eq!(session_start.as_array().unwrap().len(), 1);
279    }
280
281    #[test]
282    fn test_create_claude_settings_session_start_hook() {
283        let hook_path = PathBuf::from("/home/user/.claude/hooks/session-start.sh");
284
285        let settings = ClaudeCodeSetup::create_claude_settings(&hook_path);
286
287        let session_start = &settings["hooks"]["SessionStart"][0];
288        assert!(session_start.get("hooks").is_some());
289
290        let hooks_array = session_start["hooks"].as_array().unwrap();
291        assert_eq!(hooks_array.len(), 1);
292
293        let hook = &hooks_array[0];
294        assert_eq!(hook["type"], "command");
295        assert_eq!(hook["command"], "/home/user/.claude/hooks/session-start.sh");
296    }
297
298    // ========== Directory path tests ==========
299
300    #[test]
301    fn test_get_user_claude_dir() {
302        // This test depends on HOME environment variable
303        let result = ClaudeCodeSetup::get_user_claude_dir();
304        assert!(result.is_ok());
305
306        let dir = result.unwrap();
307        assert!(dir.ends_with(".claude"));
308    }
309
310    #[test]
311    fn test_get_project_claude_dir() {
312        let result = ClaudeCodeSetup::get_project_claude_dir();
313        assert!(result.is_ok());
314
315        let dir = result.unwrap();
316        assert!(dir.ends_with(".claude"));
317    }
318
319    #[test]
320    fn test_claude_code_setup_name() {
321        let setup = ClaudeCodeSetup;
322        assert_eq!(setup.name(), "claude-code");
323    }
324
325    // ========== JSON structure validation tests ==========
326
327    #[test]
328    fn test_create_claude_settings_paths_preserved() {
329        // Test with special characters in path
330        let hook_path = PathBuf::from("/home/user name/with spaces/.claude/hooks/session-start.sh");
331
332        let settings = ClaudeCodeSetup::create_claude_settings(&hook_path);
333
334        // Paths should be preserved as strings
335        let session_start_cmd = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
336            .as_str()
337            .unwrap();
338        assert!(session_start_cmd.contains("with spaces"));
339    }
340}