carp_cli/commands/
pull.rs

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
9/// Execute the pull command
10pub 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    // If no agent specified, show interactive selection
20    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    // Get agent definition directly from search API
41    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    // Determine output file path
51    let output_path = determine_output_file(&name, output, &config).await?;
52
53    // Check if file exists and handle force flag
54    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    // Create the agent definition content
62    let agent_content = create_agent_definition_file(&agent_info)?;
63
64    // Ensure the parent directory exists
65    if let Some(parent) = output_path.parent() {
66        fs::create_dir_all(parent)?;
67    }
68
69    // Write the agent definition file
70    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    // Show usage instructions
81    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
88/// Parse agent specification (name or name@version)
89fn 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
106/// Get agent definition directly from search API
107async fn get_agent_definition(
108    client: &ApiClient,
109    name: &str,
110    version: Option<&str>,
111) -> CarpResult<crate::api::types::Agent> {
112    // Search for the specific agent
113    let response = client.search(name, Some(1000), true).await?;
114    
115    // Find the agent with matching name and version
116    let target_version = version.unwrap_or("latest");
117    
118    if target_version == "latest" {
119        // Find the latest version (versions are sorted in descending order from search)
120        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        // Find exact version match
129        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
139/// Determine the output file path for the agent definition
140async 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 the path is a directory (or will be a directory), append the agent name as filename
149        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    // Get default agents directory
157    let default_agents_dir = get_default_agents_dir(config)?;
158    
159    // Ask user where to place the file
160    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 the path is a directory (or will be a directory), append the agent name as filename
184    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
191/// Expand tilde (~) in file paths
192fn 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
206/// Get the default agents directory
207fn 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    // Use ~/.config/carp/agents/ as default
213    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
220/// Create agent definition file content
221fn create_agent_definition_file(agent: &crate::api::types::Agent) -> CarpResult<String> {
222    let mut content = String::new();
223    
224    // Add YAML frontmatter
225    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    // Add title
256    content.push_str(&format!("# {} Agent\n\n", agent.name));
257    
258    // Add description
259    content.push_str(&format!("{}\n\n", agent.description));
260    
261    // Add metadata section
262    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    // Add README if available
286    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
298/// Interactive agent selection using inquire
299async fn interactive_agent_selection(client: &ApiClient) -> CarpResult<String> {
300    // Step 1: Get unique agent names
301    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    // Step 2: Let user select agent name
317    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    // Step 3: Get versions for selected agent
333    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    // Step 4: Let user select version
350    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    // Step 5: Get and display agent definition
380    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
387/// Get unique agent names from the registry
388async 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
401/// Get versions for a specific agent
402async 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    // Sort versions in descending order (latest first)
412    versions.sort_by(|a, b| {
413        // Simple lexicographic comparison for now - could be improved with proper semver
414        b.cmp(a)
415    });
416    
417    Ok(versions)
418}
419
420
421/// Display agent definition information
422fn 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        // Test tilde expansion for home directory paths
487        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        // Test just tilde
493        let expanded = expand_tilde("~");
494        if let Some(home_dir) = dirs::home_dir() {
495            assert_eq!(expanded, home_dir);
496        }
497
498        // Test absolute paths (no tilde)
499        let expanded = expand_tilde("/absolute/path");
500        assert_eq!(expanded, PathBuf::from("/absolute/path"));
501
502        // Test relative paths (no tilde)
503        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        // Create a temporary directory for testing
513        let temp_dir = TempDir::new().unwrap();
514        let temp_path = temp_dir.path();
515
516        // Test case 1: Existing directory should append agent name
517        let existing_dir = temp_path.join("existing");
518        fs::create_dir(&existing_dir).unwrap();
519        
520        // Mock the logic from determine_output_file for directory handling
521        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        // Test case 2: Path ending with '/' should append agent name
534        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        // Test case 3: Regular file path should be returned as-is
546        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        // Test case 4: Tilde expansion with directory should work
558        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}