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!(
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
91/// Parse agent specification (name or name@version)
92fn 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
109/// Get agent definition directly from search API
110async fn get_agent_definition(
111    client: &ApiClient,
112    name: &str,
113    version: Option<&str>,
114) -> CarpResult<crate::api::types::Agent> {
115    // Search for the specific agent
116    let response = client.search(name, Some(1000), true).await?;
117
118    // Find the agent with matching name and version
119    let target_version = version.unwrap_or("latest");
120
121    if target_version == "latest" {
122        // Find the latest version (versions are sorted in descending order from search)
123        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        // Find exact version match
133        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
144/// Determine the output file path for the agent definition
145async 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 the path is a directory (or will be a directory), append the agent name as filename
154        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    // Get default agents directory
162    let default_agents_dir = get_default_agents_dir(config)?;
163
164    // Ask user where to place the file
165    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 the path is a directory (or will be a directory), append the agent name as filename
187    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
194/// Expand tilde (~) in file paths
195fn 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
209/// Get the default agents directory
210fn 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    // Use ~/.config/carp/agents/ as default
216    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
223/// Create agent definition file content
224fn create_agent_definition_file(agent: &crate::api::types::Agent) -> CarpResult<String> {
225    let mut content = String::new();
226
227    // Add YAML frontmatter
228    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    // Add title
265    content.push_str(&format!("# {} Agent\n\n", agent.name));
266
267    // Add description
268    content.push_str(&format!("{}\n\n", agent.description));
269
270    // Add metadata section
271    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    // Add README if available
301    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
312/// Interactive agent selection using inquire
313async fn interactive_agent_selection(client: &ApiClient) -> CarpResult<String> {
314    // Step 1: Get unique agent names
315    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    // Step 2: Let user select agent name
331    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    // Step 3: Get versions for selected agent
347    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    // Step 4: Let user select version
364    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    // Step 5: Get and display agent definition
394    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
403/// Get unique agent names from the registry
404async 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
417/// Get versions for a specific agent
418async 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    // Sort versions in descending order (latest first)
429    versions.sort_by(|a, b| {
430        // Simple lexicographic comparison for now - could be improved with proper semver
431        b.cmp(a)
432    });
433
434    Ok(versions)
435}
436
437/// Display agent definition information
438fn 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        // Test tilde expansion for home directory paths
515        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        // Test just tilde
521        let expanded = expand_tilde("~");
522        if let Some(home_dir) = dirs::home_dir() {
523            assert_eq!(expanded, home_dir);
524        }
525
526        // Test absolute paths (no tilde)
527        let expanded = expand_tilde("/absolute/path");
528        assert_eq!(expanded, PathBuf::from("/absolute/path"));
529
530        // Test relative paths (no tilde)
531        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        // Create a temporary directory for testing
541        let temp_dir = TempDir::new().unwrap();
542        let temp_path = temp_dir.path();
543
544        // Test case 1: Existing directory should append agent name
545        let existing_dir = temp_path.join("existing");
546        fs::create_dir(&existing_dir).unwrap();
547
548        // Mock the logic from determine_output_file for directory handling
549        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        // Test case 2: Path ending with '/' should append agent name
562        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        // Test case 3: Regular file path should be returned as-is
575        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        // Test case 4: Tilde expansion with directory should work
587        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}