1use crate::errors::{Result, SdkError};
24use std::path::PathBuf;
25#[allow(unused_imports)]
26use tracing::{debug, info, warn};
27
28pub const MIN_CLI_VERSION: &str = "2.0.0";
30
31pub const DEFAULT_CLI_VERSION: &str = "latest";
33
34pub fn get_cache_dir() -> Option<PathBuf> {
36 #[cfg(target_os = "macos")]
37 {
38 dirs::home_dir().map(|h| h.join("Library/Caches/cc-sdk/cli"))
39 }
40 #[cfg(target_os = "windows")]
41 {
42 dirs::cache_dir().map(|c| c.join("cc-sdk").join("cli"))
43 }
44 #[cfg(all(unix, not(target_os = "macos")))]
45 {
46 dirs::cache_dir().map(|c| c.join("cc-sdk").join("cli"))
47 }
48}
49
50pub fn get_cached_cli_path() -> Option<PathBuf> {
52 let cache_dir = get_cache_dir()?;
53 let cli_name = if cfg!(windows) { "claude.exe" } else { "claude" };
54 Some(cache_dir.join(cli_name))
55}
56
57#[allow(dead_code)]
59pub fn is_cli_cached() -> bool {
60 if let Some(path) = get_cached_cli_path() {
61 if path.exists() && path.is_file() {
62 #[cfg(unix)]
63 {
64 use std::os::unix::fs::PermissionsExt;
65 if let Ok(metadata) = path.metadata() {
66 return metadata.permissions().mode() & 0o111 != 0;
67 }
68 }
69 #[cfg(not(unix))]
70 {
71 return true;
72 }
73 }
74 }
75 false
76}
77
78#[cfg(feature = "auto-download")]
94pub async fn download_cli(
95 version: Option<&str>,
96 on_progress: Option<Box<dyn Fn(u64, Option<u64>) + Send + Sync>>,
97) -> Result<PathBuf> {
98 let version = version.unwrap_or(DEFAULT_CLI_VERSION);
99 info!("Downloading Claude Code CLI version: {}", version);
100
101 let cache_dir = get_cache_dir().ok_or_else(|| {
102 SdkError::ConfigError("Cannot determine cache directory for CLI download".to_string())
103 })?;
104
105 std::fs::create_dir_all(&cache_dir).map_err(|e| {
107 SdkError::ConfigError(format!("Failed to create cache directory: {}", e))
108 })?;
109
110 let cli_path = get_cached_cli_path().ok_or_else(|| {
111 SdkError::ConfigError("Cannot determine CLI path".to_string())
112 })?;
113
114 let install_result = install_cli_for_platform(version, &cli_path, on_progress).await?;
116
117 info!("Claude Code CLI installed to: {}", install_result.display());
118 Ok(install_result)
119}
120
121#[cfg(not(feature = "auto-download"))]
123pub async fn download_cli(
124 _version: Option<&str>,
125 _on_progress: Option<Box<dyn Fn(u64, Option<u64>) + Send + Sync>>,
126) -> Result<PathBuf> {
127 Err(SdkError::ConfigError(
128 "Auto-download feature is not enabled. \
129 Either enable it with `features = [\"auto-download\"]` in Cargo.toml, \
130 or install Claude CLI manually: npm install -g @anthropic-ai/claude-code".to_string()
131 ))
132}
133
134#[cfg(feature = "auto-download")]
136async fn install_cli_for_platform(
137 version: &str,
138 target_path: &PathBuf,
139 on_progress: Option<Box<dyn Fn(u64, Option<u64>) + Send + Sync>>,
140) -> Result<PathBuf> {
141 #[cfg(unix)]
142 {
143 install_cli_unix(version, target_path, on_progress).await
144 }
145 #[cfg(windows)]
146 {
147 install_cli_windows(version, target_path, on_progress).await
148 }
149}
150
151#[cfg(all(unix, feature = "auto-download"))]
153async fn install_cli_unix(
154 version: &str,
155 target_path: &PathBuf,
156 on_progress: Option<Box<dyn Fn(u64, Option<u64>) + Send + Sync>>,
157) -> Result<PathBuf> {
158 use tokio::process::Command;
159
160 if let Some(ref progress) = on_progress {
161 progress(0, None);
162 }
163
164 if which::which("npm").is_ok() {
166 debug!("Attempting to install via npm...");
167
168 let npm_package = if version == "latest" {
169 "@anthropic-ai/claude-code".to_string()
170 } else {
171 format!("@anthropic-ai/claude-code@{}", version)
172 };
173
174 let temp_dir = std::env::temp_dir().join("cc-sdk-npm-install");
175 let _ = std::fs::remove_dir_all(&temp_dir);
176 std::fs::create_dir_all(&temp_dir).map_err(|e| {
177 SdkError::ConfigError(format!("Failed to create temp directory: {}", e))
178 })?;
179
180 let output = Command::new("npm")
181 .args(["install", "--prefix", temp_dir.to_str().unwrap(), &npm_package])
182 .output()
183 .await
184 .map_err(SdkError::ProcessError)?;
185
186 if output.status.success() {
187 let npm_bin_path = temp_dir.join("node_modules/.bin/claude");
188 if npm_bin_path.exists() {
189 std::fs::copy(&npm_bin_path, target_path).map_err(|e| {
190 SdkError::ConfigError(format!("Failed to copy CLI to cache: {}", e))
191 })?;
192
193 #[cfg(unix)]
194 {
195 use std::os::unix::fs::PermissionsExt;
196 let mut perms = std::fs::metadata(target_path)
197 .map_err(|e| {
198 SdkError::ConfigError(format!("Failed to get file permissions: {}", e))
199 })?
200 .permissions();
201 perms.set_mode(0o755);
202 std::fs::set_permissions(target_path, perms).map_err(|e| {
203 SdkError::ConfigError(format!("Failed to set file permissions: {}", e))
204 })?;
205 }
206
207 let _ = std::fs::remove_dir_all(&temp_dir);
208
209 if let Some(ref progress) = on_progress {
210 progress(100, Some(100));
211 }
212
213 return Ok(target_path.clone());
214 }
215 } else {
216 let stderr = String::from_utf8_lossy(&output.stderr);
217 warn!("npm install failed: {}", stderr);
218 }
219
220 let _ = std::fs::remove_dir_all(&temp_dir);
221 }
222
223 debug!("Attempting to install via official script...");
225
226 let install_script_url = "https://claude.ai/install.sh";
227
228 let client = reqwest::Client::new();
229 let response = client
230 .get(install_script_url)
231 .send()
232 .await
233 .map_err(|e| SdkError::ConnectionError(format!("Failed to download install script: {}", e)))?;
234
235 if !response.status().is_success() {
236 return Err(SdkError::ConnectionError(format!(
237 "Failed to download install script: HTTP {}",
238 response.status()
239 )));
240 }
241
242 let script_content = response
243 .text()
244 .await
245 .map_err(|e| SdkError::ConnectionError(format!("Failed to read install script: {}", e)))?;
246
247 let parent_dir = target_path.parent().ok_or_else(|| {
248 SdkError::ConfigError("Invalid target path".to_string())
249 })?;
250
251 let output = Command::new("bash")
252 .arg("-c")
253 .arg(&script_content)
254 .env("CLAUDE_INSTALL_DIR", parent_dir)
255 .output()
256 .await
257 .map_err(SdkError::ProcessError)?;
258
259 if output.status.success() && target_path.exists() {
260 if let Some(ref progress) = on_progress {
261 progress(100, Some(100));
262 }
263 return Ok(target_path.clone());
264 }
265
266 Err(SdkError::CliNotFound {
267 searched_paths: format!(
268 "Failed to automatically download Claude Code CLI.\n\
269 Please install manually:\n\n\
270 Option 1 (npm):\n\
271 npm install -g @anthropic-ai/claude-code\n\n\
272 Option 2 (official script):\n\
273 curl -fsSL https://claude.ai/install.sh | bash\n\n\
274 Error details: {}",
275 String::from_utf8_lossy(&output.stderr)
276 ),
277 })
278}
279
280#[cfg(all(windows, feature = "auto-download"))]
282async fn install_cli_windows(
283 version: &str,
284 target_path: &PathBuf,
285 on_progress: Option<Box<dyn Fn(u64, Option<u64>) + Send + Sync>>,
286) -> Result<PathBuf> {
287 use tokio::process::Command;
288
289 if let Some(ref progress) = on_progress {
290 progress(0, None);
291 }
292
293 if which::which("npm").is_ok() {
295 debug!("Attempting to install via npm...");
296
297 let npm_package = if version == "latest" {
298 "@anthropic-ai/claude-code".to_string()
299 } else {
300 format!("@anthropic-ai/claude-code@{}", version)
301 };
302
303 let temp_dir = std::env::temp_dir().join("cc-sdk-npm-install");
304 let _ = std::fs::remove_dir_all(&temp_dir);
305 std::fs::create_dir_all(&temp_dir).map_err(|e| {
306 SdkError::ConfigError(format!("Failed to create temp directory: {}", e))
307 })?;
308
309 let output = Command::new("npm")
310 .args(["install", "--prefix", temp_dir.to_str().unwrap(), &npm_package])
311 .output()
312 .await
313 .map_err(SdkError::ProcessError)?;
314
315 if output.status.success() {
316 let npm_bin_path = temp_dir.join("node_modules/.bin/claude.cmd");
317 if npm_bin_path.exists() {
318 std::fs::copy(&npm_bin_path, target_path).map_err(|e| {
319 SdkError::ConfigError(format!("Failed to copy CLI to cache: {}", e))
320 })?;
321
322 let _ = std::fs::remove_dir_all(&temp_dir);
323
324 if let Some(ref progress) = on_progress {
325 progress(100, Some(100));
326 }
327
328 return Ok(target_path.clone());
329 }
330 }
331
332 let _ = std::fs::remove_dir_all(&temp_dir);
333 }
334
335 debug!("Attempting to install via PowerShell script...");
337
338 let install_script_url = "https://claude.ai/install.ps1";
339
340 let parent_dir = target_path.parent().ok_or_else(|| {
341 SdkError::ConfigError("Invalid target path".to_string())
342 })?;
343
344 let output = Command::new("powershell")
345 .args([
346 "-NoProfile",
347 "-ExecutionPolicy", "Bypass",
348 "-Command",
349 &format!(
350 "$env:CLAUDE_INSTALL_DIR='{}'; iex (iwr -useb {})",
351 parent_dir.display(),
352 install_script_url
353 ),
354 ])
355 .output()
356 .await
357 .map_err(SdkError::ProcessError)?;
358
359 if output.status.success() && target_path.exists() {
360 if let Some(ref progress) = on_progress {
361 progress(100, Some(100));
362 }
363 return Ok(target_path.clone());
364 }
365
366 Err(SdkError::CliNotFound {
367 searched_paths: format!(
368 "Failed to automatically download Claude Code CLI.\n\
369 Please install manually:\n\n\
370 Option 1 (npm):\n\
371 npm install -g @anthropic-ai/claude-code\n\n\
372 Option 2 (PowerShell):\n\
373 iwr -useb https://claude.ai/install.ps1 | iex\n\n\
374 Error details: {}",
375 String::from_utf8_lossy(&output.stderr)
376 ),
377 })
378}
379
380#[allow(dead_code)]
384pub async fn ensure_cli(auto_download: bool) -> Result<PathBuf> {
385 if let Ok(path) = crate::transport::subprocess::find_claude_cli() {
387 return Ok(path);
388 }
389
390 if let Some(cached_path) = get_cached_cli_path() {
392 if cached_path.exists() {
393 debug!("Using cached CLI at: {}", cached_path.display());
394 return Ok(cached_path);
395 }
396 }
397
398 if auto_download {
400 info!("Claude Code CLI not found, downloading...");
401 return download_cli(None, None).await;
402 }
403
404 Err(SdkError::CliNotFound {
405 searched_paths: "Claude Code CLI not found.\n\n\
406 To automatically download, create the client with auto_download enabled:\n\
407 ```rust\n\
408 let options = ClaudeCodeOptions::builder()\n\
409 .auto_download_cli(true)\n\
410 .build();\n\
411 ```\n\n\
412 Or install manually:\n\
413 npm install -g @anthropic-ai/claude-code".to_string(),
414 })
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn test_get_cache_dir() {
423 let cache_dir = get_cache_dir();
424 assert!(cache_dir.is_some());
425 let dir = cache_dir.unwrap();
426 assert!(dir.to_string_lossy().contains("cc-sdk"));
427 }
428
429 #[test]
430 fn test_get_cached_cli_path() {
431 let cli_path = get_cached_cli_path();
432 assert!(cli_path.is_some());
433 let path = cli_path.unwrap();
434 if cfg!(windows) {
435 assert!(path.to_string_lossy().ends_with("claude.exe"));
436 } else {
437 assert!(path.to_string_lossy().ends_with("claude"));
438 }
439 }
440
441 #[test]
442 fn test_cli_version_constants() {
443 assert!(!MIN_CLI_VERSION.is_empty());
445 assert!(!DEFAULT_CLI_VERSION.is_empty());
446 assert_eq!(DEFAULT_CLI_VERSION, "latest");
447
448 let parts: Vec<&str> = MIN_CLI_VERSION.split('.').collect();
450 assert_eq!(parts.len(), 3, "MIN_CLI_VERSION should be semver format x.y.z");
451 }
452
453 #[test]
454 fn test_cache_dir_platform_specific() {
455 let cache_dir = get_cache_dir().expect("Should get cache dir");
456
457 #[cfg(target_os = "macos")]
458 {
459 assert!(cache_dir.to_string_lossy().contains("Library/Caches"));
460 assert!(cache_dir.to_string_lossy().contains("cc-sdk/cli"));
461 }
462
463 #[cfg(all(unix, not(target_os = "macos")))]
464 {
465 assert!(cache_dir.to_string_lossy().contains(".cache") || cache_dir.to_string_lossy().contains("cache"));
466 assert!(cache_dir.to_string_lossy().contains("cc-sdk"));
467 }
468
469 #[cfg(target_os = "windows")]
470 {
471 assert!(cache_dir.to_string_lossy().contains("cc-sdk"));
472 }
473 }
474
475 #[test]
476 fn test_is_cli_cached_when_not_cached() {
477 let _ = is_cli_cached();
482 }
483
484 #[test]
485 fn test_cached_cli_path_is_in_cache_dir() {
486 let cache_dir = get_cache_dir().expect("Should get cache dir");
487 let cli_path = get_cached_cli_path().expect("Should get cli path");
488
489 assert!(cli_path.starts_with(&cache_dir));
491
492 let cli_name = cli_path.file_name().expect("Should have file name");
494 if cfg!(windows) {
495 assert_eq!(cli_name, "claude.exe");
496 } else {
497 assert_eq!(cli_name, "claude");
498 }
499 }
500}