Skip to main content

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