1use anyhow::{Context, Result};
34use serde::Deserialize;
35use std::collections::HashMap;
36use std::path::{Path, PathBuf};
37use std::process::Command;
38
39use crate::config::Config;
40
41const COMMUNITY_REGISTRY_URL: &str = "https://github.com/parallax-labs/ctx-registry.git";
42const DEFAULT_BRANCH: &str = "main";
43
44#[derive(Debug, Deserialize, Clone)]
50pub struct RegistryManifest {
51 #[serde(default)]
53 #[allow(dead_code)]
54 pub registry: RegistryMeta,
55 #[serde(default)]
57 pub connectors: HashMap<String, ExtensionEntry>,
58 #[serde(default)]
60 pub tools: HashMap<String, ExtensionEntry>,
61 #[serde(default)]
63 pub agents: HashMap<String, ExtensionEntry>,
64}
65
66#[derive(Debug, Deserialize, Clone, Default)]
68#[allow(dead_code)]
69pub struct RegistryMeta {
70 #[serde(default)]
72 pub name: String,
73 #[serde(default)]
75 pub description: String,
76 #[serde(default)]
78 pub url: Option<String>,
79 #[serde(default)]
81 pub min_version: Option<String>,
82}
83
84#[derive(Debug, Deserialize, Clone)]
86pub struct ExtensionEntry {
87 #[serde(default)]
89 pub description: String,
90 pub path: String,
92 #[serde(default)]
94 pub tags: Vec<String>,
95 #[serde(default)]
97 pub required_config: Vec<String>,
98 #[serde(default)]
100 pub host_apis: Vec<String>,
101 #[serde(default)]
103 pub tools: Vec<String>,
104}
105
106#[derive(Debug, Clone)]
108pub struct ResolvedExtension {
109 pub name: String,
111 pub kind: String,
113 pub script_path: PathBuf,
115 pub registry_name: String,
117 pub entry: ExtensionEntry,
119}
120
121pub fn clone_registry(url: &str, branch: Option<&str>, target_dir: &Path) -> Result<()> {
127 if target_dir.exists() {
128 anyhow::bail!("Target directory already exists: {}", target_dir.display());
129 }
130
131 if let Some(parent) = target_dir.parent() {
132 std::fs::create_dir_all(parent)
133 .with_context(|| format!("Failed to create parent directory: {}", parent.display()))?;
134 }
135
136 let branch = branch.unwrap_or(DEFAULT_BRANCH);
137 let output = Command::new("git")
138 .args([
139 "clone",
140 "--depth",
141 "1",
142 "--branch",
143 branch,
144 url,
145 &target_dir.to_string_lossy(),
146 ])
147 .output()
148 .context("Failed to run git clone")?;
149
150 if !output.status.success() {
151 let stderr = String::from_utf8_lossy(&output.stderr);
152 anyhow::bail!("git clone failed: {}", stderr.trim());
153 }
154
155 Ok(())
156}
157
158pub fn pull_registry(registry_dir: &Path) -> Result<()> {
160 if !is_git_repo(registry_dir) {
161 anyhow::bail!("Not a git repository: {}", registry_dir.display());
162 }
163
164 if is_dirty(registry_dir)? {
165 eprintln!(
166 "Warning: registry at {} has uncommitted changes, skipping update",
167 registry_dir.display()
168 );
169 return Ok(());
170 }
171
172 let output = Command::new("git")
173 .args(["pull", "--ff-only"])
174 .current_dir(registry_dir)
175 .output()
176 .context("Failed to run git pull")?;
177
178 if !output.status.success() {
179 let stderr = String::from_utf8_lossy(&output.stderr);
180 anyhow::bail!("git pull failed: {}", stderr.trim());
181 }
182
183 Ok(())
184}
185
186pub fn is_git_repo(dir: &Path) -> bool {
188 dir.join(".git").exists()
189}
190
191fn is_dirty(dir: &Path) -> Result<bool> {
193 let output = Command::new("git")
194 .args(["status", "--porcelain"])
195 .current_dir(dir)
196 .output()
197 .context("Failed to run git status")?;
198
199 Ok(!output.stdout.is_empty())
200}
201
202pub fn load_manifest(registry_dir: &Path) -> Result<RegistryManifest> {
208 let manifest_path = registry_dir.join("registry.toml");
209 let content = std::fs::read_to_string(&manifest_path)
210 .with_context(|| format!("Failed to read manifest: {}", manifest_path.display()))?;
211
212 let manifest: RegistryManifest =
213 toml::from_str(&content).with_context(|| "Failed to parse registry.toml")?;
214
215 Ok(manifest)
216}
217
218fn discover_manifest(registry_dir: &Path) -> RegistryManifest {
221 let mut manifest = RegistryManifest {
222 registry: RegistryMeta::default(),
223 connectors: HashMap::new(),
224 tools: HashMap::new(),
225 agents: HashMap::new(),
226 };
227
228 let scan_dir = |subdir: &str, script_name: &str| -> HashMap<String, ExtensionEntry> {
229 let mut entries = HashMap::new();
230 let dir = registry_dir.join(subdir);
231 if !dir.is_dir() {
232 return entries;
233 }
234 if let Ok(read) = std::fs::read_dir(&dir) {
235 for entry in read.flatten() {
236 if entry.path().is_dir() {
237 let name = entry.file_name().to_string_lossy().to_string();
238 let script = entry.path().join(script_name);
239 if script.exists() {
240 let rel_path = format!("{}/{}/{}", subdir, name, script_name);
241 entries.insert(
242 name,
243 ExtensionEntry {
244 description: String::new(),
245 path: rel_path,
246 tags: Vec::new(),
247 required_config: Vec::new(),
248 host_apis: Vec::new(),
249 tools: Vec::new(),
250 },
251 );
252 }
253 }
254 }
255 }
256 entries
257 };
258
259 manifest.connectors = scan_dir("connectors", "connector.lua");
260 manifest.tools = scan_dir("tools", "tool.lua");
261
262 let mut agents = scan_dir("agents", "agent.lua");
264 let toml_agents = scan_dir("agents", "agent.toml");
265 for (k, v) in toml_agents {
266 agents.entry(k).or_insert(v);
267 }
268 manifest.agents = agents;
269
270 manifest
271}
272
273pub struct RegistryManager {
283 registries: Vec<LoadedRegistry>,
285}
286
287struct LoadedRegistry {
288 name: String,
289 path: PathBuf,
290 manifest: RegistryManifest,
291 readonly: bool,
292}
293
294impl RegistryManager {
295 pub fn from_config(config: &Config) -> Self {
301 let mut registries = Vec::new();
302
303 for (name, reg_cfg) in &config.registries {
304 let path = expand_tilde(®_cfg.path);
305 if !path.exists() {
306 eprintln!(
307 "Warning: registry '{}' path does not exist: {}",
308 name,
309 path.display()
310 );
311 continue;
312 }
313
314 let manifest = match load_manifest(&path) {
315 Ok(m) => m,
316 Err(_) => {
317 eprintln!(
318 "Warning: registry '{}' has no valid registry.toml, using directory scan",
319 name
320 );
321 discover_manifest(&path)
322 }
323 };
324
325 registries.push(LoadedRegistry {
326 name: name.clone(),
327 path,
328 manifest,
329 readonly: reg_cfg.readonly,
330 });
331 }
332
333 if let Some(ctx_dir) = find_local_ctx_dir() {
335 let manifest = match load_manifest(&ctx_dir) {
336 Ok(m) => m,
337 Err(_) => discover_manifest(&ctx_dir),
338 };
339 registries.push(LoadedRegistry {
340 name: "project-local".to_string(),
341 path: ctx_dir,
342 manifest,
343 readonly: false,
344 });
345 }
346
347 Self { registries }
348 }
349
350 pub fn list_all(&self) -> Vec<ResolvedExtension> {
354 let mut map: HashMap<String, ResolvedExtension> = HashMap::new();
355
356 for reg in &self.registries {
357 for (name, entry) in ®.manifest.connectors {
358 let key = format!("connectors/{}", name);
359 map.insert(
360 key,
361 ResolvedExtension {
362 name: name.clone(),
363 kind: "connector".to_string(),
364 script_path: reg.path.join(&entry.path),
365 registry_name: reg.name.clone(),
366 entry: entry.clone(),
367 },
368 );
369 }
370 for (name, entry) in ®.manifest.tools {
371 let key = format!("tools/{}", name);
372 map.insert(
373 key,
374 ResolvedExtension {
375 name: name.clone(),
376 kind: "tool".to_string(),
377 script_path: reg.path.join(&entry.path),
378 registry_name: reg.name.clone(),
379 entry: entry.clone(),
380 },
381 );
382 }
383 for (name, entry) in ®.manifest.agents {
384 let key = format!("agents/{}", name);
385 map.insert(
386 key,
387 ResolvedExtension {
388 name: name.clone(),
389 kind: "agent".to_string(),
390 script_path: reg.path.join(&entry.path),
391 registry_name: reg.name.clone(),
392 entry: entry.clone(),
393 },
394 );
395 }
396 }
397
398 let mut all: Vec<ResolvedExtension> = map.into_values().collect();
399 all.sort_by(|a, b| a.kind.cmp(&b.kind).then(a.name.cmp(&b.name)));
400 all
401 }
402
403 pub fn resolve(&self, extension_id: &str) -> Option<ResolvedExtension> {
405 self.list_all()
406 .into_iter()
407 .find(|e| format!("{}s/{}", e.kind, e.name) == extension_id)
408 }
409
410 #[allow(dead_code)]
412 pub fn list_connectors(&self) -> Vec<ResolvedExtension> {
413 self.list_all()
414 .into_iter()
415 .filter(|e| e.kind == "connector")
416 .collect()
417 }
418
419 pub fn list_tools(&self) -> Vec<ResolvedExtension> {
421 self.list_all()
422 .into_iter()
423 .filter(|e| e.kind == "tool")
424 .collect()
425 }
426
427 pub fn list_agents(&self) -> Vec<ResolvedExtension> {
429 self.list_all()
430 .into_iter()
431 .filter(|e| e.kind == "agent")
432 .collect()
433 }
434
435 pub fn writable_path(&self) -> Option<&Path> {
437 self.registries
438 .iter()
439 .rev()
440 .find(|r| !r.readonly)
441 .map(|r| r.path.as_path())
442 }
443
444 pub fn registries(&self) -> Vec<RegistryInfo> {
446 self.registries
447 .iter()
448 .map(|r| RegistryInfo {
449 name: r.name.clone(),
450 path: r.path.clone(),
451 readonly: r.readonly,
452 is_git: is_git_repo(&r.path),
453 connectors: r.manifest.connectors.len(),
454 tools: r.manifest.tools.len(),
455 agents: r.manifest.agents.len(),
456 })
457 .collect()
458 }
459}
460
461pub struct RegistryInfo {
463 pub name: String,
464 pub path: PathBuf,
465 pub readonly: bool,
466 pub is_git: bool,
467 pub connectors: usize,
468 pub tools: usize,
469 pub agents: usize,
470}
471
472pub fn find_local_ctx_dir() -> Option<PathBuf> {
481 find_ctx_dir_from(std::env::current_dir().ok()?)
482}
483
484fn find_ctx_dir_from(start: PathBuf) -> Option<PathBuf> {
486 let mut dir = start;
487 loop {
488 let candidate = dir.join(".ctx");
489 if candidate.is_dir() {
490 return Some(candidate);
491 }
492 if !dir.pop() {
493 return None;
494 }
495 }
496}
497
498pub fn cmd_list(config: &Config) {
504 let mgr = RegistryManager::from_config(config);
505
506 let registries = mgr.registries();
507 if registries.is_empty() {
508 println!("No registries configured.");
509 println!("Add a [registries.<name>] section to ctx.toml, or run `ctx registry install`.");
510 return;
511 }
512
513 println!("Registries:\n");
514 for r in ®istries {
515 let git_tag = if r.is_git { " (git)" } else { "" };
516 let ro_tag = if r.readonly { " [readonly]" } else { "" };
517 println!(
518 " {} — {}{}{}\n {} connectors, {} tools, {} agents",
519 r.name,
520 r.path.display(),
521 git_tag,
522 ro_tag,
523 r.connectors,
524 r.tools,
525 r.agents,
526 );
527 }
528
529 let all = mgr.list_all();
530 if all.is_empty() {
531 println!("\nNo extensions found.");
532 return;
533 }
534
535 println!("\nAvailable extensions:\n");
536 let mut current_kind = String::new();
537 for ext in &all {
538 if ext.kind != current_kind {
539 current_kind = ext.kind.clone();
540 println!(" {}s:", current_kind);
541 }
542 let tags = if ext.entry.tags.is_empty() {
543 String::new()
544 } else {
545 format!(" [{}]", ext.entry.tags.join(", "))
546 };
547 println!(
548 " {} — {}{} (from: {})",
549 ext.name, ext.entry.description, tags, ext.registry_name
550 );
551 }
552}
553
554pub fn cmd_install(config: &Config, name: Option<&str>) -> Result<()> {
556 let mut installed = 0;
557
558 for (reg_name, reg_cfg) in &config.registries {
559 if let Some(filter) = name {
560 if reg_name != filter {
561 continue;
562 }
563 }
564
565 let url = match ®_cfg.url {
566 Some(u) => u,
567 None => {
568 if name.is_some() {
569 println!("Registry '{}' is local-only (no url configured).", reg_name);
570 }
571 continue;
572 }
573 };
574
575 let target = expand_tilde(®_cfg.path);
576 if target.exists() {
577 println!(
578 "Registry '{}' already installed at {}",
579 reg_name,
580 target.display()
581 );
582 continue;
583 }
584
585 println!("Cloning registry '{}' from {}...", reg_name, url);
586 clone_registry(url, reg_cfg.branch.as_deref(), &target)?;
587
588 match load_manifest(&target) {
590 Ok(m) => {
591 println!(
592 " Installed: {} connectors, {} tools, {} agents",
593 m.connectors.len(),
594 m.tools.len(),
595 m.agents.len()
596 );
597 }
598 Err(_) => {
599 println!(" Installed (no registry.toml found — extensions will be discovered by directory scan).");
600 }
601 }
602 installed += 1;
603 }
604
605 if installed == 0 && name.is_none() {
606 println!(
607 "No registries to install. Add [registries.<name>] entries with `url` to ctx.toml."
608 );
609 }
610
611 Ok(())
612}
613
614pub fn cmd_update(config: &Config, name: Option<&str>) -> Result<()> {
616 let mut updated = 0;
617
618 for (reg_name, reg_cfg) in &config.registries {
619 if let Some(filter) = name {
620 if reg_name != filter {
621 continue;
622 }
623 }
624
625 let path = expand_tilde(®_cfg.path);
626 if !path.exists() {
627 eprintln!(
628 "Registry '{}' not installed at {}. Run `ctx registry install` first.",
629 reg_name,
630 path.display()
631 );
632 continue;
633 }
634
635 if !is_git_repo(&path) {
636 if name.is_some() {
637 println!("Registry '{}' is not a git repository, skipping.", reg_name);
638 }
639 continue;
640 }
641
642 println!("Updating registry '{}'...", reg_name);
643 match pull_registry(&path) {
644 Ok(()) => {
645 println!(" Updated successfully.");
646 updated += 1;
647 }
648 Err(e) => {
649 eprintln!(" Failed to update '{}': {}", reg_name, e);
650 }
651 }
652 }
653
654 if updated == 0 && name.is_none() {
655 println!("No git-backed registries to update.");
656 }
657
658 Ok(())
659}
660
661pub fn cmd_search(config: &Config, query: &str) {
663 let mgr = RegistryManager::from_config(config);
664 let all = mgr.list_all();
665
666 let query_lower = query.to_lowercase();
667 let matches: Vec<&ResolvedExtension> = all
668 .iter()
669 .filter(|e| {
670 e.name.to_lowercase().contains(&query_lower)
671 || e.entry.description.to_lowercase().contains(&query_lower)
672 || e.entry
673 .tags
674 .iter()
675 .any(|t| t.to_lowercase().contains(&query_lower))
676 })
677 .collect();
678
679 if matches.is_empty() {
680 println!("No extensions matching '{}'.", query);
681 return;
682 }
683
684 println!("Found {} extensions matching '{}':\n", matches.len(), query);
685 for ext in matches {
686 let tags = if ext.entry.tags.is_empty() {
687 String::new()
688 } else {
689 format!(" [{}]", ext.entry.tags.join(", "))
690 };
691 println!(
692 " {}s/{} — {}{} (from: {})",
693 ext.kind, ext.name, ext.entry.description, tags, ext.registry_name
694 );
695 }
696}
697
698pub fn cmd_info(config: &Config, extension_id: &str) -> Result<()> {
700 let mgr = RegistryManager::from_config(config);
701
702 let ext = mgr
703 .resolve(extension_id)
704 .ok_or_else(|| anyhow::anyhow!("Extension '{}' not found in any registry", extension_id))?;
705
706 println!("Extension: {}s/{}", ext.kind, ext.name);
707 println!("Registry: {}", ext.registry_name);
708 println!("Script: {}", ext.script_path.display());
709
710 if !ext.entry.description.is_empty() {
711 println!("Description: {}", ext.entry.description);
712 }
713 if !ext.entry.tags.is_empty() {
714 println!("Tags: {}", ext.entry.tags.join(", "));
715 }
716 if !ext.entry.required_config.is_empty() {
717 println!("Required config: {}", ext.entry.required_config.join(", "));
718 }
719 if !ext.entry.host_apis.is_empty() {
720 println!("Host APIs: {}", ext.entry.host_apis.join(", "));
721 }
722 if !ext.entry.tools.is_empty() {
723 println!("Tools: {}", ext.entry.tools.join(", "));
724 }
725
726 let readme_path = ext.script_path.parent().map(|p| p.join("README.md"));
728 if let Some(ref readme) = readme_path {
729 if readme.exists() {
730 if let Ok(content) = std::fs::read_to_string(readme) {
731 println!("\n--- README ---\n\n{}", content);
732 }
733 }
734 }
735
736 Ok(())
737}
738
739pub fn cmd_add(config: &Config, extension_id: &str, config_path: &Path) -> Result<()> {
741 let mgr = RegistryManager::from_config(config);
742
743 let ext = mgr
744 .resolve(extension_id)
745 .ok_or_else(|| anyhow::anyhow!("Extension '{}' not found in any registry", extension_id))?;
746
747 let example_path = ext
749 .script_path
750 .parent()
751 .map(|p| p.join("config.example.toml"));
752 let example_content = example_path
753 .as_ref()
754 .and_then(|p| std::fs::read_to_string(p).ok());
755
756 let section = match ext.kind.as_str() {
757 "connector" => {
758 if let Some(example) = &example_content {
759 format!("\n[connectors.script.{}]\n{}", ext.name, example)
760 } else {
761 let mut section = format!(
762 "\n[connectors.script.{}]\npath = \"{}\"\n",
763 ext.name,
764 ext.script_path.display()
765 );
766 for key in &ext.entry.required_config {
767 section.push_str(&format!("{} = \"\" # TODO: set this\n", key));
768 }
769 section
770 }
771 }
772 "tool" => {
773 if let Some(example) = &example_content {
774 format!("\n[tools.script.{}]\n{}", ext.name, example)
775 } else {
776 format!(
777 "\n[tools.script.{}]\npath = \"{}\"\n",
778 ext.name,
779 ext.script_path.display()
780 )
781 }
782 }
783 "agent" => {
784 if let Some(example) = &example_content {
785 format!("\n[agents.script.{}]\n{}", ext.name, example)
786 } else {
787 format!(
788 "\n[agents.script.{}]\npath = \"{}\"\n",
789 ext.name,
790 ext.script_path.display()
791 )
792 }
793 }
794 _ => anyhow::bail!("Unknown extension kind: {}", ext.kind),
795 };
796
797 let mut content = std::fs::read_to_string(config_path)
799 .with_context(|| format!("Failed to read config: {}", config_path.display()))?;
800
801 content.push_str(§ion);
802
803 std::fs::write(config_path, &content)
804 .with_context(|| format!("Failed to write config: {}", config_path.display()))?;
805
806 println!(
807 "Added [{}s.script.{}] to {}",
808 ext.kind,
809 ext.name,
810 config_path.display()
811 );
812
813 if !ext.entry.required_config.is_empty() {
814 println!(
815 "Edit {} to set: {}",
816 config_path.display(),
817 ext.entry.required_config.join(", ")
818 );
819 }
820
821 Ok(())
822}
823
824pub fn cmd_override(config: &Config, extension_id: &str) -> Result<()> {
826 let mgr = RegistryManager::from_config(config);
827
828 let ext = mgr
829 .resolve(extension_id)
830 .ok_or_else(|| anyhow::anyhow!("Extension '{}' not found in any registry", extension_id))?;
831
832 let writable_path = mgr.writable_path().ok_or_else(|| {
833 anyhow::anyhow!("No writable registry found. Add a registry with readonly = false.")
834 })?;
835
836 let dest_dir = writable_path.join(format!("{}s", ext.kind)).join(&ext.name);
837
838 if dest_dir.exists() {
839 anyhow::bail!(
840 "Override already exists at {}. Edit it directly.",
841 dest_dir.display()
842 );
843 }
844
845 let src_dir = ext
847 .script_path
848 .parent()
849 .ok_or_else(|| anyhow::anyhow!("Cannot determine source directory"))?;
850
851 copy_dir_recursive(src_dir, &dest_dir)?;
852
853 println!(
854 "Copied {}s/{} to {}",
855 ext.kind,
856 ext.name,
857 dest_dir.display()
858 );
859 println!(
860 "Your version will take precedence over the {} registry version.",
861 ext.registry_name
862 );
863
864 Ok(())
865}
866
867pub fn cmd_init_community(config_path: &Path) -> Result<()> {
869 let default_path = default_registries_dir().join("community");
870
871 if default_path.exists() {
872 println!(
873 "Community registry already installed at {}",
874 default_path.display()
875 );
876 return Ok(());
877 }
878
879 println!("Cloning community extension registry...");
880 clone_registry(COMMUNITY_REGISTRY_URL, Some(DEFAULT_BRANCH), &default_path)?;
881
882 match load_manifest(&default_path) {
884 Ok(m) => {
885 println!(
886 "Installed: {} connectors, {} tools, {} agents",
887 m.connectors.len(),
888 m.tools.len(),
889 m.agents.len()
890 );
891 }
892 Err(_) => {
893 println!("Installed (directory scan mode — no registry.toml).");
894 }
895 }
896
897 let registry_section = format!(
899 "\n[registries.community]\nurl = \"{}\"\nbranch = \"{}\"\npath = \"{}\"\nreadonly = true\nauto_update = true\n",
900 COMMUNITY_REGISTRY_URL,
901 DEFAULT_BRANCH,
902 default_path.display()
903 );
904
905 let mut content = std::fs::read_to_string(config_path)
906 .with_context(|| format!("Failed to read config: {}", config_path.display()))?;
907 content.push_str(®istry_section);
908 std::fs::write(config_path, &content)
909 .with_context(|| format!("Failed to write config: {}", config_path.display()))?;
910
911 println!("Added [registries.community] to {}", config_path.display());
912 println!("Run `ctx registry list` to see available extensions.");
913
914 Ok(())
915}
916
917fn expand_tilde(path: &Path) -> PathBuf {
923 let s = path.to_string_lossy();
924 if s.starts_with("~/") || s == "~" {
925 if let Some(home) = home_dir() {
926 return home.join(s.strip_prefix("~/").unwrap_or(""));
927 }
928 }
929 path.to_path_buf()
930}
931
932fn home_dir() -> Option<PathBuf> {
934 std::env::var_os("HOME").map(PathBuf::from)
935}
936
937fn default_registries_dir() -> PathBuf {
939 home_dir()
940 .unwrap_or_else(|| PathBuf::from("."))
941 .join(".ctx")
942 .join("registries")
943}
944
945fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
947 std::fs::create_dir_all(dst)?;
948
949 for entry in std::fs::read_dir(src)? {
950 let entry = entry?;
951 let src_path = entry.path();
952 let dst_path = dst.join(entry.file_name());
953
954 if src_path.is_dir() {
955 if entry.file_name() == ".git" {
956 continue;
957 }
958 copy_dir_recursive(&src_path, &dst_path)?;
959 } else {
960 std::fs::copy(&src_path, &dst_path)?;
961 }
962 }
963
964 Ok(())
965}
966
967#[cfg(test)]
972mod tests {
973 use super::*;
974 use crate::config::RegistryConfig;
975
976 #[test]
977 fn parse_manifest() {
978 let toml = r#"
979[registry]
980name = "test"
981description = "Test registry"
982min_version = "0.3.0"
983
984[connectors.jira]
985description = "Index Jira issues"
986path = "connectors/jira/connector.lua"
987tags = ["atlassian", "pm"]
988required_config = ["url", "api_token"]
989host_apis = ["http", "json"]
990
991[tools.summarize]
992description = "Summarize a document"
993path = "tools/summarize/tool.lua"
994tags = ["llm"]
995host_apis = ["http", "context"]
996
997[agents.runbook]
998description = "Incident response agent"
999path = "agents/runbook/agent.lua"
1000tags = ["ops"]
1001tools = ["search", "get"]
1002"#;
1003
1004 let manifest: RegistryManifest = toml::from_str(toml).unwrap();
1005
1006 assert_eq!(manifest.registry.name, "test");
1007 assert_eq!(manifest.registry.min_version, Some("0.3.0".to_string()));
1008
1009 assert_eq!(manifest.connectors.len(), 1);
1010 let jira = &manifest.connectors["jira"];
1011 assert_eq!(jira.description, "Index Jira issues");
1012 assert_eq!(jira.path, "connectors/jira/connector.lua");
1013 assert_eq!(jira.tags, vec!["atlassian", "pm"]);
1014 assert_eq!(jira.required_config, vec!["url", "api_token"]);
1015
1016 assert_eq!(manifest.tools.len(), 1);
1017 assert_eq!(
1018 manifest.tools["summarize"].description,
1019 "Summarize a document"
1020 );
1021
1022 assert_eq!(manifest.agents.len(), 1);
1023 assert_eq!(manifest.agents["runbook"].tools, vec!["search", "get"]);
1024 }
1025
1026 #[test]
1027 fn parse_empty_manifest() {
1028 let toml = "[registry]\nname = \"empty\"\n";
1029 let manifest: RegistryManifest = toml::from_str(toml).unwrap();
1030 assert_eq!(manifest.connectors.len(), 0);
1031 assert_eq!(manifest.tools.len(), 0);
1032 assert_eq!(manifest.agents.len(), 0);
1033 }
1034
1035 #[test]
1036 fn expand_tilde_works() {
1037 let expanded = expand_tilde(Path::new("~/foo/bar"));
1038 assert!(!expanded.to_string_lossy().starts_with("~"));
1039 assert!(expanded.to_string_lossy().ends_with("foo/bar"));
1040 }
1041
1042 #[test]
1043 fn expand_tilde_noop_for_absolute() {
1044 let path = Path::new("/usr/local/bin");
1045 assert_eq!(expand_tilde(path), path.to_path_buf());
1046 }
1047
1048 #[test]
1049 fn discover_manifest_empty_dir() {
1050 let dir = tempfile::tempdir().unwrap();
1051 let manifest = discover_manifest(dir.path());
1052 assert!(manifest.connectors.is_empty());
1053 assert!(manifest.tools.is_empty());
1054 assert!(manifest.agents.is_empty());
1055 }
1056
1057 #[test]
1058 fn discover_manifest_finds_scripts() {
1059 let dir = tempfile::tempdir().unwrap();
1060
1061 let jira_dir = dir.path().join("connectors").join("jira");
1063 std::fs::create_dir_all(&jira_dir).unwrap();
1064 std::fs::write(jira_dir.join("connector.lua"), "-- jira").unwrap();
1065
1066 let tool_dir = dir.path().join("tools").join("summarize");
1068 std::fs::create_dir_all(&tool_dir).unwrap();
1069 std::fs::write(tool_dir.join("tool.lua"), "-- summarize").unwrap();
1070
1071 let agent_dir = dir.path().join("agents").join("runbook");
1073 std::fs::create_dir_all(&agent_dir).unwrap();
1074 std::fs::write(agent_dir.join("agent.lua"), "-- runbook").unwrap();
1075
1076 let manifest = discover_manifest(dir.path());
1077 assert_eq!(manifest.connectors.len(), 1);
1078 assert!(manifest.connectors.contains_key("jira"));
1079 assert_eq!(manifest.tools.len(), 1);
1080 assert!(manifest.tools.contains_key("summarize"));
1081 assert_eq!(manifest.agents.len(), 1);
1082 assert!(manifest.agents.contains_key("runbook"));
1083 }
1084
1085 #[test]
1086 fn resolution_order_later_wins() {
1087 let dir_a = tempfile::tempdir().unwrap();
1088 let dir_b = tempfile::tempdir().unwrap();
1089
1090 for dir in [dir_a.path(), dir_b.path()] {
1092 let jira_dir = dir.join("connectors").join("jira");
1093 std::fs::create_dir_all(&jira_dir).unwrap();
1094 std::fs::write(jira_dir.join("connector.lua"), "-- jira").unwrap();
1095 }
1096
1097 let config = Config {
1098 registries: {
1099 let mut m = HashMap::new();
1100 m.insert(
1101 "first".to_string(),
1102 RegistryConfig {
1103 url: None,
1104 branch: None,
1105 path: dir_a.path().to_path_buf(),
1106 readonly: true,
1107 auto_update: false,
1108 },
1109 );
1110 m.insert(
1111 "second".to_string(),
1112 RegistryConfig {
1113 url: None,
1114 branch: None,
1115 path: dir_b.path().to_path_buf(),
1116 readonly: false,
1117 auto_update: false,
1118 },
1119 );
1120 m
1121 },
1122 ..Config::minimal()
1123 };
1124
1125 let mgr = RegistryManager::from_config(&config);
1126 let connectors = mgr.list_connectors();
1127 assert_eq!(connectors.len(), 1);
1128 assert_eq!(connectors[0].name, "jira");
1132 }
1133
1134 #[test]
1135 fn find_ctx_dir_from_nested() {
1136 let root = tempfile::tempdir().unwrap();
1137 let ctx_dir = root.path().join(".ctx");
1138 std::fs::create_dir_all(&ctx_dir).unwrap();
1139
1140 let nested = root.path().join("src").join("deep");
1141 std::fs::create_dir_all(&nested).unwrap();
1142
1143 let found = find_ctx_dir_from(nested);
1144 assert!(found.is_some());
1145 assert_eq!(found.unwrap(), ctx_dir);
1146 }
1147
1148 #[test]
1149 fn find_ctx_dir_returns_none_when_missing() {
1150 let root = tempfile::tempdir().unwrap();
1151 let nested = root.path().join("src").join("deep");
1152 std::fs::create_dir_all(&nested).unwrap();
1153
1154 let found = find_ctx_dir_from(nested);
1155 assert!(found.is_none());
1156 }
1157}