1use 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 fn get_user_claude_dir() -> Result<PathBuf> {
19 let home = get_home_dir()?;
20 Ok(home.join(".claude"))
21 }
22
23 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 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 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 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 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 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 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 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 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 let binary_path = find_ie_binary()?;
217 println!("✓ Found binary: {}", binary_path.display());
218
219 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 let mut config = read_json_config(&config_path)?;
229
230 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 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 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 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 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 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 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 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 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 let user_result = self.setup_user_level(opts)?;
430 let project_result = self.setup_project_level(opts)?;
431
432 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 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 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 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 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 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 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 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}