1use super::common::*;
4use super::{ConnectivityResult, SetupModule, SetupOptions, SetupResult, SetupScope};
5use crate::error::{IntentError, Result};
6use serde_json::json;
7use std::env;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11pub struct ClaudeCodeSetup;
12
13impl ClaudeCodeSetup {
14 fn get_user_claude_dir() -> Result<PathBuf> {
16 let home = get_home_dir()?;
17 Ok(home.join(".claude"))
18 }
19
20 fn get_project_claude_dir() -> Result<PathBuf> {
22 let current_dir = env::current_dir().map_err(IntentError::IoError)?;
23 Ok(current_dir.join(".claude"))
24 }
25
26 fn create_claude_settings(hook_path: &Path) -> serde_json::Value {
34 json!({
35 "hooks": {
36 "SessionStart": [{
37 "hooks": [{
38 "type": "command",
39 "command": hook_path.to_string_lossy()
40 }]
41 }]
42 }
43 })
44 }
45
46 fn setup_hooks_and_settings(
56 claude_dir: &Path,
57 opts: &SetupOptions,
58 files_modified: &mut Vec<PathBuf>,
59 ) -> Result<()> {
60 let hooks_dir = claude_dir.join("hooks");
61 let hook_script = hooks_dir.join("session-start.sh");
62
63 fs::create_dir_all(&hooks_dir).map_err(IntentError::IoError)?;
65 println!("✓ Created {}", hooks_dir.display());
66
67 if hook_script.exists() && !opts.force {
69 return Err(IntentError::InvalidInput(format!(
70 "Hook script already exists: {}. Use --force to overwrite",
71 hook_script.display()
72 )));
73 }
74
75 let hook_content = include_str!("../../templates/session-start.sh");
77 fs::write(&hook_script, hook_content).map_err(IntentError::IoError)?;
78 set_executable(&hook_script)?;
79 files_modified.push(hook_script.clone());
80 println!("✓ Installed {}", hook_script.display());
81
82 let settings_file = claude_dir.join("settings.json");
84 let hook_abs_path = resolve_absolute_path(&hook_script)?;
85
86 if settings_file.exists() && !opts.force {
88 return Err(IntentError::InvalidInput(format!(
89 "Settings file already exists: {}. Use --force to overwrite",
90 settings_file.display()
91 )));
92 }
93
94 let settings = Self::create_claude_settings(&hook_abs_path);
95
96 write_json_config(&settings_file, &settings)?;
97 files_modified.push(settings_file.clone());
98 println!("✓ Created {}", settings_file.display());
99
100 Ok(())
101 }
102
103 fn setup_user_level(&self, opts: &SetupOptions) -> Result<SetupResult> {
105 let mut files_modified = Vec::new();
106
107 println!("📦 Setting up user-level Claude Code integration...\n");
108
109 let claude_dir = Self::get_user_claude_dir()?;
111 Self::setup_hooks_and_settings(&claude_dir, opts, &mut files_modified)?;
112
113 let mcp_result = self.setup_mcp_config(opts, &mut files_modified)?;
115
116 Ok(SetupResult {
117 success: true,
118 message: "User-level Claude Code setup complete!".to_string(),
119 files_modified,
120 connectivity_test: Some(mcp_result),
121 })
122 }
123
124 fn setup_mcp_config(
126 &self,
127 opts: &SetupOptions,
128 files_modified: &mut Vec<PathBuf>,
129 ) -> Result<ConnectivityResult> {
130 let config_path = if let Some(ref path) = opts.config_path {
131 path.clone()
132 } else {
133 let home = get_home_dir()?;
134 home.join(".claude.json")
135 };
136
137 let binary_path = find_ie_binary()?;
139 println!("✓ Found binary: {}", binary_path.display());
140
141 let mut config = read_json_config(&config_path)?;
143
144 if let Some(mcp_servers) = config.get("mcpServers") {
146 if mcp_servers.get("intent-engine").is_some() && !opts.force {
147 return Ok(ConnectivityResult {
148 passed: false,
149 details: "intent-engine already configured in MCP config".to_string(),
150 });
151 }
152 }
153
154 if config.get("mcpServers").is_none() {
156 config["mcpServers"] = json!({});
157 }
158
159 config["mcpServers"]["intent-engine"] = json!({
160 "command": binary_path.to_string_lossy(),
161 "args": ["mcp-server"],
162 "description": "Strategic intent and task workflow management"
163 });
164
165 write_json_config(&config_path, &config)?;
166 files_modified.push(config_path.clone());
167 println!("✓ Updated {}", config_path.display());
168
169 Ok(ConnectivityResult {
170 passed: true,
171 details: format!("MCP configured at {}", config_path.display()),
172 })
173 }
174
175 fn setup_project_level(&self, opts: &SetupOptions) -> Result<SetupResult> {
177 println!("📦 Setting up project-level Claude Code integration...\n");
178 println!("⚠️ Note: Project-level setup is for advanced users.");
179 println!(" MCP config will still be in ~/.claude.json (user-level)\n");
180
181 let mut files_modified = Vec::new();
182
183 let claude_dir = Self::get_project_claude_dir()?;
185 Self::setup_hooks_and_settings(&claude_dir, opts, &mut files_modified)?;
186
187 let mcp_result = self.setup_mcp_config(opts, &mut files_modified)?;
189
190 Ok(SetupResult {
191 success: true,
192 message: "Project-level setup complete!".to_string(),
193 files_modified,
194 connectivity_test: Some(mcp_result),
195 })
196 }
197}
198
199impl SetupModule for ClaudeCodeSetup {
200 fn name(&self) -> &str {
201 "claude-code"
202 }
203
204 fn setup(&self, opts: &SetupOptions) -> Result<SetupResult> {
205 match opts.scope {
206 SetupScope::User => self.setup_user_level(opts),
207 SetupScope::Project => self.setup_project_level(opts),
208 SetupScope::Both => {
209 let user_result = self.setup_user_level(opts)?;
211 let project_result = self.setup_project_level(opts)?;
212
213 let mut files = user_result.files_modified;
215 files.extend(project_result.files_modified);
216
217 Ok(SetupResult {
218 success: true,
219 message: "User and project setup complete!".to_string(),
220 files_modified: files,
221 connectivity_test: user_result.connectivity_test,
222 })
223 },
224 }
225 }
226
227 fn test_connectivity(&self) -> Result<ConnectivityResult> {
228 println!("Testing session-restore command...");
230 let output = std::process::Command::new("ie")
231 .args(["session-restore", "--workspace", "."])
232 .output();
233
234 match output {
235 Ok(result) => {
236 if result.status.success() {
237 Ok(ConnectivityResult {
238 passed: true,
239 details: "session-restore command executed successfully".to_string(),
240 })
241 } else {
242 let stderr = String::from_utf8_lossy(&result.stderr);
243 Ok(ConnectivityResult {
244 passed: false,
245 details: format!("session-restore failed: {}", stderr),
246 })
247 }
248 },
249 Err(e) => Ok(ConnectivityResult {
250 passed: false,
251 details: format!("Failed to execute session-restore: {}", e),
252 }),
253 }
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use std::path::PathBuf;
261 use tempfile::TempDir;
262
263 #[test]
266 fn test_create_claude_settings_structure() {
267 let hook_path = PathBuf::from("/tmp/session-start.sh");
268
269 let settings = ClaudeCodeSetup::create_claude_settings(&hook_path);
270
271 assert!(settings.get("hooks").is_some());
273
274 let hooks = &settings["hooks"];
276 assert!(hooks.get("SessionStart").is_some());
277 let session_start = &hooks["SessionStart"];
278 assert!(session_start.is_array());
279 assert_eq!(session_start.as_array().unwrap().len(), 1);
280 }
281
282 #[test]
283 fn test_create_claude_settings_session_start_hook() {
284 let hook_path = PathBuf::from("/home/user/.claude/hooks/session-start.sh");
285
286 let settings = ClaudeCodeSetup::create_claude_settings(&hook_path);
287
288 let session_start = &settings["hooks"]["SessionStart"][0];
289 assert!(session_start.get("hooks").is_some());
290
291 let hooks_array = session_start["hooks"].as_array().unwrap();
292 assert_eq!(hooks_array.len(), 1);
293
294 let hook = &hooks_array[0];
295 assert_eq!(hook["type"], "command");
296 assert_eq!(hook["command"], "/home/user/.claude/hooks/session-start.sh");
297 }
298
299 #[test]
302 fn test_get_user_claude_dir() {
303 let result = ClaudeCodeSetup::get_user_claude_dir();
305 assert!(result.is_ok());
306
307 let dir = result.unwrap();
308 assert!(dir.ends_with(".claude"));
309 }
310
311 #[test]
312 fn test_get_project_claude_dir() {
313 let result = ClaudeCodeSetup::get_project_claude_dir();
314 assert!(result.is_ok());
315
316 let dir = result.unwrap();
317 assert!(dir.ends_with(".claude"));
318 }
319
320 #[test]
321 fn test_claude_code_setup_name() {
322 let setup = ClaudeCodeSetup;
323 assert_eq!(setup.name(), "claude-code");
324 }
325
326 #[test]
329 fn test_create_claude_settings_paths_preserved() {
330 let hook_path = PathBuf::from("/home/user name/with spaces/.claude/hooks/session-start.sh");
332
333 let settings = ClaudeCodeSetup::create_claude_settings(&hook_path);
334
335 let session_start_cmd = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
337 .as_str()
338 .unwrap();
339 assert!(session_start_cmd.contains("with spaces"));
340 }
341
342 #[test]
345 fn test_setup_hooks_and_settings_creates_directories() {
346 let temp_dir = TempDir::new().unwrap();
347 let claude_dir = temp_dir.path().join(".claude");
348
349 let opts = SetupOptions {
350 force: false,
351 scope: SetupScope::User,
352 config_path: None,
353 };
354 let mut files_modified = Vec::new();
355
356 let result =
357 ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified);
358
359 assert!(result.is_ok());
360
361 assert!(claude_dir.join("hooks").exists());
363
364 let hook_script = claude_dir.join("hooks/session-start.sh");
366 assert!(hook_script.exists());
367
368 let settings_file = claude_dir.join("settings.json");
370 assert!(settings_file.exists());
371
372 assert_eq!(files_modified.len(), 2);
374 }
375
376 #[test]
377 fn test_setup_hooks_and_settings_force_overwrites() {
378 let temp_dir = TempDir::new().unwrap();
379 let claude_dir = temp_dir.path().join(".claude");
380
381 let opts = SetupOptions {
383 force: false,
384 scope: SetupScope::User,
385 config_path: None,
386 };
387 let mut files_modified = Vec::new();
388 ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified).unwrap();
389
390 let result =
392 ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified);
393 assert!(result.is_err());
394
395 let opts_force = SetupOptions {
397 force: true,
398 scope: SetupScope::User,
399 config_path: None,
400 };
401 let mut files_modified2 = Vec::new();
402 let result = ClaudeCodeSetup::setup_hooks_and_settings(
403 &claude_dir,
404 &opts_force,
405 &mut files_modified2,
406 );
407 assert!(result.is_ok());
408 }
409
410 #[test]
411 fn test_setup_hooks_and_settings_hook_content() {
412 let temp_dir = TempDir::new().unwrap();
413 let claude_dir = temp_dir.path().join(".claude");
414
415 let opts = SetupOptions {
416 force: false,
417 scope: SetupScope::User,
418 config_path: None,
419 };
420 let mut files_modified = Vec::new();
421
422 ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified).unwrap();
423
424 let hook_script = claude_dir.join("hooks/session-start.sh");
426 let content = std::fs::read_to_string(&hook_script).unwrap();
427
428 assert!(content.contains("#!/"));
430 assert!(content.contains("ie ") || content.contains("session-restore"));
431 }
432
433 #[test]
434 fn test_setup_hooks_and_settings_json_valid() {
435 let temp_dir = TempDir::new().unwrap();
436 let claude_dir = temp_dir.path().join(".claude");
437
438 let opts = SetupOptions {
439 force: false,
440 scope: SetupScope::User,
441 config_path: None,
442 };
443 let mut files_modified = Vec::new();
444
445 ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified).unwrap();
446
447 let settings_file = claude_dir.join("settings.json");
449 let content = std::fs::read_to_string(&settings_file).unwrap();
450 let settings: serde_json::Value = serde_json::from_str(&content).unwrap();
451
452 assert!(settings.get("hooks").is_some());
454 assert!(settings["hooks"].get("SessionStart").is_some());
455 }
456
457 #[test]
458 fn test_setup_mcp_config_creates_new_file() {
459 let temp_dir = TempDir::new().unwrap();
460 let config_path = temp_dir.path().join(".claude.json");
461
462 let opts = SetupOptions {
463 force: false,
464 scope: SetupScope::User,
465 config_path: Some(config_path.clone()),
466 };
467 let mut files_modified = Vec::new();
468
469 let setup = ClaudeCodeSetup;
470 let result = setup.setup_mcp_config(&opts, &mut files_modified);
471
472 assert!(result.is_ok());
473 assert!(config_path.exists());
474
475 let content = std::fs::read_to_string(&config_path).unwrap();
477 let config: serde_json::Value = serde_json::from_str(&content).unwrap();
478 assert!(config["mcpServers"]["intent-engine"].is_object());
479 }
480
481 #[test]
482 fn test_setup_mcp_config_preserves_existing() {
483 let temp_dir = TempDir::new().unwrap();
484 let config_path = temp_dir.path().join(".claude.json");
485
486 let existing_config = json!({
488 "mcpServers": {
489 "other-server": {
490 "command": "other-cmd"
491 }
492 }
493 });
494 std::fs::write(
495 &config_path,
496 serde_json::to_string_pretty(&existing_config).unwrap(),
497 )
498 .unwrap();
499
500 let opts = SetupOptions {
501 force: true,
502 scope: SetupScope::User,
503 config_path: Some(config_path.clone()),
504 };
505 let mut files_modified = Vec::new();
506
507 let setup = ClaudeCodeSetup;
508 setup.setup_mcp_config(&opts, &mut files_modified).unwrap();
509
510 let content = std::fs::read_to_string(&config_path).unwrap();
512 let config: serde_json::Value = serde_json::from_str(&content).unwrap();
513 assert!(config["mcpServers"]["other-server"].is_object());
514 assert!(config["mcpServers"]["intent-engine"].is_object());
515 }
516
517 #[test]
518 fn test_setup_mcp_config_no_force_skips_existing() {
519 let temp_dir = TempDir::new().unwrap();
520 let config_path = temp_dir.path().join(".claude.json");
521
522 let existing_config = json!({
524 "mcpServers": {
525 "intent-engine": {
526 "command": "old-cmd"
527 }
528 }
529 });
530 std::fs::write(
531 &config_path,
532 serde_json::to_string_pretty(&existing_config).unwrap(),
533 )
534 .unwrap();
535
536 let opts = SetupOptions {
537 force: false,
538 scope: SetupScope::User,
539 config_path: Some(config_path.clone()),
540 };
541 let mut files_modified = Vec::new();
542
543 let setup = ClaudeCodeSetup;
544 let result = setup.setup_mcp_config(&opts, &mut files_modified).unwrap();
545
546 assert!(!result.passed);
548 assert!(result.details.contains("already configured"));
549 }
550}