1use crate::api::ApiClient;
2use crate::config::ConfigManager;
3use crate::utils::error::{CarpError, CarpResult};
4use colored::*;
5use inquire::{InquireError, Select, Text};
6use std::fs;
7use std::path::PathBuf;
8
9pub async fn execute(
11 agent: Option<String>,
12 output: Option<String>,
13 force: bool,
14 verbose: bool,
15) -> CarpResult<()> {
16 let config = ConfigManager::load_with_env_checks()?;
17 let client = ApiClient::new(&config)?;
18
19 let agent_spec = match agent {
21 Some(spec) => spec,
22 None => {
23 if verbose {
24 println!("Fetching available agents for selection...");
25 }
26 interactive_agent_selection(&client).await?
27 }
28 };
29
30 let (name, version) = parse_agent_spec(&agent_spec)?;
31
32 if verbose {
33 println!(
34 "Pulling agent '{}'{}...",
35 name,
36 version.map(|v| format!(" version {v}")).unwrap_or_default()
37 );
38 }
39
40 let agent_info = get_agent_definition(&client, &name, version).await?;
42
43 if verbose {
44 println!(
45 "Found {} v{} by {}",
46 agent_info.name, agent_info.version, agent_info.author
47 );
48 }
49
50 let output_path = determine_output_file(&name, output, &config).await?;
52
53 if output_path.exists() && !force {
55 return Err(CarpError::FileSystem(format!(
56 "File '{}' already exists. Use --force to overwrite.",
57 output_path.display()
58 )));
59 }
60
61 let agent_content = create_agent_definition_file(&agent_info)?;
63
64 if let Some(parent) = output_path.parent() {
66 fs::create_dir_all(parent)?;
67 }
68
69 fs::write(&output_path, agent_content)?;
71
72 println!(
73 "{} Successfully pulled {} v{} to {}",
74 "✓".green().bold(),
75 agent_info.name.blue().bold(),
76 agent_info.version,
77 output_path.display().to_string().cyan()
78 );
79
80 println!("\nTo use this agent:");
82 println!(" # The agent definition is now available at {}", output_path.display());
83 println!(" # You can reference this agent in your code or agent orchestration system");
84
85 Ok(())
86}
87
88fn parse_agent_spec(spec: &str) -> CarpResult<(String, Option<&str>)> {
90 if let Some(at_pos) = spec.find('@') {
91 let name = &spec[..at_pos];
92 let version = &spec[at_pos + 1..];
93
94 if name.is_empty() || version.is_empty() {
95 return Err(CarpError::InvalidAgent(
96 "Invalid agent specification. Use 'name' or 'name@version'.".to_string(),
97 ));
98 }
99
100 Ok((name.to_string(), Some(version)))
101 } else {
102 Ok((spec.to_string(), None))
103 }
104}
105
106async fn get_agent_definition(
108 client: &ApiClient,
109 name: &str,
110 version: Option<&str>,
111) -> CarpResult<crate::api::types::Agent> {
112 let response = client.search(name, Some(1000), true).await?;
114
115 let target_version = version.unwrap_or("latest");
117
118 if target_version == "latest" {
119 response.agents
121 .into_iter()
122 .find(|agent| agent.name == name)
123 .ok_or_else(|| CarpError::Api {
124 status: 404,
125 message: format!("Agent '{name}' not found"),
126 })
127 } else {
128 response.agents
130 .into_iter()
131 .find(|agent| agent.name == name && agent.version == target_version)
132 .ok_or_else(|| CarpError::Api {
133 status: 404,
134 message: format!("Agent '{name}' version '{target_version}' not found"),
135 })
136 }
137}
138
139async fn determine_output_file(
141 name: &str,
142 output: Option<String>,
143 config: &crate::config::Config,
144) -> CarpResult<PathBuf> {
145 if let Some(output_path) = output {
146 let path = expand_tilde(&output_path);
147
148 if path.is_dir() || output_path.ends_with('/') || output_path.ends_with('\\') {
150 return Ok(path.join(format!("{name}.md")));
151 }
152
153 return Ok(path);
154 }
155
156 let default_agents_dir = get_default_agents_dir(config)?;
158
159 let prompt_text = format!(
161 "Where would you like to save the '{name}' agent definition?"
162 );
163
164 let default_path = default_agents_dir.join(format!("{name}.md"));
165
166 let file_path = Text::new(&prompt_text)
167 .with_default(&default_path.to_string_lossy())
168 .with_help_message("Enter the full path where you want to save the agent definition file")
169 .prompt()
170 .map_err(|e| match e {
171 InquireError::OperationCanceled => CarpError::Api {
172 status: 0,
173 message: "Operation cancelled by user.".to_string(),
174 },
175 _ => CarpError::Api {
176 status: 500,
177 message: format!("Input error: {e}"),
178 },
179 })?;
180
181 let path = expand_tilde(&file_path);
182
183 if path.is_dir() || file_path.ends_with('/') || file_path.ends_with('\\') {
185 Ok(path.join(format!("{name}.md")))
186 } else {
187 Ok(path)
188 }
189}
190
191fn expand_tilde(path: &str) -> PathBuf {
193 if let Some(stripped) = path.strip_prefix("~/") {
194 if let Some(home_dir) = dirs::home_dir() {
195 home_dir.join(stripped)
196 } else {
197 PathBuf::from(path)
198 }
199 } else if path == "~" {
200 dirs::home_dir().unwrap_or_else(|| PathBuf::from(path))
201 } else {
202 PathBuf::from(path)
203 }
204}
205
206fn get_default_agents_dir(config: &crate::config::Config) -> CarpResult<PathBuf> {
208 if let Some(default_dir) = &config.default_output_dir {
209 return Ok(PathBuf::from(default_dir));
210 }
211
212 let config_dir = dirs::config_dir()
214 .ok_or_else(|| CarpError::Config("Unable to find config directory".to_string()))?;
215
216 let agents_dir = config_dir.join("carp").join("agents");
217 Ok(agents_dir)
218}
219
220fn create_agent_definition_file(agent: &crate::api::types::Agent) -> CarpResult<String> {
222 let mut content = String::new();
223
224 content.push_str("---\n");
226 content.push_str(&format!("name: {}\n", agent.name));
227 content.push_str(&format!("version: {}\n", agent.version));
228 content.push_str(&format!("description: {}\n", agent.description));
229 content.push_str(&format!("author: {}\n", agent.author));
230
231 if let Some(homepage) = &agent.homepage {
232 content.push_str(&format!("homepage: {homepage}\n"));
233 }
234
235 if let Some(repository) = &agent.repository {
236 content.push_str(&format!("repository: {repository}\n"));
237 }
238
239 if let Some(license) = &agent.license {
240 content.push_str(&format!("license: {license}\n"));
241 }
242
243 if !agent.tags.is_empty() {
244 content.push_str("tags:\n");
245 for tag in &agent.tags {
246 content.push_str(&format!(" - {tag}\n"));
247 }
248 }
249
250 content.push_str(&format!("created_at: {}\n", agent.created_at.format("%Y-%m-%d %H:%M:%S UTC")));
251 content.push_str(&format!("updated_at: {}\n", agent.updated_at.format("%Y-%m-%d %H:%M:%S UTC")));
252 content.push_str(&format!("download_count: {}\n", agent.download_count));
253 content.push_str("---\n\n");
254
255 content.push_str(&format!("# {} Agent\n\n", agent.name));
257
258 content.push_str(&format!("{}\n\n", agent.description));
260
261 content.push_str("## Metadata\n\n");
263 content.push_str(&format!("- **Version**: {}\n", agent.version));
264 content.push_str(&format!("- **Author**: {}\n", agent.author));
265 content.push_str(&format!("- **Downloads**: {}\n", agent.download_count));
266 content.push_str(&format!("- **Created**: {}\n", agent.created_at.format("%Y-%m-%d %H:%M UTC")));
267 content.push_str(&format!("- **Updated**: {}\n", agent.updated_at.format("%Y-%m-%d %H:%M UTC")));
268
269 if !agent.tags.is_empty() {
270 content.push_str(&format!("- **Tags**: {}\n", agent.tags.join(", ")));
271 }
272
273 if let Some(homepage) = &agent.homepage {
274 content.push_str(&format!("- **Homepage**: {homepage}\n"));
275 }
276
277 if let Some(repository) = &agent.repository {
278 content.push_str(&format!("- **Repository**: {repository}\n"));
279 }
280
281 if let Some(license) = &agent.license {
282 content.push_str(&format!("- **License**: {license}\n"));
283 }
284
285 if let Some(readme) = &agent.readme {
287 if !readme.trim().is_empty() {
288 content.push_str("\n## README\n\n");
289 content.push_str(readme);
290 content.push('\n');
291 }
292 }
293
294 Ok(content)
295}
296
297
298async fn interactive_agent_selection(client: &ApiClient) -> CarpResult<String> {
300 let agent_names = get_unique_agent_names(client).await?;
302
303 if agent_names.is_empty() {
304 return Err(CarpError::Api {
305 status: 404,
306 message: "No agents found in the registry.".to_string(),
307 });
308 }
309
310 println!(
311 "{} {} unique agents available:",
312 "Found".green().bold(),
313 agent_names.len()
314 );
315
316 let selected_agent = Select::new("Select an agent:", agent_names.clone())
318 .with_page_size(15)
319 .with_help_message("↑/↓ to navigate • Enter to select • Ctrl+C to cancel")
320 .prompt()
321 .map_err(|e| match e {
322 InquireError::OperationCanceled => CarpError::Api {
323 status: 0,
324 message: "Operation cancelled by user.".to_string(),
325 },
326 _ => CarpError::Api {
327 status: 500,
328 message: format!("Selection error: {e}"),
329 },
330 })?;
331
332 let versions = get_agent_versions(client, &selected_agent).await?;
334
335 if versions.is_empty() {
336 return Err(CarpError::Api {
337 status: 404,
338 message: format!("No versions found for agent '{selected_agent}'."),
339 });
340 }
341
342 println!(
343 "\n{} {} versions available for {}:",
344 "Found".green().bold(),
345 versions.len(),
346 selected_agent.blue().bold()
347 );
348
349 let selected_version = if versions.len() == 1 {
351 versions[0].clone()
352 } else {
353 Select::new(
354 &format!("Select a version for {}:", selected_agent.blue().bold()),
355 versions.clone()
356 )
357 .with_page_size(15)
358 .with_help_message("↑/↓ to navigate • Enter to select • Ctrl+C to cancel")
359 .prompt()
360 .map_err(|e| match e {
361 InquireError::OperationCanceled => CarpError::Api {
362 status: 0,
363 message: "Operation cancelled by user.".to_string(),
364 },
365 _ => CarpError::Api {
366 status: 500,
367 message: format!("Selection error: {e}"),
368 },
369 })?
370 };
371
372 println!(
373 "\n{} Selected: {} v{}",
374 "✓".green().bold(),
375 selected_agent.blue().bold(),
376 selected_version
377 );
378
379 if let Ok(agent_info) = get_agent_definition(client, &selected_agent, Some(&selected_version)).await {
381 display_agent_definition(&agent_info);
382 }
383
384 Ok(format!("{selected_agent}@{selected_version}"))
385}
386
387async fn get_unique_agent_names(client: &ApiClient) -> CarpResult<Vec<String>> {
389 let response = client.search("", Some(1000), false).await?;
390
391 let mut unique_names: std::collections::HashSet<String> = std::collections::HashSet::new();
392 for agent in response.agents {
393 unique_names.insert(agent.name);
394 }
395
396 let mut names: Vec<String> = unique_names.into_iter().collect();
397 names.sort();
398 Ok(names)
399}
400
401async fn get_agent_versions(client: &ApiClient, agent_name: &str) -> CarpResult<Vec<String>> {
403 let response = client.search(agent_name, Some(1000), true).await?;
404
405 let mut versions: Vec<String> = response.agents
406 .into_iter()
407 .filter(|agent| agent.name == agent_name)
408 .map(|agent| agent.version)
409 .collect();
410
411 versions.sort_by(|a, b| {
413 b.cmp(a)
415 });
416
417 Ok(versions)
418}
419
420
421fn display_agent_definition(agent: &crate::api::types::Agent) {
423 println!("\n{}", "Agent Definition:".bold().underline());
424 println!(" {}: {}", "Name".bold(), agent.name.blue());
425 println!(" {}: {}", "Version".bold(), agent.version);
426 println!(" {}: {}", "Author".bold(), agent.author.green());
427 println!(" {}: {}", "Description".bold(), agent.description);
428
429 if let Some(homepage) = &agent.homepage {
430 println!(" {}: {}", "Homepage".bold(), homepage.cyan());
431 }
432
433 if let Some(repository) = &agent.repository {
434 println!(" {}: {}", "Repository".bold(), repository.cyan());
435 }
436
437 if let Some(license) = &agent.license {
438 println!(" {}: {}", "License".bold(), license);
439 }
440
441 if !agent.tags.is_empty() {
442 print!(" {}: ", "Tags".bold());
443 for (i, tag) in agent.tags.iter().enumerate() {
444 if i > 0 {
445 print!(", ");
446 }
447 print!("{}", tag.yellow());
448 }
449 println!();
450 }
451
452 println!(" {}: {}", "Downloads".bold(), agent.download_count.to_string().cyan());
453 println!(" {}: {}", "Created".bold(), agent.created_at.format("%Y-%m-%d %H:%M UTC"));
454 println!(" {}: {}", "Updated".bold(), agent.updated_at.format("%Y-%m-%d %H:%M UTC"));
455
456 if let Some(readme) = &agent.readme {
457 if !readme.trim().is_empty() {
458 println!("\n{}", "README:".bold().underline());
459 println!("{readme}");
460 }
461 }
462
463 println!();
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
471 fn test_parse_agent_spec() {
472 let (name, version) = parse_agent_spec("test-agent").unwrap();
473 assert_eq!(name, "test-agent");
474 assert!(version.is_none());
475
476 let (name, version) = parse_agent_spec("test-agent@1.0.0").unwrap();
477 assert_eq!(name, "test-agent");
478 assert_eq!(version, Some("1.0.0"));
479
480 assert!(parse_agent_spec("@1.0.0").is_err());
481 assert!(parse_agent_spec("test-agent@").is_err());
482 }
483
484 #[test]
485 fn test_expand_tilde() {
486 let expanded = expand_tilde("~/test/path");
488 if let Some(home_dir) = dirs::home_dir() {
489 assert_eq!(expanded, home_dir.join("test/path"));
490 }
491
492 let expanded = expand_tilde("~");
494 if let Some(home_dir) = dirs::home_dir() {
495 assert_eq!(expanded, home_dir);
496 }
497
498 let expanded = expand_tilde("/absolute/path");
500 assert_eq!(expanded, PathBuf::from("/absolute/path"));
501
502 let expanded = expand_tilde("relative/path");
504 assert_eq!(expanded, PathBuf::from("relative/path"));
505 }
506
507 #[test]
508 fn test_directory_path_handling() {
509 use std::fs;
510 use tempfile::TempDir;
511
512 let temp_dir = TempDir::new().unwrap();
514 let temp_path = temp_dir.path();
515
516 let existing_dir = temp_path.join("existing");
518 fs::create_dir(&existing_dir).unwrap();
519
520 let agent_name = "test-agent";
522 let file_path = existing_dir.to_string_lossy().to_string();
523 let path = expand_tilde(&file_path);
524
525 let result = if path.is_dir() || file_path.ends_with('/') || file_path.ends_with('\\') {
526 path.join(format!("{agent_name}.md"))
527 } else {
528 path
529 };
530
531 assert_eq!(result, existing_dir.join("test-agent.md"));
532
533 let dir_with_slash = format!("{}/", temp_path.join("nonexistent").to_string_lossy());
535 let path = expand_tilde(&dir_with_slash);
536
537 let result = if path.is_dir() || dir_with_slash.ends_with('/') || dir_with_slash.ends_with('\\') {
538 path.join(format!("{agent_name}.md"))
539 } else {
540 path
541 };
542
543 assert_eq!(result, temp_path.join("nonexistent").join("test-agent.md"));
544
545 let file_path = temp_path.join("agent.md").to_string_lossy().to_string();
547 let path = expand_tilde(&file_path);
548
549 let result = if path.is_dir() || file_path.ends_with('/') || file_path.ends_with('\\') {
550 path.join(format!("{agent_name}.md"))
551 } else {
552 path
553 };
554
555 assert_eq!(result, temp_path.join("agent.md"));
556
557 if let Some(home_dir) = dirs::home_dir() {
559 let tilde_path = "~/.claude/agents/";
560 let path = expand_tilde(tilde_path);
561
562 let result = if path.is_dir() || tilde_path.ends_with('/') || tilde_path.ends_with('\\') {
563 path.join(format!("{agent_name}.md"))
564 } else {
565 path
566 };
567
568 assert_eq!(result, home_dir.join(".claude/agents/test-agent.md"));
569 }
570 }
571}