Skip to main content

context_harness/
registry.rs

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