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#[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#[derive(Debug)]
22enum AgentSelection {
23 Single(AgentFile),
24 All(Vec<AgentFile>),
25}
26
27pub async fn execute(
29 directory: Option<String>,
30 api_key: Option<String>,
31 verbose: bool,
32) -> CarpResult<()> {
33 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 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 AuthManager::ensure_authenticated(effective_api_key).await?;
50
51 let dir_path = get_directory_path(directory, verbose)?;
53
54 if verbose {
55 println!("Scanning directory: {}", dir_path.display());
56 }
57
58 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 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 let agent_content = fs::read_to_string(&agent.path)?;
88
89 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
159fn get_directory_path(directory: Option<String>, verbose: bool) -> CarpResult<PathBuf> {
161 let dir_path = if let Some(dir) = directory {
162 expand_directory_path(Some(dir))?
164 } else {
165 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
190fn 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
223fn 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 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 agents.sort_by(|a, b| a.name.cmp(&b.name));
262
263 Ok(agents)
264}
265
266fn 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 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 Some(v.to_string())
287 }
288 serde_json::Value::Null => None,
289 })
290}
291
292fn parse_agent_file(path: &Path, verbose: bool) -> CarpResult<AgentFile> {
294 let content = fs::read_to_string(path)?;
295
296 if !content.starts_with("---") {
298 return Err(CarpError::ManifestError(
299 "Agent file does not contain YAML frontmatter".to_string(),
300 ));
301 }
302
303 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 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 let frontmatter_lines = &lines[1..frontmatter_end];
326 let frontmatter_content = frontmatter_lines.join("\n");
327
328 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 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 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
371fn 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 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 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
403async 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 let request = UploadAgentRequest {
417 name: agent.name.clone(),
418 description: agent.description.clone(),
419 content,
420 version: Some("1.0.0".to_string()), tags: vec!["claude-agent".to_string()], homepage: None,
423 repository: None,
424 license: Some("MIT".to_string()), };
426
427 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 let result = expand_directory_path(Some(".".to_string()));
535 assert!(result.is_ok());
536
537 let result = expand_directory_path(Some("/non/existent/path".to_string()));
539 assert!(result.is_err());
540 }
541}