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