intent_engine/setup/
claude_code.rs

1//! Claude Code setup module
2
3use super::common::*;
4use super::{
5    ConnectivityResult, DiagnosisCheck, DiagnosisReport, SetupModule, SetupOptions, SetupResult,
6    SetupScope,
7};
8use crate::error::{IntentError, Result};
9use serde_json::json;
10use std::env;
11use std::fs;
12use std::path::PathBuf;
13
14pub struct ClaudeCodeSetup;
15
16impl ClaudeCodeSetup {
17    /// Get the user-level .claude directory
18    fn get_user_claude_dir() -> Result<PathBuf> {
19        let home = get_home_dir()?;
20        Ok(home.join(".claude"))
21    }
22
23    /// Get the project-level .claude directory
24    fn get_project_claude_dir() -> Result<PathBuf> {
25        let current_dir = env::current_dir().map_err(IntentError::IoError)?;
26        Ok(current_dir.join(".claude"))
27    }
28
29    /// Setup for user-level installation
30    fn setup_user_level(&self, opts: &SetupOptions) -> Result<SetupResult> {
31        let mut files_modified = Vec::new();
32        let mut backups = Vec::new();
33
34        println!("📦 Setting up user-level Claude Code integration...\n");
35
36        // 1. Setup hooks directory and script
37        let claude_dir = Self::get_user_claude_dir()?;
38        let hooks_dir = claude_dir.join("hooks");
39        let hook_script = hooks_dir.join("session-start.sh");
40
41        if !opts.dry_run {
42            fs::create_dir_all(&hooks_dir).map_err(IntentError::IoError)?;
43            println!("✓ Created {}", hooks_dir.display());
44        } else {
45            println!("Would create: {}", hooks_dir.display());
46        }
47
48        // Backup existing hook script
49        if hook_script.exists() && !opts.force {
50            return Err(IntentError::InvalidInput(format!(
51                "Hook script already exists: {}. Use --force to overwrite",
52                hook_script.display()
53            )));
54        }
55
56        if hook_script.exists() && !opts.dry_run {
57            if let Some(backup) = create_backup(&hook_script)? {
58                backups.push((hook_script.clone(), backup.clone()));
59                println!("✓ Backed up hook script to {}", backup.display());
60            }
61        }
62
63        // Install hook script with absolute path
64        let hook_content = include_str!("../../templates/session-start.sh");
65        if !opts.dry_run {
66            fs::write(&hook_script, hook_content).map_err(IntentError::IoError)?;
67            set_executable(&hook_script)?;
68            files_modified.push(hook_script.clone());
69            println!("✓ Installed {}", hook_script.display());
70        } else {
71            println!("Would write: {}", hook_script.display());
72        }
73
74        // 2. Setup settings.json with absolute path
75        let settings_file = claude_dir.join("settings.json");
76        let hook_abs_path = resolve_absolute_path(&hook_script)?;
77
78        if settings_file.exists() && !opts.force {
79            return Err(IntentError::InvalidInput(format!(
80                "Settings file already exists: {}. Use --force to overwrite",
81                settings_file.display()
82            )));
83        }
84
85        if settings_file.exists() && !opts.dry_run {
86            if let Some(backup) = create_backup(&settings_file)? {
87                backups.push((settings_file.clone(), backup.clone()));
88                println!("✓ Backed up settings to {}", backup.display());
89            }
90        }
91
92        let settings = json!({
93            "hooks": {
94                "SessionStart": [{
95                    "hooks": [{
96                        "type": "command",
97                        "command": hook_abs_path.to_string_lossy()
98                    }]
99                }]
100            }
101        });
102
103        if !opts.dry_run {
104            write_json_config(&settings_file, &settings)?;
105            files_modified.push(settings_file.clone());
106            println!("✓ Created {}", settings_file.display());
107        } else {
108            println!("Would write: {}", settings_file.display());
109        }
110
111        // 3. Setup MCP configuration
112        let mcp_result = self.setup_mcp_config(opts, &mut files_modified, &mut backups)?;
113
114        Ok(SetupResult {
115            success: true,
116            message: "User-level Claude Code setup complete!".to_string(),
117            files_modified,
118            connectivity_test: Some(mcp_result),
119        })
120    }
121
122    /// Setup MCP server configuration
123    fn setup_mcp_config(
124        &self,
125        opts: &SetupOptions,
126        files_modified: &mut Vec<PathBuf>,
127        backups: &mut Vec<(PathBuf, PathBuf)>,
128    ) -> Result<ConnectivityResult> {
129        let config_path = if let Some(ref path) = opts.config_path {
130            path.clone()
131        } else {
132            let home = get_home_dir()?;
133            home.join(".claude.json")
134        };
135
136        // Find binary
137        let binary_path = find_ie_binary()?;
138        println!("✓ Found binary: {}", binary_path.display());
139
140        // Determine project directory
141        let project_dir = if let Some(ref dir) = opts.project_dir {
142            dir.clone()
143        } else {
144            env::current_dir().map_err(IntentError::IoError)?
145        };
146        let project_dir_abs = resolve_absolute_path(&project_dir)?;
147
148        // Backup existing config
149        if config_path.exists() && !opts.dry_run {
150            if let Some(backup) = create_backup(&config_path)? {
151                backups.push((config_path.clone(), backup.clone()));
152                println!("✓ Backed up MCP config to {}", backup.display());
153            }
154        }
155
156        // Read or create config
157        let mut config = read_json_config(&config_path)?;
158
159        // Check if already configured
160        if let Some(mcp_servers) = config.get("mcpServers") {
161            if mcp_servers.get("intent-engine").is_some() && !opts.force {
162                return Ok(ConnectivityResult {
163                    passed: false,
164                    details: "intent-engine already configured in MCP config".to_string(),
165                });
166            }
167        }
168
169        // Add intent-engine configuration
170        if config.get("mcpServers").is_none() {
171            config["mcpServers"] = json!({});
172        }
173
174        config["mcpServers"]["intent-engine"] = json!({
175            "command": binary_path.to_string_lossy(),
176            "args": ["mcp-server"],
177            "env": {
178                "INTENT_ENGINE_PROJECT_DIR": project_dir_abs.to_string_lossy()
179            },
180            "description": "Strategic intent and task workflow management"
181        });
182
183        if !opts.dry_run {
184            write_json_config(&config_path, &config)?;
185            files_modified.push(config_path.clone());
186            println!("✓ Updated {}", config_path.display());
187        } else {
188            println!("Would write: {}", config_path.display());
189        }
190
191        Ok(ConnectivityResult {
192            passed: true,
193            details: format!("MCP configured at {}", config_path.display()),
194        })
195    }
196
197    /// Setup for project-level installation
198    fn setup_project_level(&self, opts: &SetupOptions) -> Result<SetupResult> {
199        println!("📦 Setting up project-level Claude Code integration...\n");
200        println!("⚠️  Note: Project-level setup is for advanced users.");
201        println!("    MCP config will still be in ~/.claude.json (user-level)\n");
202
203        let mut files_modified = Vec::new();
204        let claude_dir = Self::get_project_claude_dir()?;
205        let hooks_dir = claude_dir.join("hooks");
206        let hook_script = hooks_dir.join("session-start.sh");
207
208        if !opts.dry_run {
209            fs::create_dir_all(&hooks_dir).map_err(IntentError::IoError)?;
210            println!("✓ Created {}", hooks_dir.display());
211        } else {
212            println!("Would create: {}", hooks_dir.display());
213        }
214
215        // Check if hook script already exists
216        if hook_script.exists() && !opts.force {
217            return Err(IntentError::InvalidInput(format!(
218                "Hook script already exists: {}. Use --force to overwrite",
219                hook_script.display()
220            )));
221        }
222
223        // Install hook script
224        let hook_content = include_str!("../../templates/session-start.sh");
225        if !opts.dry_run {
226            fs::write(&hook_script, hook_content).map_err(IntentError::IoError)?;
227            set_executable(&hook_script)?;
228            files_modified.push(hook_script.clone());
229            println!("✓ Installed {}", hook_script.display());
230        } else {
231            println!("Would write: {}", hook_script.display());
232        }
233
234        // Create settings.json with absolute path
235        let settings_file = claude_dir.join("settings.json");
236        let hook_abs_path = resolve_absolute_path(&hook_script)?;
237
238        // Check if settings file already exists
239        if settings_file.exists() && !opts.force {
240            return Err(IntentError::InvalidInput(format!(
241                "Settings file already exists: {}. Use --force to overwrite",
242                settings_file.display()
243            )));
244        }
245
246        let settings = json!({
247            "hooks": {
248                "SessionStart": [{
249                    "hooks": [{
250                        "type": "command",
251                        "command": hook_abs_path.to_string_lossy()
252                    }]
253                }]
254            }
255        });
256
257        if !opts.dry_run {
258            write_json_config(&settings_file, &settings)?;
259            files_modified.push(settings_file);
260            println!("✓ Created settings.json");
261        } else {
262            println!("Would write: {}", settings_file.display());
263        }
264
265        // MCP config still goes to user-level
266        let mut backups = Vec::new();
267        let mcp_result = self.setup_mcp_config(opts, &mut files_modified, &mut backups)?;
268
269        Ok(SetupResult {
270            success: true,
271            message: "Project-level setup complete!".to_string(),
272            files_modified,
273            connectivity_test: Some(mcp_result),
274        })
275    }
276}
277
278impl SetupModule for ClaudeCodeSetup {
279    fn name(&self) -> &str {
280        "claude-code"
281    }
282
283    fn setup(&self, opts: &SetupOptions) -> Result<SetupResult> {
284        match opts.scope {
285            SetupScope::User => self.setup_user_level(opts),
286            SetupScope::Project => self.setup_project_level(opts),
287            SetupScope::Both => {
288                // First user-level, then project-level
289                let user_result = self.setup_user_level(opts)?;
290                let project_result = self.setup_project_level(opts)?;
291
292                // Combine results
293                let mut files = user_result.files_modified;
294                files.extend(project_result.files_modified);
295
296                Ok(SetupResult {
297                    success: true,
298                    message: "User and project setup complete!".to_string(),
299                    files_modified: files,
300                    connectivity_test: user_result.connectivity_test,
301                })
302            },
303        }
304    }
305
306    fn diagnose(&self) -> Result<DiagnosisReport> {
307        let mut checks = Vec::new();
308        let mut suggested_fixes = Vec::new();
309
310        // Check 1: Hook script exists and is executable
311        let claude_dir = Self::get_user_claude_dir()?;
312        let hook_script = claude_dir.join("hooks").join("session-start.sh");
313
314        let hook_check = if hook_script.exists() {
315            if hook_script.metadata().map(|m| m.is_file()).unwrap_or(false) {
316                #[cfg(unix)]
317                {
318                    use std::os::unix::fs::PermissionsExt;
319                    let perms = hook_script.metadata().unwrap().permissions();
320                    let is_executable = perms.mode() & 0o111 != 0;
321                    if is_executable {
322                        DiagnosisCheck {
323                            name: "Hook script".to_string(),
324                            passed: true,
325                            details: format!("Found at {}", hook_script.display()),
326                        }
327                    } else {
328                        suggested_fixes.push(format!("chmod +x {}", hook_script.display()));
329                        DiagnosisCheck {
330                            name: "Hook script".to_string(),
331                            passed: false,
332                            details: "Script exists but is not executable".to_string(),
333                        }
334                    }
335                }
336                #[cfg(not(unix))]
337                DiagnosisCheck {
338                    name: "Hook script".to_string(),
339                    passed: true,
340                    details: format!("Found at {}", hook_script.display()),
341                }
342            } else {
343                DiagnosisCheck {
344                    name: "Hook script".to_string(),
345                    passed: false,
346                    details: "Path exists but is not a file".to_string(),
347                }
348            }
349        } else {
350            suggested_fixes.push("Run: ie setup --target claude-code".to_string());
351            DiagnosisCheck {
352                name: "Hook script".to_string(),
353                passed: false,
354                details: format!("Not found at {}", hook_script.display()),
355            }
356        };
357        checks.push(hook_check);
358
359        // Check 2: Settings file has SessionStart config
360        let settings_file = claude_dir.join("settings.json");
361        let settings_check = if settings_file.exists() {
362            match read_json_config(&settings_file) {
363                Ok(config) => {
364                    if config
365                        .get("hooks")
366                        .and_then(|h| h.get("SessionStart"))
367                        .is_some()
368                    {
369                        DiagnosisCheck {
370                            name: "Settings file".to_string(),
371                            passed: true,
372                            details: "SessionStart hook configured".to_string(),
373                        }
374                    } else {
375                        suggested_fixes
376                            .push("Run: ie setup --target claude-code --force".to_string());
377                        DiagnosisCheck {
378                            name: "Settings file".to_string(),
379                            passed: false,
380                            details: "Missing SessionStart hook configuration".to_string(),
381                        }
382                    }
383                },
384                Err(_) => DiagnosisCheck {
385                    name: "Settings file".to_string(),
386                    passed: false,
387                    details: "Failed to parse settings.json".to_string(),
388                },
389            }
390        } else {
391            suggested_fixes.push("Run: ie setup --target claude-code".to_string());
392            DiagnosisCheck {
393                name: "Settings file".to_string(),
394                passed: false,
395                details: format!("Not found at {}", settings_file.display()),
396            }
397        };
398        checks.push(settings_check);
399
400        // Check 3: MCP config exists and has intent-engine
401        let home = get_home_dir()?;
402        let mcp_config = home.join(".claude.json");
403        let mcp_check = if mcp_config.exists() {
404            match read_json_config(&mcp_config) {
405                Ok(config) => {
406                    if config
407                        .get("mcpServers")
408                        .and_then(|s| s.get("intent-engine"))
409                        .is_some()
410                    {
411                        DiagnosisCheck {
412                            name: "MCP configuration".to_string(),
413                            passed: true,
414                            details: "intent-engine MCP server configured".to_string(),
415                        }
416                    } else {
417                        suggested_fixes
418                            .push("Run: ie setup --target claude-code --force".to_string());
419                        DiagnosisCheck {
420                            name: "MCP configuration".to_string(),
421                            passed: false,
422                            details: "Missing intent-engine server entry".to_string(),
423                        }
424                    }
425                },
426                Err(_) => DiagnosisCheck {
427                    name: "MCP configuration".to_string(),
428                    passed: false,
429                    details: "Failed to parse .claude.json".to_string(),
430                },
431            }
432        } else {
433            suggested_fixes.push("Run: ie setup --target claude-code".to_string());
434            DiagnosisCheck {
435                name: "MCP configuration".to_string(),
436                passed: false,
437                details: format!("Not found at {}", mcp_config.display()),
438            }
439        };
440        checks.push(mcp_check);
441
442        // Check 4: Binary in PATH
443        let binary_check = match find_ie_binary() {
444            Ok(path) => DiagnosisCheck {
445                name: "Binary availability".to_string(),
446                passed: true,
447                details: format!("Found at {}", path.display()),
448            },
449            Err(_) => {
450                suggested_fixes.push("Install: cargo install intent-engine".to_string());
451                DiagnosisCheck {
452                    name: "Binary availability".to_string(),
453                    passed: false,
454                    details: "intent-engine not found in PATH".to_string(),
455                }
456            },
457        };
458        checks.push(binary_check);
459
460        let overall_status = checks.iter().all(|c| c.passed);
461
462        Ok(DiagnosisReport {
463            overall_status,
464            checks,
465            suggested_fixes,
466        })
467    }
468
469    fn test_connectivity(&self) -> Result<ConnectivityResult> {
470        // Test 1: Can we execute session-restore?
471        println!("Testing session-restore command...");
472        let output = std::process::Command::new("ie")
473            .args(["session-restore", "--workspace", "."])
474            .output();
475
476        match output {
477            Ok(result) => {
478                if result.status.success() {
479                    Ok(ConnectivityResult {
480                        passed: true,
481                        details: "session-restore command executed successfully".to_string(),
482                    })
483                } else {
484                    let stderr = String::from_utf8_lossy(&result.stderr);
485                    Ok(ConnectivityResult {
486                        passed: false,
487                        details: format!("session-restore failed: {}", stderr),
488                    })
489                }
490            },
491            Err(e) => Ok(ConnectivityResult {
492                passed: false,
493                details: format!("Failed to execute session-restore: {}", e),
494            }),
495        }
496    }
497}