context_harness/
registry.rs

1//! Extension registry system for community connectors, tools, and agents.
2//!
3//! Registries are directories (optionally backed by Git repositories) that
4//! contain Lua scripts and TOML definitions described by a `registry.toml`
5//! manifest. Multiple registries can be configured with precedence ordering:
6//!
7//! ```text
8//! community (readonly) → company (readonly) → personal (writable) → .ctx/ (project-local)
9//! ```
10//!
11//! Later registries override earlier ones for the same extension name, and
12//! explicit `ctx.toml` entries always take highest precedence.
13//!
14//! # Registry Layout
15//!
16//! ```text
17//! registry.toml          # manifest
18//! connectors/
19//!   jira/
20//!     connector.lua
21//!     config.example.toml
22//!     README.md
23//! tools/
24//!   summarize/
25//!     tool.lua
26//!     README.md
27//! agents/
28//!   runbook/
29//!     agent.lua
30//!     README.md
31//! ```
32
33use 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// ═══════════════════════════════════════════════════════════════════════
45// Manifest Types
46// ═══════════════════════════════════════════════════════════════════════
47
48/// Parsed `registry.toml` manifest describing all extensions in a registry.
49#[derive(Debug, Deserialize, Clone)]
50pub struct RegistryManifest {
51    /// Top-level registry metadata.
52    #[serde(default)]
53    #[allow(dead_code)]
54    pub registry: RegistryMeta,
55    /// Connector extensions keyed by name.
56    #[serde(default)]
57    pub connectors: HashMap<String, ExtensionEntry>,
58    /// Tool extensions keyed by name.
59    #[serde(default)]
60    pub tools: HashMap<String, ExtensionEntry>,
61    /// Agent extensions keyed by name.
62    #[serde(default)]
63    pub agents: HashMap<String, ExtensionEntry>,
64}
65
66/// Top-level metadata about a registry.
67#[derive(Debug, Deserialize, Clone, Default)]
68#[allow(dead_code)]
69pub struct RegistryMeta {
70    /// Human-readable registry name (e.g. `"community"`).
71    #[serde(default)]
72    pub name: String,
73    /// One-line description.
74    #[serde(default)]
75    pub description: String,
76    /// Canonical URL for the registry.
77    #[serde(default)]
78    pub url: Option<String>,
79    /// Minimum `ctx` binary version required by this registry.
80    #[serde(default)]
81    pub min_version: Option<String>,
82}
83
84/// Metadata about a single extension (connector, tool, or agent).
85#[derive(Debug, Deserialize, Clone)]
86pub struct ExtensionEntry {
87    /// One-line description of the extension.
88    #[serde(default)]
89    pub description: String,
90    /// Relative path from registry root to the script file.
91    pub path: String,
92    /// Tags for filtering and discovery.
93    #[serde(default)]
94    pub tags: Vec<String>,
95    /// Config keys required by this extension (for connectors).
96    #[serde(default)]
97    pub required_config: Vec<String>,
98    /// Lua host APIs used by this extension.
99    #[serde(default)]
100    pub host_apis: Vec<String>,
101    /// Tools this agent exposes (agents only).
102    #[serde(default)]
103    pub tools: Vec<String>,
104}
105
106/// A resolved extension with its absolute script path and source registry.
107#[derive(Debug, Clone)]
108pub struct ResolvedExtension {
109    /// Extension name (e.g. `"jira"`).
110    pub name: String,
111    /// Extension type: `"connector"`, `"tool"`, or `"agent"`.
112    pub kind: String,
113    /// Absolute path to the script file.
114    pub script_path: PathBuf,
115    /// Name of the registry this extension came from.
116    pub registry_name: String,
117    /// Extension metadata from the manifest.
118    pub entry: ExtensionEntry,
119}
120
121// ═══════════════════════════════════════════════════════════════════════
122// Git Operations
123// ═══════════════════════════════════════════════════════════════════════
124
125/// Shallow-clone a Git repository into `target_dir`.
126pub 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
158/// Pull the latest changes for a git-backed registry.
159pub 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
186/// Returns `true` if the directory contains a `.git` subdirectory.
187pub fn is_git_repo(dir: &Path) -> bool {
188    dir.join(".git").exists()
189}
190
191/// Returns `true` if the git working tree has uncommitted changes.
192fn 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
202// ═══════════════════════════════════════════════════════════════════════
203// Manifest Loading
204// ═══════════════════════════════════════════════════════════════════════
205
206/// Load and parse a `registry.toml` manifest from a registry directory.
207pub 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
218/// Attempt to build a manifest by scanning the directory structure when
219/// no `registry.toml` is present (e.g. for `.ctx/` project-local dirs).
220fn 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    // Agents can be .lua or .toml
263    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
273// ═══════════════════════════════════════════════════════════════════════
274// Registry Manager
275// ═══════════════════════════════════════════════════════════════════════
276
277/// Manages multiple extension registries with precedence-based resolution.
278///
279/// Registries are loaded in config order. A `.ctx/` directory in the
280/// current working directory (or ancestors) is appended with the highest
281/// precedence. Later registries override earlier ones for the same name.
282pub struct RegistryManager {
283    /// Registries in precedence order (lowest first).
284    registries: Vec<LoadedRegistry>,
285}
286
287struct LoadedRegistry {
288    name: String,
289    path: PathBuf,
290    manifest: RegistryManifest,
291    readonly: bool,
292}
293
294impl RegistryManager {
295    /// Build a `RegistryManager` from the config, loading all manifests.
296    ///
297    /// Registries that don't exist on disk or lack a valid manifest are
298    /// skipped with a warning. The `.ctx/` project-local directory is
299    /// appended if found.
300    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(&reg_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        // Append .ctx/ project-local directory if found
334        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    /// List all extensions across all registries, resolved by precedence.
351    ///
352    /// Later registries override earlier ones for the same `kind/name`.
353    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 &reg.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 &reg.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 &reg.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    /// Resolve a specific extension by `"type/name"` (e.g. `"connectors/jira"`).
404    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    /// List all resolved connectors.
411    #[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    /// List all resolved tools.
420    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    /// List all resolved agents.
428    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    /// Find the first writable registry path (iterates from highest precedence).
436    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    /// Get the loaded registries (for listing/status).
445    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
461/// Summary information about a loaded registry (for CLI display).
462pub 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
472// ═══════════════════════════════════════════════════════════════════════
473// .ctx/ Directory Discovery
474// ═══════════════════════════════════════════════════════════════════════
475
476/// Walk from the current directory upward looking for a `.ctx/` directory.
477///
478/// Returns the first `.ctx/` directory found, or `None` if the filesystem
479/// root is reached without finding one.
480pub fn find_local_ctx_dir() -> Option<PathBuf> {
481    find_ctx_dir_from(std::env::current_dir().ok()?)
482}
483
484/// Walk from `start` upward looking for a `.ctx/` directory.
485fn 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
498// ═══════════════════════════════════════════════════════════════════════
499// CLI Command Implementations
500// ═══════════════════════════════════════════════════════════════════════
501
502/// `ctx registry list` — show configured registries and their extensions.
503pub 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 &registries {
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
554/// `ctx registry install` — clone configured registries that aren't yet present.
555pub 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 &reg_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(&reg_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        // Report what was installed
589        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
614/// `ctx registry update` — git pull all (or a specific) registry.
615pub 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(&reg_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
661/// `ctx registry search <query>` — fuzzy search extensions by name/description/tags.
662pub 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
698/// `ctx registry info <type/name>` — show details for a specific extension.
699pub 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    // Try to print the README
727    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
739/// `ctx registry add <type/name>` — scaffold a config entry in ctx.toml.
740pub 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    // Try to load config.example.toml from the extension directory
748    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    // Append to config file
798    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(&section);
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
824/// `ctx registry override <type/name>` — copy extension to a writable registry.
825pub 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    // Copy the entire extension directory
846    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
867/// `ctx registry init` — interactive first-run community registry setup.
868pub 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    // Report what was installed
883    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    // Append registry config to ctx.toml
898    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(&registry_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
917// ═══════════════════════════════════════════════════════════════════════
918// Utilities
919// ═══════════════════════════════════════════════════════════════════════
920
921/// Expand `~` at the start of a path to the user's home directory.
922fn 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
932/// Get the user's home directory.
933fn home_dir() -> Option<PathBuf> {
934    std::env::var_os("HOME").map(PathBuf::from)
935}
936
937/// Default directory for storing registries.
938fn default_registries_dir() -> PathBuf {
939    home_dir()
940        .unwrap_or_else(|| PathBuf::from("."))
941        .join(".ctx")
942        .join("registries")
943}
944
945/// Recursively copy a directory and all its contents.
946fn 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// ═══════════════════════════════════════════════════════════════════════
968// Tests
969// ═══════════════════════════════════════════════════════════════════════
970
971#[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        // Create connectors/jira/connector.lua
1062        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        // Create tools/summarize/tool.lua
1067        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        // Create agents/runbook/agent.lua
1072        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        // Both registries have connectors/jira
1091        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        // The winner should be from one of the two registries; the exact
1129        // winner depends on HashMap iteration order, but both are valid
1130        // since they have the same name.
1131        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}