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