carp_cli/commands/
upload.rs1use 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#[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
20pub async fn execute(
22 directory: Option<String>,
23 api_key: Option<String>,
24 verbose: bool,
25) -> CarpResult<()> {
26 AuthManager::ensure_authenticated(api_key.as_deref()).await?;
28
29 let dir_path = expand_directory_path(directory)?;
31
32 if verbose {
33 println!("Scanning directory: {}", dir_path.display());
34 }
35
36 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 let selected_agent = select_agent(agent_files)?;
57
58 if verbose {
59 println!("Selected agent: {}", selected_agent.name);
60 }
61
62 let agent_content = fs::read_to_string(&selected_agent.path)?;
64
65 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
77fn 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
110fn 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 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 agents.sort_by(|a, b| a.name.cmp(&b.name));
137
138 Ok(agents)
139}
140
141fn parse_agent_file(path: &Path, verbose: bool) -> CarpResult<AgentFile> {
143 let content = fs::read_to_string(path)?;
144
145 if !content.starts_with("---") {
147 return Err(CarpError::ManifestError(
148 "Agent file does not contain YAML frontmatter".to_string(),
149 ));
150 }
151
152 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 let frontmatter_lines = &lines[1..frontmatter_end];
169 let frontmatter_content = frontmatter_lines.join("\n");
170
171 let frontmatter: serde_json::Value = serde_yaml::from_str(&frontmatter_content)
173 .map_err(|e| CarpError::ManifestError(format!("Invalid YAML frontmatter: {e}")))?;
174
175 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 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
214fn 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 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
229async 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 let request = UploadAgentRequest {
242 name: agent.name.clone(),
243 description: agent.description.clone(),
244 content,
245 version: Some("1.0.0".to_string()), tags: vec!["claude-agent".to_string()], homepage: None,
248 repository: None,
249 license: Some("MIT".to_string()), };
251
252 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 let result = expand_directory_path(Some(".".to_string()));
361 assert!(result.is_ok());
362
363 let result = expand_directory_path(Some("/non/existent/path".to_string()));
365 assert!(result.is_err());
366 }
367}