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!(
83 " # The agent definition is now available at {}",
84 output_path.display()
85 );
86 println!(" # You can reference this agent in your code or agent orchestration system");
87
88 Ok(())
89}
90
91fn parse_agent_spec(spec: &str) -> CarpResult<(String, Option<&str>)> {
93 if let Some(at_pos) = spec.find('@') {
94 let name = &spec[..at_pos];
95 let version = &spec[at_pos + 1..];
96
97 if name.is_empty() || version.is_empty() {
98 return Err(CarpError::InvalidAgent(
99 "Invalid agent specification. Use 'name' or 'name@version'.".to_string(),
100 ));
101 }
102
103 Ok((name.to_string(), Some(version)))
104 } else {
105 Ok((spec.to_string(), None))
106 }
107}
108
109async fn get_agent_definition(
111 client: &ApiClient,
112 name: &str,
113 version: Option<&str>,
114) -> CarpResult<crate::api::types::Agent> {
115 let response = client.search(name, Some(1000), true).await?;
117
118 let target_version = version.unwrap_or("latest");
120
121 if target_version == "latest" {
122 response
124 .agents
125 .into_iter()
126 .find(|agent| agent.name == name)
127 .ok_or_else(|| CarpError::Api {
128 status: 404,
129 message: format!("Agent '{name}' not found"),
130 })
131 } else {
132 response
134 .agents
135 .into_iter()
136 .find(|agent| agent.name == name && agent.version == target_version)
137 .ok_or_else(|| CarpError::Api {
138 status: 404,
139 message: format!("Agent '{name}' version '{target_version}' not found"),
140 })
141 }
142}
143
144async fn determine_output_file(
146 name: &str,
147 output: Option<String>,
148 config: &crate::config::Config,
149) -> CarpResult<PathBuf> {
150 if let Some(output_path) = output {
151 let path = expand_tilde(&output_path);
152
153 if path.is_dir() || output_path.ends_with('/') || output_path.ends_with('\\') {
155 return Ok(path.join(format!("{name}.md")));
156 }
157
158 return Ok(path);
159 }
160
161 let default_agents_dir = get_default_agents_dir(config)?;
163
164 let prompt_text = format!("Where would you like to save the '{name}' agent definition?");
166
167 let default_path = default_agents_dir.join(format!("{name}.md"));
168
169 let file_path = Text::new(&prompt_text)
170 .with_default(&default_path.to_string_lossy())
171 .with_help_message("Enter the full path where you want to save the agent definition file")
172 .prompt()
173 .map_err(|e| match e {
174 InquireError::OperationCanceled => CarpError::Api {
175 status: 0,
176 message: "Operation cancelled by user.".to_string(),
177 },
178 _ => CarpError::Api {
179 status: 500,
180 message: format!("Input error: {e}"),
181 },
182 })?;
183
184 let path = expand_tilde(&file_path);
185
186 if path.is_dir() || file_path.ends_with('/') || file_path.ends_with('\\') {
188 Ok(path.join(format!("{name}.md")))
189 } else {
190 Ok(path)
191 }
192}
193
194fn expand_tilde(path: &str) -> PathBuf {
196 if let Some(stripped) = path.strip_prefix("~/") {
197 if let Some(home_dir) = dirs::home_dir() {
198 home_dir.join(stripped)
199 } else {
200 PathBuf::from(path)
201 }
202 } else if path == "~" {
203 dirs::home_dir().unwrap_or_else(|| PathBuf::from(path))
204 } else {
205 PathBuf::from(path)
206 }
207}
208
209fn get_default_agents_dir(config: &crate::config::Config) -> CarpResult<PathBuf> {
211 if let Some(default_dir) = &config.default_output_dir {
212 return Ok(PathBuf::from(default_dir));
213 }
214
215 let config_dir = dirs::config_dir()
217 .ok_or_else(|| CarpError::Config("Unable to find config directory".to_string()))?;
218
219 let agents_dir = config_dir.join("carp").join("agents");
220 Ok(agents_dir)
221}
222
223fn create_agent_definition_file(agent: &crate::api::types::Agent) -> CarpResult<String> {
225 let mut content = String::new();
226
227 content.push_str("---\n");
229 content.push_str(&format!("name: {}\n", agent.name));
230 content.push_str(&format!("version: {}\n", agent.version));
231 content.push_str(&format!("description: {}\n", agent.description));
232 content.push_str(&format!("author: {}\n", agent.author));
233
234 if let Some(homepage) = &agent.homepage {
235 content.push_str(&format!("homepage: {homepage}\n"));
236 }
237
238 if let Some(repository) = &agent.repository {
239 content.push_str(&format!("repository: {repository}\n"));
240 }
241
242 if let Some(license) = &agent.license {
243 content.push_str(&format!("license: {license}\n"));
244 }
245
246 if !agent.tags.is_empty() {
247 content.push_str("tags:\n");
248 for tag in &agent.tags {
249 content.push_str(&format!(" - {tag}\n"));
250 }
251 }
252
253 content.push_str(&format!(
254 "created_at: {}\n",
255 agent.created_at.format("%Y-%m-%d %H:%M:%S UTC")
256 ));
257 content.push_str(&format!(
258 "updated_at: {}\n",
259 agent.updated_at.format("%Y-%m-%d %H:%M:%S UTC")
260 ));
261 content.push_str(&format!("download_count: {}\n", agent.download_count));
262 content.push_str("---\n\n");
263
264 content.push_str(&format!("# {} Agent\n\n", agent.name));
266
267 content.push_str(&format!("{}\n\n", agent.description));
269
270 content.push_str("## Metadata\n\n");
272 content.push_str(&format!("- **Version**: {}\n", agent.version));
273 content.push_str(&format!("- **Author**: {}\n", agent.author));
274 content.push_str(&format!("- **Downloads**: {}\n", agent.download_count));
275 content.push_str(&format!(
276 "- **Created**: {}\n",
277 agent.created_at.format("%Y-%m-%d %H:%M UTC")
278 ));
279 content.push_str(&format!(
280 "- **Updated**: {}\n",
281 agent.updated_at.format("%Y-%m-%d %H:%M UTC")
282 ));
283
284 if !agent.tags.is_empty() {
285 content.push_str(&format!("- **Tags**: {}\n", agent.tags.join(", ")));
286 }
287
288 if let Some(homepage) = &agent.homepage {
289 content.push_str(&format!("- **Homepage**: {homepage}\n"));
290 }
291
292 if let Some(repository) = &agent.repository {
293 content.push_str(&format!("- **Repository**: {repository}\n"));
294 }
295
296 if let Some(license) = &agent.license {
297 content.push_str(&format!("- **License**: {license}\n"));
298 }
299
300 if let Some(readme) = &agent.readme {
302 if !readme.trim().is_empty() {
303 content.push_str("\n## README\n\n");
304 content.push_str(readme);
305 content.push('\n');
306 }
307 }
308
309 Ok(content)
310}
311
312async fn interactive_agent_selection(client: &ApiClient) -> CarpResult<String> {
314 let agent_names = get_unique_agent_names(client).await?;
316
317 if agent_names.is_empty() {
318 return Err(CarpError::Api {
319 status: 404,
320 message: "No agents found in the registry.".to_string(),
321 });
322 }
323
324 println!(
325 "{} {} unique agents available:",
326 "Found".green().bold(),
327 agent_names.len()
328 );
329
330 let selected_agent = Select::new("Select an agent:", agent_names.clone())
332 .with_page_size(15)
333 .with_help_message("↑/↓ to navigate • Enter to select • Ctrl+C to cancel")
334 .prompt()
335 .map_err(|e| match e {
336 InquireError::OperationCanceled => CarpError::Api {
337 status: 0,
338 message: "Operation cancelled by user.".to_string(),
339 },
340 _ => CarpError::Api {
341 status: 500,
342 message: format!("Selection error: {e}"),
343 },
344 })?;
345
346 let versions = get_agent_versions(client, &selected_agent).await?;
348
349 if versions.is_empty() {
350 return Err(CarpError::Api {
351 status: 404,
352 message: format!("No versions found for agent '{selected_agent}'."),
353 });
354 }
355
356 println!(
357 "\n{} {} versions available for {}:",
358 "Found".green().bold(),
359 versions.len(),
360 selected_agent.blue().bold()
361 );
362
363 let selected_version = if versions.len() == 1 {
365 versions[0].clone()
366 } else {
367 Select::new(
368 &format!("Select a version for {}:", selected_agent.blue().bold()),
369 versions.clone(),
370 )
371 .with_page_size(15)
372 .with_help_message("↑/↓ to navigate • Enter to select • Ctrl+C to cancel")
373 .prompt()
374 .map_err(|e| match e {
375 InquireError::OperationCanceled => CarpError::Api {
376 status: 0,
377 message: "Operation cancelled by user.".to_string(),
378 },
379 _ => CarpError::Api {
380 status: 500,
381 message: format!("Selection error: {e}"),
382 },
383 })?
384 };
385
386 println!(
387 "\n{} Selected: {} v{}",
388 "✓".green().bold(),
389 selected_agent.blue().bold(),
390 selected_version
391 );
392
393 if let Ok(agent_info) =
395 get_agent_definition(client, &selected_agent, Some(&selected_version)).await
396 {
397 display_agent_definition(&agent_info);
398 }
399
400 Ok(format!("{selected_agent}@{selected_version}"))
401}
402
403async fn get_unique_agent_names(client: &ApiClient) -> CarpResult<Vec<String>> {
405 let response = client.search("", Some(1000), false).await?;
406
407 let mut unique_names: std::collections::HashSet<String> = std::collections::HashSet::new();
408 for agent in response.agents {
409 unique_names.insert(agent.name);
410 }
411
412 let mut names: Vec<String> = unique_names.into_iter().collect();
413 names.sort();
414 Ok(names)
415}
416
417async fn get_agent_versions(client: &ApiClient, agent_name: &str) -> CarpResult<Vec<String>> {
419 let response = client.search(agent_name, Some(1000), true).await?;
420
421 let mut versions: Vec<String> = response
422 .agents
423 .into_iter()
424 .filter(|agent| agent.name == agent_name)
425 .map(|agent| agent.version)
426 .collect();
427
428 versions.sort_by(|a, b| {
430 b.cmp(a)
432 });
433
434 Ok(versions)
435}
436
437fn display_agent_definition(agent: &crate::api::types::Agent) {
439 println!("\n{}", "Agent Definition:".bold().underline());
440 println!(" {}: {}", "Name".bold(), agent.name.blue());
441 println!(" {}: {}", "Version".bold(), agent.version);
442 println!(" {}: {}", "Author".bold(), agent.author.green());
443 println!(" {}: {}", "Description".bold(), agent.description);
444
445 if let Some(homepage) = &agent.homepage {
446 println!(" {}: {}", "Homepage".bold(), homepage.cyan());
447 }
448
449 if let Some(repository) = &agent.repository {
450 println!(" {}: {}", "Repository".bold(), repository.cyan());
451 }
452
453 if let Some(license) = &agent.license {
454 println!(" {}: {}", "License".bold(), license);
455 }
456
457 if !agent.tags.is_empty() {
458 print!(" {}: ", "Tags".bold());
459 for (i, tag) in agent.tags.iter().enumerate() {
460 if i > 0 {
461 print!(", ");
462 }
463 print!("{}", tag.yellow());
464 }
465 println!();
466 }
467
468 println!(
469 " {}: {}",
470 "Downloads".bold(),
471 agent.download_count.to_string().cyan()
472 );
473 println!(
474 " {}: {}",
475 "Created".bold(),
476 agent.created_at.format("%Y-%m-%d %H:%M UTC")
477 );
478 println!(
479 " {}: {}",
480 "Updated".bold(),
481 agent.updated_at.format("%Y-%m-%d %H:%M UTC")
482 );
483
484 if let Some(readme) = &agent.readme {
485 if !readme.trim().is_empty() {
486 println!("\n{}", "README:".bold().underline());
487 println!("{readme}");
488 }
489 }
490
491 println!();
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 #[test]
499 fn test_parse_agent_spec() {
500 let (name, version) = parse_agent_spec("test-agent").unwrap();
501 assert_eq!(name, "test-agent");
502 assert!(version.is_none());
503
504 let (name, version) = parse_agent_spec("test-agent@1.0.0").unwrap();
505 assert_eq!(name, "test-agent");
506 assert_eq!(version, Some("1.0.0"));
507
508 assert!(parse_agent_spec("@1.0.0").is_err());
509 assert!(parse_agent_spec("test-agent@").is_err());
510 }
511
512 #[test]
513 fn test_expand_tilde() {
514 let expanded = expand_tilde("~/test/path");
516 if let Some(home_dir) = dirs::home_dir() {
517 assert_eq!(expanded, home_dir.join("test/path"));
518 }
519
520 let expanded = expand_tilde("~");
522 if let Some(home_dir) = dirs::home_dir() {
523 assert_eq!(expanded, home_dir);
524 }
525
526 let expanded = expand_tilde("/absolute/path");
528 assert_eq!(expanded, PathBuf::from("/absolute/path"));
529
530 let expanded = expand_tilde("relative/path");
532 assert_eq!(expanded, PathBuf::from("relative/path"));
533 }
534
535 #[test]
536 fn test_directory_path_handling() {
537 use std::fs;
538 use tempfile::TempDir;
539
540 let temp_dir = TempDir::new().unwrap();
542 let temp_path = temp_dir.path();
543
544 let existing_dir = temp_path.join("existing");
546 fs::create_dir(&existing_dir).unwrap();
547
548 let agent_name = "test-agent";
550 let file_path = existing_dir.to_string_lossy().to_string();
551 let path = expand_tilde(&file_path);
552
553 let result = if path.is_dir() || file_path.ends_with('/') || file_path.ends_with('\\') {
554 path.join(format!("{agent_name}.md"))
555 } else {
556 path
557 };
558
559 assert_eq!(result, existing_dir.join("test-agent.md"));
560
561 let dir_with_slash = format!("{}/", temp_path.join("nonexistent").to_string_lossy());
563 let path = expand_tilde(&dir_with_slash);
564
565 let result =
566 if path.is_dir() || dir_with_slash.ends_with('/') || dir_with_slash.ends_with('\\') {
567 path.join(format!("{agent_name}.md"))
568 } else {
569 path
570 };
571
572 assert_eq!(result, temp_path.join("nonexistent").join("test-agent.md"));
573
574 let file_path = temp_path.join("agent.md").to_string_lossy().to_string();
576 let path = expand_tilde(&file_path);
577
578 let result = if path.is_dir() || file_path.ends_with('/') || file_path.ends_with('\\') {
579 path.join(format!("{agent_name}.md"))
580 } else {
581 path
582 };
583
584 assert_eq!(result, temp_path.join("agent.md"));
585
586 if let Some(home_dir) = dirs::home_dir() {
588 let tilde_path = "~/.claude/agents/";
589 let path = expand_tilde(tilde_path);
590
591 let result = if path.is_dir() || tilde_path.ends_with('/') || tilde_path.ends_with('\\')
592 {
593 path.join(format!("{agent_name}.md"))
594 } else {
595 path
596 };
597
598 assert_eq!(result, home_dir.join(".claude/agents/test-agent.md"));
599 }
600 }
601}