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/// Execute the upload command
21pub async fn execute(
22    directory: Option<String>,
23    api_key: Option<String>,
24    verbose: bool,
25) -> CarpResult<()> {
26    // Ensure user is authenticated (either via API key parameter or stored configuration)
27    AuthManager::ensure_authenticated(api_key.as_deref()).await?;
28
29    // Expand directory path, defaulting to ~/.claude/agents/
30    let dir_path = expand_directory_path(directory)?;
31
32    if verbose {
33        println!("Scanning directory: {}", dir_path.display());
34    }
35
36    // Scan directory for agent files
37    let agent_files = scan_agent_files(&dir_path, verbose)?;
38
39    if agent_files.is_empty() {
40        println!(
41            "{} No agent files found in {}",
42            "Warning:".yellow().bold(),
43            dir_path.display()
44        );
45        println!(
46            "Looking for .md files with YAML frontmatter containing name and description fields."
47        );
48        return Ok(());
49    }
50
51    if verbose {
52        println!("Found {} agent files", agent_files.len());
53    }
54
55    // Use inquire to prompt user for agent selection
56    let selected_agent = select_agent(agent_files)?;
57
58    if verbose {
59        println!("Selected agent: {}", selected_agent.name);
60    }
61
62    // Read and parse the selected agent file
63    let agent_content = fs::read_to_string(&selected_agent.path)?;
64
65    // Upload the agent
66    upload_agent(&selected_agent, agent_content, api_key, verbose).await?;
67
68    println!(
69        "{} Successfully uploaded agent '{}'",
70        "✓".green().bold(),
71        selected_agent.name.blue().bold()
72    );
73
74    Ok(())
75}
76
77/// Expand directory path, handling tilde expansion
78fn expand_directory_path(directory: Option<String>) -> CarpResult<PathBuf> {
79    let dir_str = directory.unwrap_or_else(|| "~/.claude/agents/".to_string());
80
81    let expanded_path = if let Some(stripped) = dir_str.strip_prefix('~') {
82        if let Some(home_dir) = dirs::home_dir() {
83            home_dir.join(dir_str.strip_prefix("~/").unwrap_or(stripped))
84        } else {
85            return Err(CarpError::FileSystem(
86                "Unable to determine home directory".to_string(),
87            ));
88        }
89    } else {
90        PathBuf::from(dir_str)
91    };
92
93    if !expanded_path.exists() {
94        return Err(CarpError::FileSystem(format!(
95            "Directory does not exist: {}",
96            expanded_path.display()
97        )));
98    }
99
100    if !expanded_path.is_dir() {
101        return Err(CarpError::FileSystem(format!(
102            "Path is not a directory: {}",
103            expanded_path.display()
104        )));
105    }
106
107    Ok(expanded_path)
108}
109
110/// Scan directory recursively for agent definition files
111fn scan_agent_files(dir_path: &Path, verbose: bool) -> CarpResult<Vec<AgentFile>> {
112    let mut agents = Vec::new();
113
114    if verbose {
115        println!("Scanning for agent files recursively...");
116    }
117
118    for entry in WalkDir::new(dir_path).follow_links(false) {
119        let entry =
120            entry.map_err(|e| CarpError::FileSystem(format!("Error scanning directory: {e}")))?;
121
122        let path = entry.path();
123        if path.is_file() {
124            // Check if it's a markdown file
125            if let Some(extension) = path.extension() {
126                if extension == "md" {
127                    if let Ok(agent) = parse_agent_file(path, verbose) {
128                        agents.push(agent);
129                    }
130                }
131            }
132        }
133    }
134
135    // Sort by name for consistent ordering
136    agents.sort_by(|a, b| a.name.cmp(&b.name));
137
138    Ok(agents)
139}
140
141/// Parse an agent file to extract name and description from YAML frontmatter
142fn parse_agent_file(path: &Path, verbose: bool) -> CarpResult<AgentFile> {
143    let content = fs::read_to_string(path)?;
144
145    // Check if file starts with YAML frontmatter
146    if !content.starts_with("---") {
147        return Err(CarpError::ManifestError(
148            "Agent file does not contain YAML frontmatter".to_string(),
149        ));
150    }
151
152    // Find the end of the frontmatter
153    let lines: Vec<&str> = content.lines().collect();
154    let mut frontmatter_end = None;
155
156    for (i, line) in lines.iter().enumerate().skip(1) {
157        if line.trim() == "---" {
158            frontmatter_end = Some(i);
159            break;
160        }
161    }
162
163    let frontmatter_end = frontmatter_end.ok_or_else(|| {
164        CarpError::ManifestError("Invalid YAML frontmatter: missing closing ---".to_string())
165    })?;
166
167    // Extract frontmatter content
168    let frontmatter_lines = &lines[1..frontmatter_end];
169    let frontmatter_content = frontmatter_lines.join("\n");
170
171    // Parse YAML frontmatter
172    let frontmatter: serde_json::Value = serde_yaml::from_str(&frontmatter_content)
173        .map_err(|e| CarpError::ManifestError(format!("Invalid YAML frontmatter: {e}")))?;
174
175    // Extract name and description
176    let name = frontmatter
177        .get("name")
178        .and_then(|v| v.as_str())
179        .ok_or_else(|| CarpError::ManifestError("Missing 'name' field in frontmatter".to_string()))?
180        .to_string();
181
182    let description = frontmatter
183        .get("description")
184        .and_then(|v| v.as_str())
185        .ok_or_else(|| {
186            CarpError::ManifestError("Missing 'description' field in frontmatter".to_string())
187        })?
188        .to_string();
189
190    // Create display name for selection
191    let file_name = path
192        .file_name()
193        .and_then(|n| n.to_str())
194        .unwrap_or("unknown");
195
196    let display_name = format!("{name} ({file_name})");
197
198    if verbose {
199        println!(
200            "  Found agent: {} - {}",
201            name,
202            description.chars().take(60).collect::<String>()
203        );
204    }
205
206    Ok(AgentFile {
207        path: path.to_path_buf(),
208        name,
209        description,
210        display_name,
211    })
212}
213
214/// Use inquire to prompt user for agent selection
215fn select_agent(agents: Vec<AgentFile>) -> CarpResult<AgentFile> {
216    let options: Vec<String> = agents.iter().map(|a| a.display_name.clone()).collect();
217
218    let selection = Select::new("Select an agent to upload:", options)
219        .prompt()
220        .map_err(|e| CarpError::Other(format!("Selection cancelled: {e}")))?;
221
222    // Find the selected agent
223    agents
224        .into_iter()
225        .find(|a| a.display_name == selection)
226        .ok_or_else(|| CarpError::Other("Selected agent not found".to_string()))
227}
228
229/// Upload the selected agent to the registry
230async fn upload_agent(
231    agent: &AgentFile,
232    content: String,
233    api_key: Option<String>,
234    verbose: bool,
235) -> CarpResult<()> {
236    if verbose {
237        println!("Preparing to upload agent '{}'...", agent.name);
238    }
239
240    // Create upload request
241    let request = UploadAgentRequest {
242        name: agent.name.clone(),
243        description: agent.description.clone(),
244        content,
245        version: Some("1.0.0".to_string()), // Default version for uploaded agents
246        tags: vec!["claude-agent".to_string()], // Default tag for uploaded agents
247        homepage: None,
248        repository: None,
249        license: Some("MIT".to_string()), // Default license
250    };
251
252    // Upload to registry
253    let config = ConfigManager::load_with_env_checks()?;
254    let client = ApiClient::new(&config)?.with_api_key(api_key);
255
256    if verbose {
257        println!("Uploading to registry...");
258    }
259
260    let response = client.upload(request).await?;
261
262    if !response.success {
263        if let Some(validation_errors) = &response.validation_errors {
264            println!("{} Validation errors:", "Error:".red().bold());
265            for error in validation_errors {
266                println!("  {}: {}", error.field.yellow(), error.message);
267            }
268        }
269        return Err(CarpError::Api {
270            status: 400,
271            message: response.message,
272        });
273    }
274
275    if verbose {
276        if let Some(agent_info) = response.agent {
277            println!(
278                "View at: https://carp.refcell.org/agents/{}",
279                agent_info.name
280            );
281        }
282    }
283
284    Ok(())
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use std::fs;
291    use tempfile::TempDir;
292
293    #[test]
294    fn test_parse_agent_file() {
295        let temp_dir = TempDir::new().unwrap();
296        let agent_file_path = temp_dir.path().join("test-agent.md");
297
298        let content = r#"---
299name: test-agent
300description: A test agent for unit testing
301color: blue
302---
303
304# Test Agent
305
306This is a test agent for unit testing purposes.
307
308## Usage
309
310This agent helps with testing.
311"#;
312
313        fs::write(&agent_file_path, content).unwrap();
314
315        let result = parse_agent_file(&agent_file_path, false);
316        assert!(result.is_ok());
317
318        let agent = result.unwrap();
319        assert_eq!(agent.name, "test-agent");
320        assert_eq!(agent.description, "A test agent for unit testing");
321    }
322
323    #[test]
324    fn test_parse_agent_file_missing_frontmatter() {
325        let temp_dir = TempDir::new().unwrap();
326        let agent_file_path = temp_dir.path().join("invalid-agent.md");
327
328        let content = r#"# Invalid Agent
329
330This file doesn't have YAML frontmatter.
331"#;
332
333        fs::write(&agent_file_path, content).unwrap();
334
335        let result = parse_agent_file(&agent_file_path, false);
336        assert!(result.is_err());
337    }
338
339    #[test]
340    fn test_parse_agent_file_missing_name() {
341        let temp_dir = TempDir::new().unwrap();
342        let agent_file_path = temp_dir.path().join("incomplete-agent.md");
343
344        let content = r#"---
345description: Missing name field
346---
347
348# Incomplete Agent
349"#;
350
351        fs::write(&agent_file_path, content).unwrap();
352
353        let result = parse_agent_file(&agent_file_path, false);
354        assert!(result.is_err());
355    }
356
357    #[test]
358    fn test_expand_directory_path() {
359        // Test relative path
360        let result = expand_directory_path(Some(".".to_string()));
361        assert!(result.is_ok());
362
363        // Test non-existent directory
364        let result = expand_directory_path(Some("/non/existent/path".to_string()));
365        assert!(result.is_err());
366    }
367}