Skip to main content

thndrs_lib/core/acp/
registry.rs

1//! ACP Registry metadata and local install records.
2
3use std::collections::BTreeMap;
4use std::fmt;
5use std::fs::{self, File, OpenOptions};
6use std::io::{Read, Write};
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use serde::{Deserialize, Serialize};
11
12use crate::config::validate_acp_agent_name;
13
14/// Official ACP Registry JSON endpoint.
15pub const DEFAULT_REGISTRY_URL: &str = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
16
17const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
18const MAX_REGISTRY_BYTES: u64 = 2 * 1024 * 1024;
19const MAX_FIELD_CHARS: usize = 300;
20const CONFIG_BLOCK_START: &str = "# thndrs acp registry install:";
21const CONFIG_BLOCK_END: &str = "# /thndrs acp registry install:";
22
23#[derive(Clone, Debug, Deserialize)]
24struct RegistryDocument {
25    version: String,
26    agents: Vec<RegistryAgent>,
27}
28
29#[derive(Clone, Debug, Deserialize)]
30struct RegistryAgent {
31    id: String,
32    name: String,
33    version: String,
34    description: String,
35    #[serde(default)]
36    repository: Option<String>,
37    #[serde(default)]
38    website: Option<String>,
39    #[serde(default)]
40    distribution: Distribution,
41}
42
43#[derive(Clone, Debug, Default, Deserialize)]
44struct Distribution {
45    #[serde(default)]
46    npx: Option<PackageDistribution>,
47    #[serde(default)]
48    uvx: Option<PackageDistribution>,
49    #[serde(default)]
50    binary: Option<serde_json::Value>,
51}
52
53#[derive(Clone, Debug, Deserialize)]
54struct PackageDistribution {
55    package: String,
56    #[serde(default)]
57    args: Vec<String>,
58    #[serde(default)]
59    env: BTreeMap<String, String>,
60}
61
62#[derive(Clone, Debug, Eq, PartialEq)]
63enum InstallDistribution {
64    Npx {
65        package: String,
66        args: Vec<String>,
67        env_keys: Vec<String>,
68    },
69    Uvx {
70        package: String,
71        args: Vec<String>,
72        env_keys: Vec<String>,
73    },
74}
75
76/// Request to install one registry agent into the local workspace config.
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub struct InstallRequest {
79    /// Registry agent id to install.
80    pub agent_id: String,
81    /// Optional local `acp:<name>` config name.
82    pub name: Option<String>,
83    /// Registry source label for metadata.
84    pub source: RegistrySource,
85    /// Require explicit `--yes` confirmation before mutating files.
86    pub confirmed: bool,
87    /// Timestamp used for installed metadata.
88    pub timestamp: String,
89}
90
91/// Request to update one managed installed registry agent.
92#[derive(Clone, Debug, Eq, PartialEq)]
93pub struct UpdateRequest {
94    /// Local `acp:<name>` config name.
95    pub name: String,
96    /// Registry source label for metadata.
97    pub source: RegistrySource,
98    /// Require explicit `--yes` confirmation before mutating files.
99    pub confirmed: bool,
100    /// Timestamp used for installed metadata.
101    pub timestamp: String,
102}
103
104/// ACP Registry metadata source.
105#[derive(Clone, Debug, Eq, PartialEq)]
106pub enum RegistrySource {
107    /// Official CDN registry source.
108    Official,
109    /// Local registry JSON file.
110    File(PathBuf),
111}
112
113impl RegistrySource {
114    fn label(&self) -> String {
115        match self {
116            RegistrySource::Official => DEFAULT_REGISTRY_URL.to_string(),
117            RegistrySource::File(path) => format!("file:{}", path.display()),
118        }
119    }
120}
121
122/// Result of installing or updating one registry agent.
123#[derive(Clone, Debug, Eq, PartialEq)]
124pub struct InstallOutcome {
125    /// Local `acp:<name>` config name.
126    pub name: String,
127    /// Registry id.
128    pub agent_id: String,
129    /// Registry-published version.
130    pub agent_version: String,
131    /// Config file that was written.
132    pub config_path: PathBuf,
133    /// Metadata file that was written.
134    pub metadata_path: PathBuf,
135    /// Command users can select.
136    pub model: String,
137}
138
139#[derive(Debug, Default, Deserialize, Serialize)]
140struct InstalledAgentsFile {
141    #[serde(default)]
142    agents: BTreeMap<String, InstalledAgentRecord>,
143}
144
145#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
146struct InstalledAgentRecord {
147    registry_source: String,
148    registry_document_version: String,
149    agent_id: String,
150    agent_name: String,
151    agent_version: String,
152    distribution: String,
153    package: String,
154    command: String,
155    args: Vec<String>,
156    install_dir: String,
157    installed_at: String,
158    status: String,
159    env_keys: Vec<String>,
160}
161
162/// One ACP Registry agent row safe for display.
163#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct RegistryAgentView {
165    /// Stable registry id.
166    pub id: String,
167    /// Human-readable agent name.
168    pub name: String,
169    /// Registry-published agent version.
170    pub version: String,
171    /// Short description.
172    pub description: String,
173    /// Available distribution kinds, excluding env values and install commands.
174    pub distributions: Vec<String>,
175    /// Repository or website URL.
176    pub homepage: Option<String>,
177}
178
179/// Parsed ACP Registry metadata safe for display.
180#[derive(Clone, Debug, Eq, PartialEq)]
181pub struct RegistryView {
182    /// Registry document version.
183    pub version: String,
184    /// Display-safe agent rows.
185    pub agents: Vec<RegistryAgentView>,
186}
187
188impl fmt::Display for RegistryView {
189    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
190        writeln!(formatter, "ACP registry v{} ({})", self.version, DEFAULT_REGISTRY_URL)?;
191        if self.agents.is_empty() {
192            writeln!(formatter, "no ACP agents found")?;
193            return Ok(());
194        }
195
196        for agent in &self.agents {
197            let distributions =
198                if agent.distributions.is_empty() { "unknown".to_string() } else { agent.distributions.join(", ") };
199            let homepage = agent.homepage.as_deref().unwrap_or("-");
200            writeln!(
201                formatter,
202                "{}\t{}\t{}\t{}\t{}",
203                agent.id, agent.name, agent.version, distributions, homepage
204            )?;
205            writeln!(formatter, "  {}", agent.description)?;
206        }
207        writeln!(
208            formatter,
209            "install/update: use `thndrs acp install <registry-id> --yes` or `thndrs acp update <name> --yes`; package managers and binaries are not executed during install/update"
210        )
211    }
212}
213
214/// Fetch the official ACP Registry metadata.
215pub fn fetch_official() -> Result<RegistryView, String> {
216    parse(&read_source(&RegistrySource::Official)?)
217}
218
219/// Read ACP Registry metadata from a local JSON file.
220pub fn read_file(path: &Path) -> Result<RegistryView, String> {
221    parse(&read_source(&RegistrySource::File(path.to_path_buf()))?)
222}
223
224/// Parse ACP Registry JSON into display-safe rows.
225pub fn parse(json: &str) -> Result<RegistryView, String> {
226    let document: RegistryDocument =
227        serde_json::from_str(json).map_err(|err| format!("failed to parse ACP registry JSON: {err}"))?;
228    let agents = document
229        .agents
230        .into_iter()
231        .map(|agent| RegistryAgentView {
232            id: display_field(&agent.id),
233            name: display_field(&agent.name),
234            version: display_field(&agent.version),
235            description: display_field(&agent.description),
236            distributions: distribution_labels(&agent.distribution),
237            homepage: agent.repository.or(agent.website).map(|value| display_field(&value)),
238        })
239        .collect();
240    Ok(RegistryView { version: document.version, agents })
241}
242
243/// Install one registry agent into workspace ACP config and metadata.
244pub fn install(workspace: &Path, request: &InstallRequest) -> Result<InstallOutcome, String> {
245    if !request.confirmed {
246        return Err("refusing to install ACP registry agent without --yes".to_string());
247    }
248    let text = read_source(&request.source)?;
249    let document = parse_document(&text)?;
250    let agent = find_agent(&document, &request.agent_id)?;
251    let name = request
252        .name
253        .clone()
254        .unwrap_or_else(|| local_name_from_agent_id(&agent.id));
255    validate_acp_name(&name)?;
256    let distribution = install_distribution(&agent.distribution).ok_or_else(|| {
257        format!(
258            "ACP registry agent `{}` has no supported install distribution",
259            agent.id
260        )
261    })?;
262
263    let config_path = workspace.join(".thndrs").join("config.toml");
264    let metadata_path = workspace.join(".thndrs").join("acp-installed.toml");
265    let block = config_block(&name, &distribution);
266    let config = read_optional_string(&config_path)?;
267    if config.contains(&format!("[acp_agents.{name}]")) && !has_managed_block(&config, &name) {
268        return Err(format!(
269            "ACP agent `{name}` already exists and is not managed by registry install"
270        ));
271    }
272
273    let updated_config = upsert_managed_block(&config, &name, &block);
274    write_string(&config_path, &updated_config)?;
275
276    let mut installed = read_installed_agents(&metadata_path)?;
277    installed.agents.insert(
278        name.clone(),
279        installed_record(
280            &document,
281            &agent,
282            &distribution,
283            &request.source,
284            workspace,
285            &request.timestamp,
286        ),
287    );
288
289    write_installed_agents(&metadata_path, &installed)?;
290
291    Ok(InstallOutcome {
292        name: name.clone(),
293        agent_id: agent.id,
294        agent_version: agent.version,
295        config_path,
296        metadata_path,
297        model: format!("acp:{name}"),
298    })
299}
300
301/// Update one managed registry-installed ACP agent.
302pub fn update(workspace: &Path, request: &UpdateRequest) -> Result<InstallOutcome, String> {
303    if !request.confirmed {
304        return Err("refusing to update ACP registry agent without --yes".to_string());
305    }
306
307    validate_acp_name(&request.name)?;
308
309    let metadata_path = workspace.join(".thndrs").join("acp-installed.toml");
310    let installed = read_installed_agents(&metadata_path)?;
311    let existing = installed
312        .agents
313        .get(&request.name)
314        .ok_or_else(|| format!("ACP agent `{}` is not managed by registry install", request.name))?;
315
316    install(
317        workspace,
318        &InstallRequest {
319            agent_id: existing.agent_id.clone(),
320            name: Some(request.name.clone()),
321            source: request.source.clone(),
322            confirmed: true,
323            timestamp: request.timestamp.clone(),
324        },
325    )
326}
327
328fn read_source(source: &RegistrySource) -> Result<String, String> {
329    match source {
330        RegistrySource::Official => fetch_official_text(),
331        RegistrySource::File(path) => read_limited_file(path),
332    }
333}
334
335fn fetch_official_text() -> Result<String, String> {
336    let config = ureq::Agent::config_builder()
337        .timeout_global(Some(FETCH_TIMEOUT))
338        .build();
339    let agent = ureq::Agent::new_with_config(config);
340    agent
341        .get(DEFAULT_REGISTRY_URL)
342        .call()
343        .map_err(|err| format!("failed to fetch ACP registry: {err}"))?
344        .body_mut()
345        .with_config()
346        .limit(MAX_REGISTRY_BYTES)
347        .read_to_string()
348        .map_err(|err| format!("failed to read ACP registry response: {err}"))
349}
350
351fn parse_document(json: &str) -> Result<RegistryDocument, String> {
352    serde_json::from_str(json).map_err(|err| format!("failed to parse ACP registry JSON: {err}"))
353}
354
355fn find_agent(document: &RegistryDocument, agent_id: &str) -> Result<RegistryAgent, String> {
356    document
357        .agents
358        .iter()
359        .find(|agent| agent.id == agent_id)
360        .cloned()
361        .ok_or_else(|| format!("ACP registry agent `{agent_id}` was not found"))
362}
363
364fn distribution_labels(distribution: &Distribution) -> Vec<String> {
365    let mut labels = Vec::new();
366    if let Some(npx) = &distribution.npx {
367        labels.push(format!("npx:{}", display_field(&npx.package)));
368    }
369    if let Some(uvx) = &distribution.uvx {
370        labels.push(format!("uvx:{}", display_field(&uvx.package)));
371    }
372    if distribution.binary.is_some() {
373        labels.push("binary".to_string());
374    }
375    labels
376}
377
378fn install_distribution(dist: &Distribution) -> Option<InstallDistribution> {
379    dist.npx
380        .as_ref()
381        .map(|package| InstallDistribution::Npx {
382            package: package.package.clone(),
383            args: package.args.clone(),
384            env_keys: sorted_env_keys(&package.env),
385        })
386        .or_else(|| {
387            dist.uvx.as_ref().map(|package| InstallDistribution::Uvx {
388                package: package.package.clone(),
389                args: package.args.clone(),
390                env_keys: sorted_env_keys(&package.env),
391            })
392        })
393}
394
395fn sorted_env_keys(env: &BTreeMap<String, String>) -> Vec<String> {
396    env.keys().map(|key| display_field(key)).collect()
397}
398
399fn local_name_from_agent_id(agent_id: &str) -> String {
400    agent_id
401        .strip_suffix("-acp")
402        .unwrap_or(agent_id)
403        .chars()
404        .map(|character| {
405            if character.is_ascii_alphanumeric() || character == '_' || character == '-' {
406                character
407            } else {
408                '-'
409            }
410        })
411        .collect()
412}
413
414fn validate_acp_name(name: &str) -> Result<(), String> {
415    validate_acp_agent_name(name).map_err(|err| err.to_string())
416}
417
418fn config_block(name: &str, distribution: &InstallDistribution) -> String {
419    let (command, args) = command_and_args(distribution);
420    let mut block = String::new();
421    block.push_str(&format!("{CONFIG_BLOCK_START} {name}\n"));
422    block.push_str(&format!("[acp_agents.{name}]\n"));
423    block.push_str(&format!("command = {}\n", toml_string(&command)));
424    block.push_str("args = [");
425    for (index, arg) in args.iter().enumerate() {
426        if index > 0 {
427            block.push_str(", ");
428        }
429        block.push_str(&toml_string(arg));
430    }
431    block.push_str("]\n");
432    block.push_str("enabled = true\n");
433    block.push_str(&format!("{CONFIG_BLOCK_END} {name}\n"));
434    block
435}
436
437fn command_and_args(distribution: &InstallDistribution) -> (String, Vec<String>) {
438    match distribution {
439        InstallDistribution::Npx { package, args, .. } => {
440            let mut all_args = vec!["-y".to_string(), package.clone()];
441            all_args.extend(args.clone());
442            ("npx".to_string(), all_args)
443        }
444        InstallDistribution::Uvx { package, args, .. } => {
445            let mut all_args = vec![package.clone()];
446            all_args.extend(args.clone());
447            ("uvx".to_string(), all_args)
448        }
449    }
450}
451
452fn toml_string(value: &str) -> String {
453    toml::Value::String(value.to_string()).to_string()
454}
455
456fn read_optional_string(path: &Path) -> Result<String, String> {
457    match fs::read_to_string(path) {
458        Ok(content) => Ok(content),
459        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
460        Err(err) => Err(format!("failed to read `{}`: {err}", path.display())),
461    }
462}
463
464fn has_managed_block(config: &str, name: &str) -> bool {
465    config.contains(&format!("{CONFIG_BLOCK_START} {name}"))
466}
467
468fn upsert_managed_block(config: &str, name: &str, block: &str) -> String {
469    let start_marker = format!("{CONFIG_BLOCK_START} {name}");
470    let end_marker = format!("{CONFIG_BLOCK_END} {name}");
471    let Some(start) = config.find(&start_marker) else {
472        let mut updated = config.trim_end().to_string();
473        if !updated.is_empty() {
474            updated.push_str("\n\n");
475        }
476        updated.push_str(block);
477        return updated;
478    };
479    let Some(relative_end) = config[start..].find(&end_marker) else {
480        return config.to_string();
481    };
482    let end = start + relative_end + end_marker.len();
483    let mut updated = String::new();
484    updated.push_str(config[..start].trim_end());
485    if !updated.is_empty() {
486        updated.push_str("\n\n");
487    }
488    updated.push_str(block.trim_end());
489    updated.push('\n');
490    updated.push_str(config[end..].trim_start_matches(['\r', '\n']));
491    updated
492}
493
494fn write_string(path: &Path, content: &str) -> Result<(), String> {
495    if let Some(parent) = path.parent() {
496        fs::create_dir_all(parent).map_err(|err| format!("failed to create `{}`: {err}", parent.display()))?;
497    }
498    let mut file = OpenOptions::new()
499        .create(true)
500        .truncate(true)
501        .write(true)
502        .open(path)
503        .map_err(|err| format!("failed to write `{}`: {err}", path.display()))?;
504    file.write_all(content.as_bytes())
505        .map_err(|err| format!("failed to write `{}`: {err}", path.display()))
506}
507
508fn read_installed_agents(path: &Path) -> Result<InstalledAgentsFile, String> {
509    let content = read_optional_string(path)?;
510    if content.trim().is_empty() {
511        return Ok(InstalledAgentsFile::default());
512    }
513    toml::from_str(&content)
514        .map_err(|err| format!("failed to parse installed ACP metadata `{}`: {err}", path.display()))
515}
516
517fn write_installed_agents(path: &Path, installed: &InstalledAgentsFile) -> Result<(), String> {
518    let content = toml::to_string_pretty(installed)
519        .map_err(|err| format!("failed to encode installed ACP metadata `{}`: {err}", path.display()))?;
520    write_string(path, &content)
521}
522
523fn installed_record(
524    document: &RegistryDocument, agent: &RegistryAgent, dist: &InstallDistribution, source: &RegistrySource, ws: &Path,
525    timestamp: &str,
526) -> InstalledAgentRecord {
527    let (command, args) = command_and_args(dist);
528    let (distribution_name, package, env_keys) = match dist {
529        InstallDistribution::Npx { package, env_keys, .. } => ("npx", package, env_keys),
530        InstallDistribution::Uvx { package, env_keys, .. } => ("uvx", package, env_keys),
531    };
532    InstalledAgentRecord {
533        registry_source: source.label(),
534        registry_document_version: document.version.clone(),
535        agent_id: agent.id.clone(),
536        agent_name: agent.name.clone(),
537        agent_version: agent.version.clone(),
538        distribution: distribution_name.to_string(),
539        package: package.clone(),
540        command,
541        args,
542        install_dir: ws.join(".thndrs").display().to_string(),
543        installed_at: timestamp.to_string(),
544        status: "installed".to_string(),
545        env_keys: env_keys.clone(),
546    }
547}
548
549fn read_limited_file(path: &Path) -> Result<String, String> {
550    if let Ok(metadata) = fs::metadata(path)
551        && metadata.len() > MAX_REGISTRY_BYTES
552    {
553        return Err(format!(
554            "ACP registry file `{}` is too large: {} bytes exceeds {} bytes",
555            path.display(),
556            metadata.len(),
557            MAX_REGISTRY_BYTES
558        ));
559    }
560
561    let mut file =
562        File::open(path).map_err(|err| format!("failed to read ACP registry file `{}`: {err}", path.display()))?;
563    let mut bytes = Vec::new();
564    std::io::Read::by_ref(&mut file)
565        .take(MAX_REGISTRY_BYTES + 1)
566        .read_to_end(&mut bytes)
567        .map_err(|err| format!("failed to read ACP registry file `{}`: {err}", path.display()))?;
568    if bytes.len() as u64 > MAX_REGISTRY_BYTES {
569        return Err(format!(
570            "ACP registry file `{}` is too large: exceeds {} bytes",
571            path.display(),
572            MAX_REGISTRY_BYTES
573        ));
574    }
575    String::from_utf8(bytes)
576        .map_err(|err| format!("failed to read ACP registry file `{}` as UTF-8: {err}", path.display()))
577}
578
579fn display_field(value: &str) -> String {
580    let mut out = String::new();
581    for part in value.split_whitespace() {
582        if !out.is_empty() {
583            out.push(' ');
584        }
585        out.extend(part.chars().filter(|character| !character.is_control()));
586        if out.chars().count() >= MAX_FIELD_CHARS {
587            break;
588        }
589    }
590    truncate_chars(out, MAX_FIELD_CHARS)
591}
592
593fn truncate_chars(value: String, max: usize) -> String {
594    let mut chars = value.chars();
595    let mut out = String::new();
596    for _ in 0..max {
597        let Some(character) = chars.next() else {
598            return value;
599        };
600        out.push(character);
601    }
602    if chars.next().is_some() {
603        out.push_str("...");
604    }
605    out
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    #[test]
613    fn registry_parse_returns_display_safe_rows() {
614        let registry = parse(
615            r#"{
616                "version": "1.0.0",
617                "agents": [{
618                    "id": "codex-acp",
619                    "name": "Codex",
620                    "version": "1.1.0",
621                    "description": "ACP adapter\nfor OpenAI",
622                    "repository": "https://github.com/agentclientprotocol/codex-acp",
623                    "distribution": {
624                        "npx": {
625                            "package": "@agentclientprotocol/codex-acp@1.1.0",
626                            "args": ["--token", "sk-secret"],
627                            "env": {"OPENAI_API_KEY": "sk-secret"}
628                        }
629                    }
630                }]
631            }"#,
632        )
633        .expect("parse registry");
634
635        assert_eq!(registry.version, "1.0.0");
636        assert_eq!(registry.agents.len(), 1);
637        assert_eq!(registry.agents[0].id, "codex-acp");
638        assert_eq!(
639            registry.agents[0].distributions,
640            vec!["npx:@agentclientprotocol/codex-acp@1.1.0"]
641        );
642        let output = registry.to_string();
643        assert!(output.contains("codex-acp\tCodex\t1.1.0"));
644        assert!(output.contains("install/update: use `thndrs acp install"));
645        assert!(!output.contains("sk-secret"));
646        assert!(!output.contains("OPENAI_API_KEY"));
647    }
648
649    #[test]
650    fn registry_parse_reports_invalid_json() {
651        let err = parse("{not json").expect_err("invalid json should fail");
652
653        assert!(err.contains("failed to parse ACP registry JSON"));
654    }
655
656    #[test]
657    fn registry_parse_normalizes_display_fields() {
658        let registry = parse(
659            r#"{
660                "version": "1.0.0",
661                "agents": [{
662                    "id": "bad\nid",
663                    "name": "Name\tWith\tTabs",
664                    "version": "1.0.0\r\nnext",
665                    "description": "line\u0007 one\nline two",
666                    "repository": "https://example.test/repo\ninjected",
667                    "distribution": {
668                        "npx": {"package": "pkg\n--bad"}
669                    }
670                }]
671            }"#,
672        )
673        .expect("parse registry");
674
675        let output = registry.to_string();
676        assert!(
677            output.contains("bad id\tName With Tabs\t1.0.0 next\tnpx:pkg --bad\thttps://example.test/repo injected")
678        );
679        assert!(output.contains("line one line two"));
680        assert!(!output.contains("bad\nid"));
681        assert!(!output.contains('\u{0007}'));
682    }
683
684    #[test]
685    fn registry_read_file_reports_missing_path() {
686        let err = read_file(Path::new("/definitely/missing/acp-registry.json")).expect_err("missing file should fail");
687        assert!(err.contains("failed to read ACP registry file"));
688    }
689
690    #[test]
691    fn registry_read_file_rejects_oversized_file() {
692        let temp = tempfile::tempdir().expect("temp dir");
693        let path = temp.path().join("registry.json");
694        fs::write(&path, vec![b' '; MAX_REGISTRY_BYTES as usize + 1]).expect("write registry");
695
696        let err = read_file(&path).expect_err("oversized file should fail");
697        assert!(err.contains("too large"));
698    }
699
700    #[test]
701    fn registry_install_requires_confirmation() {
702        let temp = tempfile::tempdir().expect("temp dir");
703        let registry_path = write_registry(temp.path(), "1.1.0", "pkg@1.1.0", None);
704        let err = install(
705            temp.path(),
706            &InstallRequest {
707                agent_id: "codex-acp".to_string(),
708                name: Some("codex".to_string()),
709                source: RegistrySource::File(registry_path),
710                confirmed: false,
711                timestamp: "2026-07-05T00:00:00Z".to_string(),
712            },
713        )
714        .expect_err("install without --yes should fail");
715
716        assert!(err.contains("without --yes"));
717    }
718
719    #[test]
720    fn registry_install_writes_managed_config_and_metadata() {
721        let temp = tempfile::tempdir().expect("temp dir");
722        let registry_path = write_registry(temp.path(), "1.1.0", "@agentclientprotocol/codex-acp@1.1.0", None);
723        let outcome = install(
724            temp.path(),
725            &InstallRequest {
726                agent_id: "codex-acp".to_string(),
727                name: Some("codex".to_string()),
728                source: RegistrySource::File(registry_path),
729                confirmed: true,
730                timestamp: "2026-07-05T00:00:00Z".to_string(),
731            },
732        )
733        .expect("install");
734
735        assert_eq!(outcome.model, "acp:codex");
736
737        let config = fs::read_to_string(temp.path().join(".thndrs/config.toml")).expect("config");
738        assert!(config.contains("# thndrs acp registry install: codex"));
739        assert!(config.contains("[acp_agents.codex]"));
740        assert!(config.contains("command = \"npx\""));
741        assert!(config.contains("args = [\"-y\", \"@agentclientprotocol/codex-acp@1.1.0\", \"--acp\"]"));
742        assert!(!config.contains("OPENAI_API_KEY"));
743
744        let metadata = fs::read_to_string(temp.path().join(".thndrs/acp-installed.toml")).expect("metadata");
745        assert!(metadata.contains("agent_id = \"codex-acp\""));
746        assert!(metadata.contains("agent_version = \"1.1.0\""));
747        assert!(metadata.contains("distribution = \"npx\""));
748        assert!(metadata.contains("env_keys = [\"OPENAI_API_KEY\"]"));
749        assert!(!metadata.contains("sk-secret"));
750    }
751
752    #[test]
753    fn registry_update_replaces_managed_config_and_metadata() {
754        let temp = tempfile::tempdir().expect("temp dir");
755        let old_registry = write_registry(temp.path(), "1.1.0", "@agentclientprotocol/codex-acp@1.1.0", None);
756        install(
757            temp.path(),
758            &InstallRequest {
759                agent_id: "codex-acp".to_string(),
760                name: Some("codex".to_string()),
761                source: RegistrySource::File(old_registry),
762                confirmed: true,
763                timestamp: "2026-07-05T00:00:00Z".to_string(),
764            },
765        )
766        .expect("install");
767        let new_registry = write_registry(
768            temp.path(),
769            "1.2.0",
770            "@agentclientprotocol/codex-acp@1.2.0",
771            Some("new"),
772        );
773
774        let outcome = update(
775            temp.path(),
776            &UpdateRequest {
777                name: "codex".to_string(),
778                source: RegistrySource::File(new_registry),
779                confirmed: true,
780                timestamp: "2026-07-06T00:00:00Z".to_string(),
781            },
782        )
783        .expect("update");
784
785        assert_eq!(outcome.agent_version, "1.2.0");
786
787        let config = fs::read_to_string(temp.path().join(".thndrs/config.toml")).expect("config");
788        assert!(config.contains("@agentclientprotocol/codex-acp@1.2.0"));
789        assert!(!config.contains("@agentclientprotocol/codex-acp@1.1.0"));
790
791        let metadata = fs::read_to_string(temp.path().join(".thndrs/acp-installed.toml")).expect("metadata");
792        assert!(metadata.contains("agent_version = \"1.2.0\""));
793        assert!(metadata.contains("installed_at = \"2026-07-06T00:00:00Z\""));
794    }
795
796    #[test]
797    fn registry_install_rejects_unmanaged_existing_agent() {
798        let temp = tempfile::tempdir().expect("temp dir");
799        fs::create_dir_all(temp.path().join(".thndrs")).expect("config dir");
800        fs::write(
801            temp.path().join(".thndrs/config.toml"),
802            "[acp_agents.codex]\ncommand = \"manual\"\n",
803        )
804        .expect("write config");
805        let registry_path = write_registry(temp.path(), "1.1.0", "pkg@1.1.0", None);
806
807        let err = install(
808            temp.path(),
809            &InstallRequest {
810                agent_id: "codex-acp".to_string(),
811                name: Some("codex".to_string()),
812                source: RegistrySource::File(registry_path),
813                confirmed: true,
814                timestamp: "2026-07-05T00:00:00Z".to_string(),
815            },
816        )
817        .expect_err("unmanaged existing agent should fail");
818
819        assert!(err.contains("not managed by registry install"));
820    }
821
822    #[test]
823    fn registry_install_rejects_binary_only_agent() {
824        let temp = tempfile::tempdir().expect("temp dir");
825        let registry_path = temp.path().join("binary-registry.json");
826        fs::write(
827            &registry_path,
828            r#"{
829                "version": "1.0.0",
830                "agents": [{
831                    "id": "bin-agent",
832                    "name": "Binary Agent",
833                    "version": "1.0.0",
834                    "description": "binary only",
835                    "distribution": {"binary": {"linux-x86_64": {"archive": "https://example.test/a.tar.gz", "cmd": "./a"}}}
836                }]
837            }"#,
838        )
839        .expect("write registry");
840
841        let err = install(
842            temp.path(),
843            &InstallRequest {
844                agent_id: "bin-agent".to_string(),
845                name: None,
846                source: RegistrySource::File(registry_path),
847                confirmed: true,
848                timestamp: "2026-07-05T00:00:00Z".to_string(),
849            },
850        )
851        .expect_err("binary-only should fail closed");
852
853        assert!(err.contains("no supported install distribution"));
854    }
855
856    fn write_registry(root: &Path, version: &str, package: &str, suffix: Option<&str>) -> PathBuf {
857        let filename = suffix.map_or_else(
858            || "registry.json".to_string(),
859            |suffix| format!("registry-{suffix}.json"),
860        );
861        let path = root.join(filename);
862        fs::write(
863            &path,
864            format!(
865                r#"{{
866                    "version": "1.0.0",
867                    "agents": [{{
868                        "id": "codex-acp",
869                        "name": "Codex",
870                        "version": "{version}",
871                        "description": "ACP adapter for OpenAI",
872                        "repository": "https://github.com/agentclientprotocol/codex-acp",
873                        "distribution": {{
874                            "npx": {{
875                                "package": "{package}",
876                                "args": ["--acp"],
877                                "env": {{"OPENAI_API_KEY": "sk-secret"}}
878                            }}
879                        }}
880                    }}]
881                }}"#
882            ),
883        )
884        .expect("write registry");
885        path
886    }
887}