carp_cli/commands/
pull.rs1use crate::api::ApiClient;
2use crate::config::ConfigManager;
3use crate::utils::error::{CarpError, CarpResult};
4use colored::*;
5use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8
9pub async fn execute(agent: String, output: Option<String>, force: bool, verbose: bool) -> CarpResult<()> {
11 let (name, version) = parse_agent_spec(&agent)?;
12
13 if verbose {
14 println!("Pulling agent '{}'{}...", name,
15 version.map(|v| format!(" version {}", v)).unwrap_or_default());
16 }
17
18 let config = ConfigManager::load()?;
19 let client = ApiClient::new(&config)?;
20
21 let download_info = client.get_agent_download(&name, version).await?;
23
24 if verbose {
25 println!("Found {} v{} ({} bytes)",
26 download_info.name,
27 download_info.version,
28 download_info.size);
29 }
30
31 let output_dir = determine_output_dir(&name, output, &config)?;
33
34 if output_dir.exists() && !force {
36 return Err(CarpError::FileSystem(format!(
37 "Directory '{}' already exists. Use --force to overwrite.",
38 output_dir.display()
39 )));
40 }
41
42 if output_dir.exists() && force {
43 if verbose {
44 println!("Removing existing directory...");
45 }
46 fs::remove_dir_all(&output_dir)?;
47 }
48
49 println!("Downloading {}...", download_info.name.blue().bold());
51 let content = client.download_agent(&download_info.download_url).await?;
52
53 if !download_info.checksum.is_empty() {
55 if verbose {
56 println!("Verifying checksum...");
57 }
58 verify_checksum(&content, &download_info.checksum)?;
59 }
60
61 if verbose {
63 println!("Extracting to {}...", output_dir.display());
64 }
65 extract_agent(&content, &output_dir)?;
66
67 println!("{} Successfully pulled {} v{} to {}",
68 "✓".green().bold(),
69 download_info.name.blue().bold(),
70 download_info.version,
71 output_dir.display().to_string().cyan());
72
73 println!("\nTo use this agent:");
75 println!(" cd {}", output_dir.display());
76 println!(" # Follow the README.md for specific usage instructions");
77
78 Ok(())
79}
80
81fn parse_agent_spec(spec: &str) -> CarpResult<(String, Option<&str>)> {
83 if let Some(at_pos) = spec.find('@') {
84 let name = &spec[..at_pos];
85 let version = &spec[at_pos + 1..];
86
87 if name.is_empty() || version.is_empty() {
88 return Err(CarpError::InvalidAgent(
89 "Invalid agent specification. Use 'name' or 'name@version'.".to_string()
90 ));
91 }
92
93 Ok((name.to_string(), Some(version)))
94 } else {
95 Ok((spec.to_string(), None))
96 }
97}
98
99fn determine_output_dir(name: &str, output: Option<String>, config: &crate::config::Config) -> CarpResult<PathBuf> {
101 if let Some(output_path) = output {
102 return Ok(PathBuf::from(output_path));
103 }
104
105 if let Some(default_dir) = &config.default_output_dir {
106 return Ok(PathBuf::from(default_dir).join(name));
107 }
108
109 Ok(PathBuf::from(name))
111}
112
113fn verify_checksum(content: &[u8], expected: &str) -> CarpResult<()> {
115 use std::collections::hash_map::DefaultHasher;
116 use std::hash::{Hash, Hasher};
117
118 let mut hasher = DefaultHasher::new();
119 content.hash(&mut hasher);
120 let computed = format!("{:x}", hasher.finish());
121
122 if computed != expected {
123 return Err(CarpError::Network(
124 "Checksum verification failed. The downloaded file may be corrupted.".to_string()
125 ));
126 }
127
128 Ok(())
129}
130
131fn extract_agent(content: &[u8], output_dir: &Path) -> CarpResult<()> {
133 use std::io::Cursor;
134
135 fs::create_dir_all(output_dir)?;
137
138 let reader = Cursor::new(content);
140 let mut archive = zip::ZipArchive::new(reader)
141 .map_err(|e| CarpError::FileSystem(format!("Failed to read ZIP archive: {}", e)))?;
142
143 for i in 0..archive.len() {
144 let mut file = archive.by_index(i)
145 .map_err(|e| CarpError::FileSystem(format!("Failed to read ZIP entry: {}", e)))?;
146
147 let file_name = file.name();
149 if file_name.contains("..") || file_name.starts_with('/') || file_name.contains('\0') {
150 return Err(CarpError::FileSystem(format!(
151 "Unsafe file path in archive: {}", file_name
152 )));
153 }
154
155 let file_path = output_dir.join(file_name);
156
157 let canonical_output = output_dir.canonicalize()?;
159 let canonical_file = file_path.canonicalize().unwrap_or_else(|_| {
160 file_path.parent().unwrap_or(output_dir).join(
161 file_path.file_name().unwrap_or_default()
162 )
163 });
164
165 if !canonical_file.starts_with(&canonical_output) {
166 return Err(CarpError::FileSystem(format!(
167 "File path outside target directory: {}", file_name
168 )));
169 }
170
171 if file.is_dir() {
172 fs::create_dir_all(&file_path)?;
173 } else {
174 if let Some(parent) = file_path.parent() {
175 fs::create_dir_all(parent)?;
176 }
177
178 let mut output_file = fs::File::create(&file_path)?;
179 std::io::copy(&mut file, &mut output_file)?;
180
181 #[cfg(unix)]
183 {
184 use std::os::unix::fs::PermissionsExt;
185 let mut perms = output_file.metadata()?.permissions();
186 perms.set_mode(0o644); fs::set_permissions(&file_path, perms)?;
188 }
189 }
190 }
191
192 Ok(())
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn test_parse_agent_spec() {
201 let (name, version) = parse_agent_spec("test-agent").unwrap();
202 assert_eq!(name, "test-agent");
203 assert!(version.is_none());
204
205 let (name, version) = parse_agent_spec("test-agent@1.0.0").unwrap();
206 assert_eq!(name, "test-agent");
207 assert_eq!(version, Some("1.0.0"));
208
209 assert!(parse_agent_spec("@1.0.0").is_err());
210 assert!(parse_agent_spec("test-agent@").is_err());
211 }
212}