1use 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
14pub 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#[derive(Clone, Debug, Eq, PartialEq)]
78pub struct InstallRequest {
79 pub agent_id: String,
81 pub name: Option<String>,
83 pub source: RegistrySource,
85 pub confirmed: bool,
87 pub timestamp: String,
89}
90
91#[derive(Clone, Debug, Eq, PartialEq)]
93pub struct UpdateRequest {
94 pub name: String,
96 pub source: RegistrySource,
98 pub confirmed: bool,
100 pub timestamp: String,
102}
103
104#[derive(Clone, Debug, Eq, PartialEq)]
106pub enum RegistrySource {
107 Official,
109 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#[derive(Clone, Debug, Eq, PartialEq)]
124pub struct InstallOutcome {
125 pub name: String,
127 pub agent_id: String,
129 pub agent_version: String,
131 pub config_path: PathBuf,
133 pub metadata_path: PathBuf,
135 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#[derive(Clone, Debug, Eq, PartialEq)]
164pub struct RegistryAgentView {
165 pub id: String,
167 pub name: String,
169 pub version: String,
171 pub description: String,
173 pub distributions: Vec<String>,
175 pub homepage: Option<String>,
177}
178
179#[derive(Clone, Debug, Eq, PartialEq)]
181pub struct RegistryView {
182 pub version: String,
184 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
214pub fn fetch_official() -> Result<RegistryView, String> {
216 parse(&read_source(&RegistrySource::Official)?)
217}
218
219pub fn read_file(path: &Path) -> Result<RegistryView, String> {
221 parse(&read_source(&RegistrySource::File(path.to_path_buf()))?)
222}
223
224pub 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
243pub 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
301pub 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 ®istry_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}