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!(
39 "DEBUG: Stored API key present: {}",
40 config.api_key.is_some()
41 );
42 }
43
44 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 AuthManager::ensure_authenticated(effective_api_key).await?;
56
57 let dir_path = get_directory_path(directory, verbose)?;
59
60 if verbose {
61 println!("Scanning directory: {}", dir_path.display());
62 }
63
64 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 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 let agent_content = fs::read_to_string(&agent.path)?;
94
95 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
177fn get_directory_path(directory: Option<String>, verbose: bool) -> CarpResult<PathBuf> {
179 let dir_path = if let Some(dir) = directory {
180 expand_directory_path(Some(dir))?
182 } else {
183 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
208fn 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
241fn 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 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 agents.sort_by(|a, b| a.name.cmp(&b.name));
275
276 Ok(agents)
277}
278
279fn 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 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 Some(v.to_string())
300 }
301 serde_json::Value::Null => None,
302 })
303}
304
305fn parse_agent_file(path: &Path, verbose: bool) -> CarpResult<AgentFile> {
307 let content = fs::read_to_string(path)?;
308
309 if !content.starts_with("---") {
311 return Err(CarpError::ManifestError(
312 "Agent file does not contain YAML frontmatter".to_string(),
313 ));
314 }
315
316 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 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 let frontmatter_lines = &lines[1..frontmatter_end];
342 let frontmatter_content = frontmatter_lines.join("\n");
343
344 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 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 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
387fn 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 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 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
419async 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 let request = UploadAgentRequest {
433 name: agent.name.clone(),
434 description: agent.description.clone(),
435 content,
436 version: Some("1.0.0".to_string()), tags: vec!["claude-agent".to_string()], homepage: None,
439 repository: None,
440 license: Some("MIT".to_string()), };
442
443 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 let result = expand_directory_path(Some(".".to_string()));
551 assert!(result.is_ok());
552
553 let result = expand_directory_path(Some("/non/existent/path".to_string()));
555 assert!(result.is_err());
556 }
557}