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 session-start hook script
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        // Install format-ie-output hook script
75        let format_hook_script = hooks_dir.join("format-ie-output.sh");
76        let format_hook_content = include_str!("../../templates/format-ie-output.sh");
77
78        if format_hook_script.exists() && !opts.force {
79            return Err(IntentError::InvalidInput(format!(
80                "Format hook already exists: {}. Use --force to overwrite",
81                format_hook_script.display()
82            )));
83        }
84
85        if format_hook_script.exists() && !opts.dry_run {
86            if let Some(backup) = create_backup(&format_hook_script)? {
87                backups.push((format_hook_script.clone(), backup.clone()));
88                println!("✓ Backed up format hook to {}", backup.display());
89            }
90        }
91
92        if !opts.dry_run {
93            fs::write(&format_hook_script, format_hook_content).map_err(IntentError::IoError)?;
94            set_executable(&format_hook_script)?;
95            files_modified.push(format_hook_script.clone());
96            println!("✓ Installed {}", format_hook_script.display());
97        } else {
98            println!("Would write: {}", format_hook_script.display());
99        }
100
101        // 2. Setup settings.json with absolute paths
102        let settings_file = claude_dir.join("settings.json");
103        let hook_abs_path = resolve_absolute_path(&hook_script)?;
104        let format_hook_abs_path = resolve_absolute_path(&format_hook_script)?;
105
106        if settings_file.exists() && !opts.force {
107            return Err(IntentError::InvalidInput(format!(
108                "Settings file already exists: {}. Use --force to overwrite",
109                settings_file.display()
110            )));
111        }
112
113        if settings_file.exists() && !opts.dry_run {
114            if let Some(backup) = create_backup(&settings_file)? {
115                backups.push((settings_file.clone(), backup.clone()));
116                println!("✓ Backed up settings to {}", backup.display());
117            }
118        }
119
120        let settings = json!({
121            "hooks": {
122                "SessionStart": [{
123                    "hooks": [{
124                        "type": "command",
125                        "command": hook_abs_path.to_string_lossy()
126                    }]
127                }],
128                "PostToolUse": [
129                    {
130                        "matcher": "mcp__intent-engine__task_context",
131                        "hooks": [{
132                            "type": "command",
133                            "command": format_hook_abs_path.to_string_lossy()
134                        }]
135                    },
136                    {
137                        "matcher": "mcp__intent-engine__task_get",
138                        "hooks": [{
139                            "type": "command",
140                            "command": format_hook_abs_path.to_string_lossy()
141                        }]
142                    },
143                    {
144                        "matcher": "mcp__intent-engine__current_task_get",
145                        "hooks": [{
146                            "type": "command",
147                            "command": format_hook_abs_path.to_string_lossy()
148                        }]
149                    },
150                    {
151                        "matcher": "mcp__intent-engine__task_list",
152                        "hooks": [{
153                            "type": "command",
154                            "command": format_hook_abs_path.to_string_lossy()
155                        }]
156                    },
157                    {
158                        "matcher": "mcp__intent-engine__task_pick_next",
159                        "hooks": [{
160                            "type": "command",
161                            "command": format_hook_abs_path.to_string_lossy()
162                        }]
163                    },
164                    {
165                        "matcher": "mcp__intent-engine__unified_search",
166                        "hooks": [{
167                            "type": "command",
168                            "command": format_hook_abs_path.to_string_lossy()
169                        }]
170                    },
171                    {
172                        "matcher": "mcp__intent-engine__event_list",
173                        "hooks": [{
174                            "type": "command",
175                            "command": format_hook_abs_path.to_string_lossy()
176                        }]
177                    }
178                ]
179            }
180        });
181
182        if !opts.dry_run {
183            write_json_config(&settings_file, &settings)?;
184            files_modified.push(settings_file.clone());
185            println!("✓ Created {}", settings_file.display());
186        } else {
187            println!("Would write: {}", settings_file.display());
188        }
189
190        // 3. Setup MCP configuration
191        let mcp_result = self.setup_mcp_config(opts, &mut files_modified, &mut backups)?;
192
193        Ok(SetupResult {
194            success: true,
195            message: "User-level Claude Code setup complete!".to_string(),
196            files_modified,
197            connectivity_test: Some(mcp_result),
198        })
199    }
200
201    /// Setup MCP server configuration
202    fn setup_mcp_config(
203        &self,
204        opts: &SetupOptions,
205        files_modified: &mut Vec<PathBuf>,
206        backups: &mut Vec<(PathBuf, PathBuf)>,
207    ) -> Result<ConnectivityResult> {
208        let config_path = if let Some(ref path) = opts.config_path {
209            path.clone()
210        } else {
211            let home = get_home_dir()?;
212            home.join(".claude.json")
213        };
214
215        // Find binary
216        let binary_path = find_ie_binary()?;
217        println!("✓ Found binary: {}", binary_path.display());
218
219        // Determine project directory
220        let project_dir = if let Some(ref dir) = opts.project_dir {
221            dir.clone()
222        } else {
223            env::current_dir().map_err(IntentError::IoError)?
224        };
225        let project_dir_abs = resolve_absolute_path(&project_dir)?;
226
227        // Backup existing config
228        if config_path.exists() && !opts.dry_run {
229            if let Some(backup) = create_backup(&config_path)? {
230                backups.push((config_path.clone(), backup.clone()));
231                println!("✓ Backed up MCP config to {}", backup.display());
232            }
233        }
234
235        // Read or create config
236        let mut config = read_json_config(&config_path)?;
237
238        // Check if already configured
239        if let Some(mcp_servers) = config.get("mcpServers") {
240            if mcp_servers.get("intent-engine").is_some() && !opts.force {
241                return Ok(ConnectivityResult {
242                    passed: false,
243                    details: "intent-engine already configured in MCP config".to_string(),
244                });
245            }
246        }
247
248        // Add intent-engine configuration
249        if config.get("mcpServers").is_none() {
250            config["mcpServers"] = json!({});
251        }
252
253        config["mcpServers"]["intent-engine"] = json!({
254            "command": binary_path.to_string_lossy(),
255            "args": ["mcp-server"],
256            "env": {
257                "INTENT_ENGINE_PROJECT_DIR": project_dir_abs.to_string_lossy()
258            },
259            "description": "Strategic intent and task workflow management"
260        });
261
262        if !opts.dry_run {
263            write_json_config(&config_path, &config)?;
264            files_modified.push(config_path.clone());
265            println!("✓ Updated {}", config_path.display());
266        } else {
267            println!("Would write: {}", config_path.display());
268        }
269
270        Ok(ConnectivityResult {
271            passed: true,
272            details: format!("MCP configured at {}", config_path.display()),
273        })
274    }
275
276    /// Setup for project-level installation
277    fn setup_project_level(&self, opts: &SetupOptions) -> Result<SetupResult> {
278        println!("📦 Setting up project-level Claude Code integration...\n");
279        println!("⚠️  Note: Project-level setup is for advanced users.");
280        println!("    MCP config will still be in ~/.claude.json (user-level)\n");
281
282        let mut files_modified = Vec::new();
283        let claude_dir = Self::get_project_claude_dir()?;
284        let hooks_dir = claude_dir.join("hooks");
285        let hook_script = hooks_dir.join("session-start.sh");
286
287        if !opts.dry_run {
288            fs::create_dir_all(&hooks_dir).map_err(IntentError::IoError)?;
289            println!("✓ Created {}", hooks_dir.display());
290        } else {
291            println!("Would create: {}", hooks_dir.display());
292        }
293
294        // Check if hook script already exists
295        if hook_script.exists() && !opts.force {
296            return Err(IntentError::InvalidInput(format!(
297                "Hook script already exists: {}. Use --force to overwrite",
298                hook_script.display()
299            )));
300        }
301
302        // Install session-start hook script
303        let hook_content = include_str!("../../templates/session-start.sh");
304        if !opts.dry_run {
305            fs::write(&hook_script, hook_content).map_err(IntentError::IoError)?;
306            set_executable(&hook_script)?;
307            files_modified.push(hook_script.clone());
308            println!("✓ Installed {}", hook_script.display());
309        } else {
310            println!("Would write: {}", hook_script.display());
311        }
312
313        // Install format-ie-output hook script
314        let format_hook_script = hooks_dir.join("format-ie-output.sh");
315        let format_hook_content = include_str!("../../templates/format-ie-output.sh");
316
317        if format_hook_script.exists() && !opts.force {
318            return Err(IntentError::InvalidInput(format!(
319                "Format hook already exists: {}. Use --force to overwrite",
320                format_hook_script.display()
321            )));
322        }
323
324        if !opts.dry_run {
325            fs::write(&format_hook_script, format_hook_content).map_err(IntentError::IoError)?;
326            set_executable(&format_hook_script)?;
327            files_modified.push(format_hook_script.clone());
328            println!("✓ Installed {}", format_hook_script.display());
329        } else {
330            println!("Would write: {}", format_hook_script.display());
331        }
332
333        // Create settings.json with absolute paths
334        let settings_file = claude_dir.join("settings.json");
335        let hook_abs_path = resolve_absolute_path(&hook_script)?;
336        let format_hook_abs_path = resolve_absolute_path(&format_hook_script)?;
337
338        // Check if settings file already exists
339        if settings_file.exists() && !opts.force {
340            return Err(IntentError::InvalidInput(format!(
341                "Settings file already exists: {}. Use --force to overwrite",
342                settings_file.display()
343            )));
344        }
345
346        let settings = json!({
347            "hooks": {
348                "SessionStart": [{
349                    "hooks": [{
350                        "type": "command",
351                        "command": hook_abs_path.to_string_lossy()
352                    }]
353                }],
354                "PostToolUse": [
355                    {
356                        "matcher": "mcp__intent-engine__task_context",
357                        "hooks": [{
358                            "type": "command",
359                            "command": format_hook_abs_path.to_string_lossy()
360                        }]
361                    },
362                    {
363                        "matcher": "mcp__intent-engine__task_get",
364                        "hooks": [{
365                            "type": "command",
366                            "command": format_hook_abs_path.to_string_lossy()
367                        }]
368                    },
369                    {
370                        "matcher": "mcp__intent-engine__current_task_get",
371                        "hooks": [{
372                            "type": "command",
373                            "command": format_hook_abs_path.to_string_lossy()
374                        }]
375                    },
376                    {
377                        "matcher": "mcp__intent-engine__task_list",
378                        "hooks": [{
379                            "type": "command",
380                            "command": format_hook_abs_path.to_string_lossy()
381                        }]
382                    },
383                    {
384                        "matcher": "mcp__intent-engine__task_pick_next",
385                        "hooks": [{
386                            "type": "command",
387                            "command": format_hook_abs_path.to_string_lossy()
388                        }]
389                    },
390                    {
391                        "matcher": "mcp__intent-engine__unified_search",
392                        "hooks": [{
393                            "type": "command",
394                            "command": format_hook_abs_path.to_string_lossy()
395                        }]
396                    },
397                    {
398                        "matcher": "mcp__intent-engine__event_list",
399                        "hooks": [{
400                            "type": "command",
401                            "command": format_hook_abs_path.to_string_lossy()
402                        }]
403                    }
404                ]
405            }
406        });
407
408        if !opts.dry_run {
409            write_json_config(&settings_file, &settings)?;
410            files_modified.push(settings_file);
411            println!("✓ Created settings.json");
412        } else {
413            println!("Would write: {}", settings_file.display());
414        }
415
416        // MCP config still goes to user-level
417        let mut backups = Vec::new();
418        let mcp_result = self.setup_mcp_config(opts, &mut files_modified, &mut backups)?;
419
420        Ok(SetupResult {
421            success: true,
422            message: "Project-level setup complete!".to_string(),
423            files_modified,
424            connectivity_test: Some(mcp_result),
425        })
426    }
427}
428
429impl SetupModule for ClaudeCodeSetup {
430    fn name(&self) -> &str {
431        "claude-code"
432    }
433
434    fn setup(&self, opts: &SetupOptions) -> Result<SetupResult> {
435        match opts.scope {
436            SetupScope::User => self.setup_user_level(opts),
437            SetupScope::Project => self.setup_project_level(opts),
438            SetupScope::Both => {
439                // First user-level, then project-level
440                let user_result = self.setup_user_level(opts)?;
441                let project_result = self.setup_project_level(opts)?;
442
443                // Combine results
444                let mut files = user_result.files_modified;
445                files.extend(project_result.files_modified);
446
447                Ok(SetupResult {
448                    success: true,
449                    message: "User and project setup complete!".to_string(),
450                    files_modified: files,
451                    connectivity_test: user_result.connectivity_test,
452                })
453            },
454        }
455    }
456
457    fn diagnose(&self) -> Result<DiagnosisReport> {
458        let mut checks = Vec::new();
459        let mut suggested_fixes = Vec::new();
460
461        // Check 1: Hook script exists and is executable
462        let claude_dir = Self::get_user_claude_dir()?;
463        let hook_script = claude_dir.join("hooks").join("session-start.sh");
464
465        let hook_check = if hook_script.exists() {
466            if hook_script.metadata().map(|m| m.is_file()).unwrap_or(false) {
467                #[cfg(unix)]
468                {
469                    use std::os::unix::fs::PermissionsExt;
470                    let perms = hook_script.metadata().unwrap().permissions();
471                    let is_executable = perms.mode() & 0o111 != 0;
472                    if is_executable {
473                        DiagnosisCheck {
474                            name: "Hook script".to_string(),
475                            passed: true,
476                            details: format!("Found at {}", hook_script.display()),
477                        }
478                    } else {
479                        suggested_fixes.push(format!("chmod +x {}", hook_script.display()));
480                        DiagnosisCheck {
481                            name: "Hook script".to_string(),
482                            passed: false,
483                            details: "Script exists but is not executable".to_string(),
484                        }
485                    }
486                }
487                #[cfg(not(unix))]
488                DiagnosisCheck {
489                    name: "Hook script".to_string(),
490                    passed: true,
491                    details: format!("Found at {}", hook_script.display()),
492                }
493            } else {
494                DiagnosisCheck {
495                    name: "Hook script".to_string(),
496                    passed: false,
497                    details: "Path exists but is not a file".to_string(),
498                }
499            }
500        } else {
501            suggested_fixes.push("Run: ie setup --target claude-code".to_string());
502            DiagnosisCheck {
503                name: "Hook script".to_string(),
504                passed: false,
505                details: format!("Not found at {}", hook_script.display()),
506            }
507        };
508        checks.push(hook_check);
509
510        // Check 2: Format hook script exists and is executable
511        let format_hook_script = claude_dir.join("hooks").join("format-ie-output.sh");
512        let format_hook_check = if format_hook_script.exists() {
513            if format_hook_script
514                .metadata()
515                .map(|m| m.is_file())
516                .unwrap_or(false)
517            {
518                #[cfg(unix)]
519                {
520                    use std::os::unix::fs::PermissionsExt;
521                    let perms = format_hook_script.metadata().unwrap().permissions();
522                    let is_executable = perms.mode() & 0o111 != 0;
523                    if is_executable {
524                        DiagnosisCheck {
525                            name: "Format hook script".to_string(),
526                            passed: true,
527                            details: format!("Found at {}", format_hook_script.display()),
528                        }
529                    } else {
530                        suggested_fixes.push(format!("chmod +x {}", format_hook_script.display()));
531                        DiagnosisCheck {
532                            name: "Format hook script".to_string(),
533                            passed: false,
534                            details: "Script exists but is not executable".to_string(),
535                        }
536                    }
537                }
538                #[cfg(not(unix))]
539                DiagnosisCheck {
540                    name: "Format hook script".to_string(),
541                    passed: true,
542                    details: format!("Found at {}", format_hook_script.display()),
543                }
544            } else {
545                DiagnosisCheck {
546                    name: "Format hook script".to_string(),
547                    passed: false,
548                    details: "Path exists but is not a file".to_string(),
549                }
550            }
551        } else {
552            suggested_fixes.push("Run: ie setup --target claude-code --force".to_string());
553            DiagnosisCheck {
554                name: "Format hook script".to_string(),
555                passed: false,
556                details: format!("Not found at {}", format_hook_script.display()),
557            }
558        };
559        checks.push(format_hook_check);
560
561        // Check 3: Settings file has SessionStart config
562        let settings_file = claude_dir.join("settings.json");
563        let settings_check = if settings_file.exists() {
564            match read_json_config(&settings_file) {
565                Ok(config) => {
566                    if config
567                        .get("hooks")
568                        .and_then(|h| h.get("SessionStart"))
569                        .is_some()
570                    {
571                        DiagnosisCheck {
572                            name: "Settings file".to_string(),
573                            passed: true,
574                            details: "SessionStart hook configured".to_string(),
575                        }
576                    } else {
577                        suggested_fixes
578                            .push("Run: ie setup --target claude-code --force".to_string());
579                        DiagnosisCheck {
580                            name: "Settings file".to_string(),
581                            passed: false,
582                            details: "Missing SessionStart hook configuration".to_string(),
583                        }
584                    }
585                },
586                Err(_) => DiagnosisCheck {
587                    name: "Settings file".to_string(),
588                    passed: false,
589                    details: "Failed to parse settings.json".to_string(),
590                },
591            }
592        } else {
593            suggested_fixes.push("Run: ie setup --target claude-code".to_string());
594            DiagnosisCheck {
595                name: "Settings file".to_string(),
596                passed: false,
597                details: format!("Not found at {}", settings_file.display()),
598            }
599        };
600        checks.push(settings_check);
601
602        // Check 4: Settings file has PostToolUse config
603        let posttool_check = if settings_file.exists() {
604            match read_json_config(&settings_file) {
605                Ok(config) => {
606                    if config
607                        .get("hooks")
608                        .and_then(|h| h.get("PostToolUse"))
609                        .is_some()
610                    {
611                        DiagnosisCheck {
612                            name: "PostToolUse hooks".to_string(),
613                            passed: true,
614                            details: "PostToolUse hook configured".to_string(),
615                        }
616                    } else {
617                        suggested_fixes
618                            .push("Run: ie setup --target claude-code --force".to_string());
619                        DiagnosisCheck {
620                            name: "PostToolUse hooks".to_string(),
621                            passed: false,
622                            details: "Missing PostToolUse hook configuration".to_string(),
623                        }
624                    }
625                },
626                Err(_) => DiagnosisCheck {
627                    name: "PostToolUse hooks".to_string(),
628                    passed: false,
629                    details: "Failed to parse settings.json".to_string(),
630                },
631            }
632        } else {
633            DiagnosisCheck {
634                name: "PostToolUse hooks".to_string(),
635                passed: false,
636                details: "Settings file not found".to_string(),
637            }
638        };
639        checks.push(posttool_check);
640
641        // Check 5: MCP config exists and has intent-engine
642        let home = get_home_dir()?;
643        let mcp_config = home.join(".claude.json");
644        let mcp_check = if mcp_config.exists() {
645            match read_json_config(&mcp_config) {
646                Ok(config) => {
647                    if config
648                        .get("mcpServers")
649                        .and_then(|s| s.get("intent-engine"))
650                        .is_some()
651                    {
652                        DiagnosisCheck {
653                            name: "MCP configuration".to_string(),
654                            passed: true,
655                            details: "intent-engine MCP server configured".to_string(),
656                        }
657                    } else {
658                        suggested_fixes
659                            .push("Run: ie setup --target claude-code --force".to_string());
660                        DiagnosisCheck {
661                            name: "MCP configuration".to_string(),
662                            passed: false,
663                            details: "Missing intent-engine server entry".to_string(),
664                        }
665                    }
666                },
667                Err(_) => DiagnosisCheck {
668                    name: "MCP configuration".to_string(),
669                    passed: false,
670                    details: "Failed to parse .claude.json".to_string(),
671                },
672            }
673        } else {
674            suggested_fixes.push("Run: ie setup --target claude-code".to_string());
675            DiagnosisCheck {
676                name: "MCP configuration".to_string(),
677                passed: false,
678                details: format!("Not found at {}", mcp_config.display()),
679            }
680        };
681        checks.push(mcp_check);
682
683        // Check 6: Binary in PATH
684        let binary_check = match find_ie_binary() {
685            Ok(path) => DiagnosisCheck {
686                name: "Binary availability".to_string(),
687                passed: true,
688                details: format!("Found at {}", path.display()),
689            },
690            Err(_) => {
691                suggested_fixes.push("Install: cargo install intent-engine".to_string());
692                DiagnosisCheck {
693                    name: "Binary availability".to_string(),
694                    passed: false,
695                    details: "intent-engine not found in PATH".to_string(),
696                }
697            },
698        };
699        checks.push(binary_check);
700
701        let overall_status = checks.iter().all(|c| c.passed);
702
703        Ok(DiagnosisReport {
704            overall_status,
705            checks,
706            suggested_fixes,
707        })
708    }
709
710    fn test_connectivity(&self) -> Result<ConnectivityResult> {
711        // Test 1: Can we execute session-restore?
712        println!("Testing session-restore command...");
713        let output = std::process::Command::new("ie")
714            .args(["session-restore", "--workspace", "."])
715            .output();
716
717        match output {
718            Ok(result) => {
719                if result.status.success() {
720                    Ok(ConnectivityResult {
721                        passed: true,
722                        details: "session-restore command executed successfully".to_string(),
723                    })
724                } else {
725                    let stderr = String::from_utf8_lossy(&result.stderr);
726                    Ok(ConnectivityResult {
727                        passed: false,
728                        details: format!("session-restore failed: {}", stderr),
729                    })
730                }
731            },
732            Err(e) => Ok(ConnectivityResult {
733                passed: false,
734                details: format!("Failed to execute session-restore: {}", e),
735            }),
736        }
737    }
738}