intent_engine/setup/
claude_code.rs1use 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 Ok(SetupResult {
114 success: true,
115 message: "User-level Claude Code setup complete!".to_string(),
116 files_modified,
117 connectivity_test: None,
118 })
119 }
120
121 fn setup_project_level(&self, opts: &SetupOptions) -> Result<SetupResult> {
123 println!("📦 Setting up project-level Claude Code integration...\n");
124 println!("⚠️ Note: Project-level setup is for advanced users.\n");
125
126 let mut files_modified = Vec::new();
127
128 let claude_dir = Self::get_project_claude_dir()?;
130 Self::setup_hooks_and_settings(&claude_dir, opts, &mut files_modified)?;
131
132 Ok(SetupResult {
133 success: true,
134 message: "Project-level setup complete!".to_string(),
135 files_modified,
136 connectivity_test: None,
137 })
138 }
139}
140
141impl SetupModule for ClaudeCodeSetup {
142 fn name(&self) -> &str {
143 "claude-code"
144 }
145
146 fn setup(&self, opts: &SetupOptions) -> Result<SetupResult> {
147 match opts.scope {
148 SetupScope::User => self.setup_user_level(opts),
149 SetupScope::Project => self.setup_project_level(opts),
150 SetupScope::Both => {
151 let user_result = self.setup_user_level(opts)?;
153 let project_result = self.setup_project_level(opts)?;
154
155 let mut files = user_result.files_modified;
157 files.extend(project_result.files_modified);
158
159 Ok(SetupResult {
160 success: true,
161 message: "User and project setup complete!".to_string(),
162 files_modified: files,
163 connectivity_test: user_result.connectivity_test,
164 })
165 },
166 }
167 }
168
169 fn test_connectivity(&self) -> Result<ConnectivityResult> {
170 println!("Testing session-restore command...");
172 let output = std::process::Command::new("ie")
173 .args(["session-restore", "--workspace", "."])
174 .output();
175
176 match output {
177 Ok(result) => {
178 if result.status.success() {
179 Ok(ConnectivityResult {
180 passed: true,
181 details: "session-restore command executed successfully".to_string(),
182 })
183 } else {
184 let stderr = String::from_utf8_lossy(&result.stderr);
185 Ok(ConnectivityResult {
186 passed: false,
187 details: format!("session-restore failed: {}", stderr),
188 })
189 }
190 },
191 Err(e) => Ok(ConnectivityResult {
192 passed: false,
193 details: format!("Failed to execute session-restore: {}", e),
194 }),
195 }
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use std::path::PathBuf;
203 use tempfile::TempDir;
204
205 #[test]
208 fn test_create_claude_settings_structure() {
209 let hook_path = PathBuf::from("/tmp/session-start.sh");
210
211 let settings = ClaudeCodeSetup::create_claude_settings(&hook_path);
212
213 assert!(settings.get("hooks").is_some());
215
216 let hooks = &settings["hooks"];
218 assert!(hooks.get("SessionStart").is_some());
219 let session_start = &hooks["SessionStart"];
220 assert!(session_start.is_array());
221 assert_eq!(session_start.as_array().unwrap().len(), 1);
222 }
223
224 #[test]
225 fn test_create_claude_settings_session_start_hook() {
226 let hook_path = PathBuf::from("/home/user/.claude/hooks/session-start.sh");
227
228 let settings = ClaudeCodeSetup::create_claude_settings(&hook_path);
229
230 let session_start = &settings["hooks"]["SessionStart"][0];
231 assert!(session_start.get("hooks").is_some());
232
233 let hooks_array = session_start["hooks"].as_array().unwrap();
234 assert_eq!(hooks_array.len(), 1);
235
236 let hook = &hooks_array[0];
237 assert_eq!(hook["type"], "command");
238 assert_eq!(hook["command"], "/home/user/.claude/hooks/session-start.sh");
239 }
240
241 #[test]
244 fn test_get_user_claude_dir() {
245 let result = ClaudeCodeSetup::get_user_claude_dir();
247 assert!(result.is_ok());
248
249 let dir = result.unwrap();
250 assert!(dir.ends_with(".claude"));
251 }
252
253 #[test]
254 fn test_get_project_claude_dir() {
255 let result = ClaudeCodeSetup::get_project_claude_dir();
256 assert!(result.is_ok());
257
258 let dir = result.unwrap();
259 assert!(dir.ends_with(".claude"));
260 }
261
262 #[test]
263 fn test_claude_code_setup_name() {
264 let setup = ClaudeCodeSetup;
265 assert_eq!(setup.name(), "claude-code");
266 }
267
268 #[test]
271 fn test_create_claude_settings_paths_preserved() {
272 let hook_path = PathBuf::from("/home/user name/with spaces/.claude/hooks/session-start.sh");
274
275 let settings = ClaudeCodeSetup::create_claude_settings(&hook_path);
276
277 let session_start_cmd = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
279 .as_str()
280 .unwrap();
281 assert!(session_start_cmd.contains("with spaces"));
282 }
283
284 #[test]
287 fn test_setup_hooks_and_settings_creates_directories() {
288 let temp_dir = TempDir::new().unwrap();
289 let claude_dir = temp_dir.path().join(".claude");
290
291 let opts = SetupOptions {
292 force: false,
293 scope: SetupScope::User,
294 config_path: None,
295 };
296 let mut files_modified = Vec::new();
297
298 let result =
299 ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified);
300
301 assert!(result.is_ok());
302
303 assert!(claude_dir.join("hooks").exists());
305
306 let hook_script = claude_dir.join("hooks/session-start.sh");
308 assert!(hook_script.exists());
309
310 let settings_file = claude_dir.join("settings.json");
312 assert!(settings_file.exists());
313
314 assert_eq!(files_modified.len(), 2);
316 }
317
318 #[test]
319 fn test_setup_hooks_and_settings_force_overwrites() {
320 let temp_dir = TempDir::new().unwrap();
321 let claude_dir = temp_dir.path().join(".claude");
322
323 let opts = SetupOptions {
325 force: false,
326 scope: SetupScope::User,
327 config_path: None,
328 };
329 let mut files_modified = Vec::new();
330 ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified).unwrap();
331
332 let result =
334 ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified);
335 assert!(result.is_err());
336
337 let opts_force = SetupOptions {
339 force: true,
340 scope: SetupScope::User,
341 config_path: None,
342 };
343 let mut files_modified2 = Vec::new();
344 let result = ClaudeCodeSetup::setup_hooks_and_settings(
345 &claude_dir,
346 &opts_force,
347 &mut files_modified2,
348 );
349 assert!(result.is_ok());
350 }
351
352 #[test]
353 fn test_setup_hooks_and_settings_hook_content() {
354 let temp_dir = TempDir::new().unwrap();
355 let claude_dir = temp_dir.path().join(".claude");
356
357 let opts = SetupOptions {
358 force: false,
359 scope: SetupScope::User,
360 config_path: None,
361 };
362 let mut files_modified = Vec::new();
363
364 ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified).unwrap();
365
366 let hook_script = claude_dir.join("hooks/session-start.sh");
368 let content = std::fs::read_to_string(&hook_script).unwrap();
369
370 assert!(content.contains("#!/"));
372 assert!(content.contains("ie ") || content.contains("session-restore"));
373 }
374
375 #[test]
376 fn test_setup_hooks_and_settings_json_valid() {
377 let temp_dir = TempDir::new().unwrap();
378 let claude_dir = temp_dir.path().join(".claude");
379
380 let opts = SetupOptions {
381 force: false,
382 scope: SetupScope::User,
383 config_path: None,
384 };
385 let mut files_modified = Vec::new();
386
387 ClaudeCodeSetup::setup_hooks_and_settings(&claude_dir, &opts, &mut files_modified).unwrap();
388
389 let settings_file = claude_dir.join("settings.json");
391 let content = std::fs::read_to_string(&settings_file).unwrap();
392 let settings: serde_json::Value = serde_json::from_str(&content).unwrap();
393
394 assert!(settings.get("hooks").is_some());
396 assert!(settings["hooks"].get("SessionStart").is_some());
397 }
398}