carp_cli/commands/
upload.rs

1use crate::api::{ApiClient, UploadAgentRequest};
2use crate::auth::AuthManager;
3use crate::config::ConfigManager;
4use crate::utils::error::{CarpError, CarpResult};
5use colored::*;
6use inquire::Select;
7use std::fs;
8use std::path::{Path, PathBuf};
9use walkdir::WalkDir;
10
11/// Agent file information extracted from agent definition files
12#[derive(Debug, Clone)]
13pub struct AgentFile {
14    pub path: PathBuf,
15    pub name: String,
16    pub description: String,
17    pub display_name: String,
18}
19
20/// Selection result from agent selection prompt
21#[derive(Debug)]
22enum AgentSelection {
23    Single(AgentFile),
24    All(Vec<AgentFile>),
25}
26
27/// Execute the upload command
28pub async fn execute(
29    directory: Option<String>,
30    api_key: Option<String>,
31    verbose: bool,
32) -> CarpResult<()> {
33    // Load config first to get stored API key
34    let config = ConfigManager::load_with_env_checks()?;
35    
36    if verbose {
37        println!("DEBUG: Runtime API key present: {}", api_key.is_some());
38        println!("DEBUG: Stored API key present: {}", config.api_key.is_some());
39    }
40    
41    // Use runtime API key if provided, otherwise use stored API key
42    let effective_api_key = api_key.as_deref().or(config.api_key.as_deref());
43    
44    if verbose {
45        println!("DEBUG: Effective API key present: {}", effective_api_key.is_some());
46    }
47    
48    // Ensure user is authenticated (either via API key parameter or stored configuration)
49    AuthManager::ensure_authenticated(effective_api_key).await?;
50
51    // Get directory path - either provided, prompted for, or use default
52    let dir_path = get_directory_path(directory, verbose)?;
53
54    if verbose {
55        println!("Scanning directory: {}", dir_path.display());
56    }
57
58    // Scan directory for agent files
59    let agent_files = scan_agent_files(&dir_path, verbose)?;
60
61    if agent_files.is_empty() {
62        println!(
63            "{} No agent files found in {}",
64            "Warning:".yellow().bold(),
65            dir_path.display()
66        );
67        println!(
68            "Looking for .md files with YAML frontmatter containing name and description fields."
69        );
70        return Ok(());
71    }
72
73    if verbose {
74        println!("Found {} agent files", agent_files.len());
75    }
76
77    // Use inquire to prompt user for agent selection (including "All" option)
78    let selection = select_agents(agent_files.clone())?;
79
80    match selection {
81        AgentSelection::Single(agent) => {
82            if verbose {
83                println!("Selected agent: {}", agent.name);
84            }
85
86            // Read and parse the selected agent file
87            let agent_content = fs::read_to_string(&agent.path)?;
88
89            // Upload the agent
90            upload_agent(&agent, agent_content, effective_api_key, verbose, &config).await?;
91
92            println!(
93                "{} Successfully uploaded agent '{}'",
94                "✓".green().bold(),
95                agent.name.blue().bold()
96            );
97        }
98        AgentSelection::All(agents) => {
99            if verbose {
100                println!("Uploading all {} agents", agents.len());
101            }
102
103            let mut successful = 0;
104            let mut failed = 0;
105
106            for agent in agents {
107                println!(
108                    "{} Uploading agent '{}'...",
109                    "⟳".blue().bold(),
110                    agent.name.blue().bold()
111                );
112
113                match fs::read_to_string(&agent.path) {
114                    Ok(agent_content) => {
115                        match upload_agent(&agent, agent_content, effective_api_key, verbose, &config).await {
116                            Ok(_) => {
117                                println!(
118                                    "{} Successfully uploaded agent '{}'",
119                                    "✓".green().bold(),
120                                    agent.name.blue().bold()
121                                );
122                                successful += 1;
123                            }
124                            Err(e) => {
125                                println!(
126                                    "{} Failed to upload agent '{}': {}",
127                                    "✗".red().bold(),
128                                    agent.name.red().bold(),
129                                    e
130                                );
131                                failed += 1;
132                            }
133                        }
134                    }
135                    Err(e) => {
136                        println!(
137                            "{} Failed to read agent '{}': {}",
138                            "✗".red().bold(),
139                            agent.name.red().bold(),
140                            e
141                        );
142                        failed += 1;
143                    }
144                }
145            }
146
147            println!(
148                "\n{} Upload complete: {} successful, {} failed",
149                "✓".green().bold(),
150                successful.to_string().green().bold(),
151                if failed > 0 { failed.to_string().red().bold() } else { failed.to_string().green().bold() }
152            );
153        }
154    }
155
156    Ok(())
157}
158
159/// Get directory path from user input, prompt, or default
160fn get_directory_path(directory: Option<String>, verbose: bool) -> CarpResult<PathBuf> {
161    let dir_path = if let Some(dir) = directory {
162        // Directory provided via command line
163        expand_directory_path(Some(dir))?
164    } else {
165        // Prompt user for directory
166        let default_dir = "~/.claude/agents/";
167        let prompt_text = format!("Enter directory to scan for agents (default: {default_dir}):");
168        
169        let input = inquire::Text::new(&prompt_text)
170            .with_default(default_dir)
171            .prompt()
172            .map_err(|e| CarpError::Other(format!("Input cancelled: {e}")))?;
173        
174        let input = if input.trim().is_empty() {
175            default_dir.to_string()
176        } else {
177            input
178        };
179        
180        expand_directory_path(Some(input))?
181    };
182    
183    if verbose {
184        println!("Using directory: {}", dir_path.display());
185    }
186    
187    Ok(dir_path)
188}
189
190/// Expand directory path, handling tilde expansion
191fn expand_directory_path(directory: Option<String>) -> CarpResult<PathBuf> {
192    let dir_str = directory.unwrap_or_else(|| "~/.claude/agents/".to_string());
193
194    let expanded_path = if let Some(stripped) = dir_str.strip_prefix('~') {
195        if let Some(home_dir) = dirs::home_dir() {
196            home_dir.join(dir_str.strip_prefix("~/").unwrap_or(stripped))
197        } else {
198            return Err(CarpError::FileSystem(
199                "Unable to determine home directory".to_string(),
200            ));
201        }
202    } else {
203        PathBuf::from(dir_str)
204    };
205
206    if !expanded_path.exists() {
207        return Err(CarpError::FileSystem(format!(
208            "Directory does not exist: {}",
209            expanded_path.display()
210        )));
211    }
212
213    if !expanded_path.is_dir() {
214        return Err(CarpError::FileSystem(format!(
215            "Path is not a directory: {}",
216            expanded_path.display()
217        )));
218    }
219
220    Ok(expanded_path)
221}
222
223/// Scan directory recursively for agent definition files
224fn scan_agent_files(dir_path: &Path, verbose: bool) -> CarpResult<Vec<AgentFile>> {
225    let mut agents = Vec::new();
226
227    if verbose {
228        println!("Scanning for agent files recursively...");
229    }
230
231    for entry in WalkDir::new(dir_path).follow_links(false) {
232        let entry =
233            entry.map_err(|e| CarpError::FileSystem(format!("Error scanning directory: {e}")))?;
234
235        let path = entry.path();
236        if path.is_file() {
237            // Check if it's a markdown file
238            if let Some(extension) = path.extension() {
239                if extension == "md" {
240                    match parse_agent_file(path, verbose) {
241                        Ok(agent) => {
242                            agents.push(agent);
243                        }
244                        Err(e) => {
245                            if verbose {
246                                println!(
247                                    "  {} Skipping {}: {}",
248                                    "⚠".yellow(),
249                                    path.display(),
250                                    e
251                                );
252                            }
253                        }
254                    }
255                }
256            }
257        }
258    }
259
260    // Sort by name for consistent ordering
261    agents.sort_by(|a, b| a.name.cmp(&b.name));
262
263    Ok(agents)
264}
265
266/// Extract a field from YAML as a string, handling various data types
267fn extract_field_as_string(frontmatter: &serde_json::Value, field: &str) -> Option<String> {
268    frontmatter.get(field).and_then(|v| match v {
269        serde_json::Value::String(s) => Some(s.clone()),
270        serde_json::Value::Number(n) => Some(n.to_string()),
271        serde_json::Value::Bool(b) => Some(b.to_string()),
272        serde_json::Value::Array(arr) => {
273            // Join array elements as comma-separated string
274            Some(
275                arr.iter()
276                    .map(|item| match item {
277                        serde_json::Value::String(s) => s.clone(),
278                        _ => item.to_string(),
279                    })
280                    .collect::<Vec<_>>()
281                    .join(", ")
282            )
283        }
284        serde_json::Value::Object(_) => {
285            // Convert object to string representation
286            Some(v.to_string())
287        }
288        serde_json::Value::Null => None,
289    })
290}
291
292/// Parse an agent file to extract name and description from YAML frontmatter
293fn parse_agent_file(path: &Path, verbose: bool) -> CarpResult<AgentFile> {
294    let content = fs::read_to_string(path)?;
295
296    // Check if file starts with YAML frontmatter
297    if !content.starts_with("---") {
298        return Err(CarpError::ManifestError(
299            "Agent file does not contain YAML frontmatter".to_string(),
300        ));
301    }
302
303    // Find the end of the frontmatter with more flexible boundary detection
304    let lines: Vec<&str> = content.lines().collect();
305    let mut frontmatter_end = None;
306
307    for (i, line) in lines.iter().enumerate().skip(1) {
308        let trimmed = line.trim();
309        // Accept "---" or "..." as valid YAML document endings
310        if trimmed == "---" || trimmed == "..." {
311            frontmatter_end = Some(i);
312            break;
313        }
314    }
315
316    let frontmatter_end = frontmatter_end.ok_or_else(|| {
317        if verbose {
318            eprintln!("Could not find closing frontmatter boundary in {}", path.display());
319            eprintln!("Looking for '---' or '...' after opening '---'");
320        }
321        CarpError::ManifestError("Invalid YAML frontmatter: missing closing --- or ...".to_string())
322    })?;
323
324    // Extract frontmatter content
325    let frontmatter_lines = &lines[1..frontmatter_end];
326    let frontmatter_content = frontmatter_lines.join("\n");
327
328    // Parse YAML frontmatter with better error handling
329    let frontmatter: serde_json::Value = serde_yaml::from_str(&frontmatter_content)
330        .map_err(|e| {
331            if verbose {
332                eprintln!("YAML parsing failed for {}: {}", path.display(), e);
333                eprintln!("Frontmatter content:\n{frontmatter_content}");
334            }
335            CarpError::ManifestError(format!("Invalid YAML frontmatter: {e}"))
336        })?;
337
338    // Extract name and description with more flexible handling
339    let name = extract_field_as_string(&frontmatter, "name")
340        .ok_or_else(|| CarpError::ManifestError("Missing 'name' field in frontmatter".to_string()))?;
341
342    let description = extract_field_as_string(&frontmatter, "description")
343        .ok_or_else(|| {
344            CarpError::ManifestError("Missing 'description' field in frontmatter".to_string())
345        })?;
346
347    // Create display name for selection
348    let file_name = path
349        .file_name()
350        .and_then(|n| n.to_str())
351        .unwrap_or("unknown");
352
353    let display_name = format!("{name} ({file_name})");
354
355    if verbose {
356        println!(
357            "  Found agent: {} - {}",
358            name,
359            description.chars().take(60).collect::<String>()
360        );
361    }
362
363    Ok(AgentFile {
364        path: path.to_path_buf(),
365        name,
366        description,
367        display_name,
368    })
369}
370
371/// Use inquire to prompt user for agent selection (single or all)
372fn select_agents(agents: Vec<AgentFile>) -> CarpResult<AgentSelection> {
373    if agents.is_empty() {
374        return Err(CarpError::Other("No agents found".to_string()));
375    }
376
377    // If there's only one agent, automatically select it
378    if agents.len() == 1 {
379        println!("Found single agent: {}", agents[0].display_name);
380        return Ok(AgentSelection::Single(agents.into_iter().next().unwrap()));
381    }
382
383    let mut options = vec!["📦 All agents".to_string()];
384    options.extend(agents.iter().map(|a| a.display_name.clone()));
385
386    let selection = Select::new("Select agents to upload:", options)
387        .prompt()
388        .map_err(|e| CarpError::Other(format!("Selection cancelled: {e}")))?;
389
390    if selection == "📦 All agents" {
391        Ok(AgentSelection::All(agents))
392    } else {
393        // Find the selected agent
394        let selected_agent = agents
395            .into_iter()
396            .find(|a| a.display_name == selection)
397            .ok_or_else(|| CarpError::Other("Selected agent not found".to_string()))?;
398        
399        Ok(AgentSelection::Single(selected_agent))
400    }
401}
402
403/// Upload the selected agent to the registry
404async fn upload_agent(
405    agent: &AgentFile,
406    content: String,
407    api_key: Option<&str>,
408    verbose: bool,
409    config: &crate::config::Config,
410) -> CarpResult<()> {
411    if verbose {
412        println!("Preparing to upload agent '{}'...", agent.name);
413    }
414
415    // Create upload request
416    let request = UploadAgentRequest {
417        name: agent.name.clone(),
418        description: agent.description.clone(),
419        content,
420        version: Some("1.0.0".to_string()), // Default version for uploaded agents
421        tags: vec!["claude-agent".to_string()], // Default tag for uploaded agents
422        homepage: None,
423        repository: None,
424        license: Some("MIT".to_string()), // Default license
425    };
426
427    // Upload to registry  
428    let client = ApiClient::new(config)?.with_api_key(api_key.map(|s| s.to_string()));
429
430    if verbose {
431        println!("Uploading to registry...");
432    }
433
434    let response = client.upload(request).await?;
435
436    if !response.success {
437        if let Some(validation_errors) = &response.validation_errors {
438            println!("{} Validation errors:", "Error:".red().bold());
439            for error in validation_errors {
440                println!("  {}: {}", error.field.yellow(), error.message);
441            }
442        }
443        return Err(CarpError::Api {
444            status: 400,
445            message: response.message,
446        });
447    }
448
449    if verbose {
450        if let Some(agent_info) = response.agent {
451            println!(
452                "View at: https://carp.refcell.org/agents/{}",
453                agent_info.name
454            );
455        }
456    }
457
458    Ok(())
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use std::fs;
465    use tempfile::TempDir;
466
467    #[test]
468    fn test_parse_agent_file() {
469        let temp_dir = TempDir::new().unwrap();
470        let agent_file_path = temp_dir.path().join("test-agent.md");
471
472        let content = r#"---
473name: test-agent
474description: A test agent for unit testing
475color: blue
476---
477
478# Test Agent
479
480This is a test agent for unit testing purposes.
481
482## Usage
483
484This agent helps with testing.
485"#;
486
487        fs::write(&agent_file_path, content).unwrap();
488
489        let result = parse_agent_file(&agent_file_path, false);
490        assert!(result.is_ok());
491
492        let agent = result.unwrap();
493        assert_eq!(agent.name, "test-agent");
494        assert_eq!(agent.description, "A test agent for unit testing");
495    }
496
497    #[test]
498    fn test_parse_agent_file_missing_frontmatter() {
499        let temp_dir = TempDir::new().unwrap();
500        let agent_file_path = temp_dir.path().join("invalid-agent.md");
501
502        let content = r#"# Invalid Agent
503
504This file doesn't have YAML frontmatter.
505"#;
506
507        fs::write(&agent_file_path, content).unwrap();
508
509        let result = parse_agent_file(&agent_file_path, false);
510        assert!(result.is_err());
511    }
512
513    #[test]
514    fn test_parse_agent_file_missing_name() {
515        let temp_dir = TempDir::new().unwrap();
516        let agent_file_path = temp_dir.path().join("incomplete-agent.md");
517
518        let content = r#"---
519description: Missing name field
520---
521
522# Incomplete Agent
523"#;
524
525        fs::write(&agent_file_path, content).unwrap();
526
527        let result = parse_agent_file(&agent_file_path, false);
528        assert!(result.is_err());
529    }
530
531    #[test]
532    fn test_expand_directory_path() {
533        // Test relative path
534        let result = expand_directory_path(Some(".".to_string()));
535        assert!(result.is_ok());
536
537        // Test non-existent directory
538        let result = expand_directory_path(Some("/non/existent/path".to_string()));
539        assert!(result.is_err());
540    }
541}