1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::path::{Path, PathBuf};
5
6use super::content::PackageContent;
7use super::manifest::PackageManifest;
8
9const INDEX_FILE: &str = "package-index.json";
10const PACKAGES_DIR: &str = "packages";
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct PackageIndex {
14 pub schema_version: u32,
15 pub updated_at: DateTime<Utc>,
16 pub entries: Vec<PackageEntry>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct PackageEntry {
21 pub name: String,
22 pub version: String,
23 pub description: String,
24 pub installed_at: DateTime<Utc>,
25 pub layers: Vec<String>,
26 pub sha256: String,
27 pub byte_size: u64,
28 #[serde(default)]
29 pub tags: Vec<String>,
30 #[serde(default)]
31 pub auto_load: bool,
32}
33
34impl PackageIndex {
35 fn new() -> Self {
36 Self {
37 schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
38 updated_at: Utc::now(),
39 entries: Vec::new(),
40 }
41 }
42}
43
44pub struct LocalRegistry {
45 root: PathBuf,
46}
47
48impl LocalRegistry {
49 pub fn open() -> Result<Self, String> {
50 let data_dir = crate::core::data_dir::lean_ctx_data_dir()?;
51 let root = data_dir.join(PACKAGES_DIR);
52 std::fs::create_dir_all(&root).map_err(|e| format!("create packages dir: {e}"))?;
53 Ok(Self { root })
54 }
55
56 pub fn open_at(root: &Path) -> Result<Self, String> {
57 std::fs::create_dir_all(root).map_err(|e| format!("create packages dir: {e}"))?;
58 Ok(Self {
59 root: root.to_path_buf(),
60 })
61 }
62
63 pub fn root(&self) -> &Path {
64 &self.root
65 }
66
67 pub fn install(
68 &self,
69 manifest: &PackageManifest,
70 content: &PackageContent,
71 ) -> Result<PathBuf, String> {
72 manifest.validate().map_err(|errs| errs.join("; "))?;
73
74 if manifest.kind == super::manifest::PackageKind::Skills {
78 let docs = content
79 .documents
80 .as_ref()
81 .ok_or("kind=skills package has no documents payload")?;
82 let root = super::skills::materialize_documents(&self.root, manifest, docs)?;
83 tracing::info!(
84 "skills pack {}@{} materialized at {}",
85 manifest.name,
86 manifest.version,
87 root.display()
88 );
89 }
90
91 let pkg_dir = self.package_dir(&manifest.name, &manifest.version);
92 std::fs::create_dir_all(&pkg_dir).map_err(|e| format!("create package dir: {e}"))?;
93
94 let manifest_json = serde_json::to_string_pretty(manifest).map_err(|e| e.to_string())?;
95 atomic_write(&pkg_dir.join("manifest.json"), manifest_json.as_bytes())?;
96
97 let content_json = serde_json::to_string_pretty(content).map_err(|e| e.to_string())?;
98 atomic_write(&pkg_dir.join("content.json"), content_json.as_bytes())?;
99
100 let mut index = self.load_index()?;
101 index
102 .entries
103 .retain(|e| !(e.name == manifest.name && e.version == manifest.version));
104 index.entries.push(PackageEntry {
105 name: manifest.name.clone(),
106 version: manifest.version.clone(),
107 description: manifest.description.clone(),
108 installed_at: Utc::now(),
109 layers: manifest
110 .layers
111 .iter()
112 .map(|l| l.as_str().to_string())
113 .collect(),
114 sha256: manifest.integrity.sha256.clone(),
115 byte_size: manifest.integrity.byte_size,
116 tags: manifest.tags.clone(),
117 auto_load: false,
118 });
119 index.updated_at = Utc::now();
120 self.save_index(&index)?;
121
122 Ok(pkg_dir)
123 }
124
125 pub fn remove(&self, name: &str, version: Option<&str>) -> Result<u32, String> {
126 let mut index = self.load_index()?;
127 let before = index.entries.len();
128
129 let to_remove: Vec<(String, String)> = index
130 .entries
131 .iter()
132 .filter(|e| e.name == name && version.is_none_or(|v| e.version == v))
133 .map(|e| (e.name.clone(), e.version.clone()))
134 .collect();
135
136 for (n, v) in &to_remove {
137 let dir = self.package_dir(n, v);
138 if dir.exists() {
139 let _ = std::fs::remove_dir_all(&dir);
140 }
141 let skills = super::skills::skills_dir(&self.root, n, v);
144 if skills.exists() {
145 let _ = std::fs::remove_dir_all(&skills);
146 }
147 }
148
149 index.entries.retain(|e| {
150 !to_remove
151 .iter()
152 .any(|(n, v)| e.name == *n && e.version == *v)
153 });
154
155 let removed = (before - index.entries.len()) as u32;
156 if removed > 0 {
157 index.updated_at = Utc::now();
158 self.save_index(&index)?;
159 }
160
161 Ok(removed)
162 }
163
164 pub fn list(&self) -> Result<Vec<PackageEntry>, String> {
165 let index = self.load_index()?;
166 Ok(index.entries)
167 }
168
169 pub fn get(&self, name: &str, version: Option<&str>) -> Result<Option<PackageEntry>, String> {
170 let index = self.load_index()?;
171 Ok(index
172 .entries
173 .into_iter()
174 .find(|e| e.name == name && version.is_none_or(|v| e.version == v)))
175 }
176
177 pub fn load_package(
178 &self,
179 name: &str,
180 version: &str,
181 ) -> Result<(PackageManifest, PackageContent), String> {
182 let pkg_dir = self.package_dir(name, version);
183 if !pkg_dir.exists() {
184 return Err(format!("package {name}@{version} not found"));
185 }
186
187 let manifest_json = std::fs::read_to_string(pkg_dir.join("manifest.json"))
188 .map_err(|e| format!("read manifest: {e}"))?;
189 let content_json = std::fs::read_to_string(pkg_dir.join("content.json"))
190 .map_err(|e| format!("read content: {e}"))?;
191
192 let manifest: PackageManifest =
193 serde_json::from_str(&manifest_json).map_err(|e| format!("parse manifest: {e}"))?;
194 let content: PackageContent =
195 serde_json::from_str(&content_json).map_err(|e| format!("parse content: {e}"))?;
196
197 verify_integrity(&manifest, &content_json)?;
198
199 Ok((manifest, content))
200 }
201
202 pub fn set_auto_load(&self, name: &str, version: &str, auto_load: bool) -> Result<(), String> {
203 let mut index = self.load_index()?;
204 if let Some(entry) = index
205 .entries
206 .iter_mut()
207 .find(|e| e.name == name && e.version == version)
208 {
209 entry.auto_load = auto_load;
210 index.updated_at = Utc::now();
211 self.save_index(&index)?;
212 } else {
213 return Err(format!("package {name}@{version} not found in index"));
214 }
215 Ok(())
216 }
217
218 pub fn auto_load_packages(&self) -> Result<Vec<PackageEntry>, String> {
219 let index = self.load_index()?;
220 Ok(index.entries.into_iter().filter(|e| e.auto_load).collect())
221 }
222
223 pub fn export_to_file(&self, name: &str, version: &str, output: &Path) -> Result<u64, String> {
224 let (manifest, content) = self.load_package(name, version)?;
225
226 let bundle = ExportBundle { manifest, content };
227 let json = serde_json::to_string_pretty(&bundle).map_err(|e| e.to_string())?;
228 let bytes = json.as_bytes();
229
230 atomic_write(output, bytes)?;
231 Ok(bytes.len() as u64)
232 }
233
234 pub fn export_to_file_signed(
239 &self,
240 name: &str,
241 version: &str,
242 output: &Path,
243 signing_key: &ed25519_dalek::SigningKey,
244 private: bool,
245 ) -> Result<u64, String> {
246 let (mut manifest, content) = self.load_package(name, version)?;
247
248 if private {
249 manifest.visibility = Some("private".to_string());
250 }
251 super::signing::sign_package(&mut manifest, &content, signing_key);
252
253 let bundle = ExportBundle { manifest, content };
254 let json = serde_json::to_string_pretty(&bundle).map_err(|e| e.to_string())?;
255 let bytes = json.as_bytes();
256
257 atomic_write(output, bytes)?;
258 Ok(bytes.len() as u64)
259 }
260
261 pub fn import_from_file(&self, path: &Path) -> Result<PackageManifest, String> {
262 if !crate::core::contracts::is_package_file(path) {
263 let ext = path
264 .extension()
265 .and_then(|e| e.to_str())
266 .unwrap_or("(none)");
267 return Err(format!(
268 "unsupported file extension '.{ext}' — expected .{} or .{}",
269 crate::core::contracts::PACKAGE_EXTENSION,
270 crate::core::contracts::LEGACY_PACKAGE_EXTENSION,
271 ));
272 }
273
274 let meta = std::fs::metadata(path).map_err(|e| format!("stat package file: {e}"))?;
275 if meta.len() > crate::core::contracts::MAX_PACKAGE_FILE_BYTES {
276 return Err(format!(
277 "package file too large ({} bytes, max {} bytes)",
278 meta.len(),
279 crate::core::contracts::MAX_PACKAGE_FILE_BYTES,
280 ));
281 }
282
283 let json = std::fs::read_to_string(path).map_err(|e| format!("read package file: {e}"))?;
284 let bundle: ExportBundle =
285 serde_json::from_str(&json).map_err(|e| format!("parse package: {e}"))?;
286
287 bundle.manifest.validate().map_err(|errs| errs.join("; "))?;
288
289 super::verify::validate_kind_coherence(&bundle.manifest, &bundle.content)
294 .map_err(|errs| errs.join("; "))?;
295 if bundle.manifest.kind == super::manifest::PackageKind::Addon {
296 return Err(format!(
297 "`{}` is a kind=addon package — install it with `lean-ctx addon add {}` \
298 (addon installs need capability consent + the addon trust chain)",
299 bundle.manifest.name,
300 bundle.manifest.name.trim_start_matches('@'),
301 ));
302 }
303
304 let content_text = extract_top_level_value_text(&json, "content")
305 .ok_or_else(|| "package has no top-level content member".to_string())?;
306 verify_integrity(&bundle.manifest, content_text)?;
307
308 if bundle.manifest.signature.is_some()
312 && !super::signing::verify_signature(&bundle.manifest)?
313 {
314 return Err(
315 "signature verification failed — the package was modified after signing".into(),
316 );
317 }
318
319 self.install(&bundle.manifest, &bundle.content)?;
320 Ok(bundle.manifest)
321 }
322
323 fn package_dir(&self, name: &str, version: &str) -> PathBuf {
324 self.root.join(format!("{name}-{version}"))
325 }
326
327 fn load_index(&self) -> Result<PackageIndex, String> {
328 let path = self.root.join(INDEX_FILE);
329 if !path.exists() {
330 return Ok(PackageIndex::new());
331 }
332 let json = std::fs::read_to_string(&path).map_err(|e| format!("read index: {e}"))?;
333 serde_json::from_str(&json).map_err(|e| format!("parse index: {e}"))
334 }
335
336 fn save_index(&self, index: &PackageIndex) -> Result<(), String> {
337 let json = serde_json::to_string_pretty(index).map_err(|e| e.to_string())?;
338 atomic_write(&self.root.join(INDEX_FILE), json.as_bytes())
339 }
340}
341
342#[derive(Debug, Serialize, Deserialize)]
343struct ExportBundle {
344 manifest: PackageManifest,
345 content: PackageContent,
346}
347
348use super::verify::{compact_json_text, extract_top_level_value_text};
349
350fn verify_integrity(manifest: &PackageManifest, content_text: &str) -> Result<(), String> {
354 let canonical = compact_json_text(content_text);
355 let content_bytes = canonical.as_bytes();
356
357 let mut h1 = Sha256::new();
358 h1.update(content_bytes);
359 let actual_content_hash = crate::core::agent_identity::hex_encode(&h1.finalize());
360
361 if actual_content_hash != manifest.integrity.content_hash {
362 return Err(format!(
363 "integrity check failed: content_hash mismatch (expected {}, got {actual_content_hash})",
364 manifest.integrity.content_hash
365 ));
366 }
367
368 let expected_sha256 = {
369 let composite = format!(
370 "{}:{}:{actual_content_hash}",
371 manifest.name, manifest.version
372 );
373 let mut h2 = Sha256::new();
374 h2.update(composite.as_bytes());
375 crate::core::agent_identity::hex_encode(&h2.finalize())
376 };
377
378 if manifest.integrity.sha256 != expected_sha256 {
379 return Err(format!(
380 "integrity check failed: sha256 mismatch (expected {expected_sha256}, got {})",
381 manifest.integrity.sha256
382 ));
383 }
384
385 if manifest.integrity.byte_size != content_bytes.len() as u64 {
386 return Err(format!(
387 "integrity check failed: byte_size mismatch (expected {}, got {})",
388 manifest.integrity.byte_size,
389 content_bytes.len()
390 ));
391 }
392
393 Ok(())
394}
395
396fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
397 if path.exists()
398 && path
399 .symlink_metadata()
400 .is_ok_and(|m| m.file_type().is_symlink())
401 {
402 return Err(format!(
403 "refusing to write through symlink: {}",
404 path.display()
405 ));
406 }
407 let parent = path.parent().ok_or_else(|| "invalid path".to_string())?;
408 let tmp = parent.join(format!(
409 ".{}.tmp",
410 path.file_name().and_then(|s| s.to_str()).unwrap_or("pkg")
411 ));
412 std::fs::write(&tmp, data).map_err(|e| format!("write tmp: {e}"))?;
413 std::fs::rename(&tmp, path).map_err(|e| format!("rename: {e}"))?;
414 Ok(())
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use crate::core::context_package::manifest::{CompatibilitySpec, PackageStats};
421
422 #[test]
423 fn registry_round_trip() {
424 let dir = tempfile::tempdir().unwrap();
425 let reg = LocalRegistry::open_at(dir.path()).unwrap();
426
427 assert!(reg.list().unwrap().is_empty());
428
429 let manifest = PackageManifest {
430 schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
431 conformance_level: None,
432 kind: crate::core::context_package::manifest::PackageKind::default(),
433 name: "test-pkg".into(),
434 version: "1.0.0".into(),
435 description: "test".into(),
436 author: None,
437 scope: None,
438 created_at: Utc::now(),
439 updated_at: None,
440 layers: vec![super::super::manifest::PackageLayer::Knowledge],
441 dependencies: vec![],
442 tags: vec!["rust".into()],
443 visibility: None,
444 integrity: {
445 let c = PackageContent::default();
446 let j = serde_json::to_string(&c).unwrap();
447 let mut h = Sha256::new();
448 h.update(j.as_bytes());
449 let ch = crate::core::agent_identity::hex_encode(&h.finalize());
450 let composite = format!("test-pkg:1.0.0:{ch}");
451 let mut h2 = Sha256::new();
452 h2.update(composite.as_bytes());
453 let sha = crate::core::agent_identity::hex_encode(&h2.finalize());
454 super::super::manifest::PackageIntegrity {
455 sha256: sha,
456 content_hash: ch,
457 byte_size: j.len() as u64,
458 }
459 },
460 provenance: super::super::manifest::PackageProvenance {
461 tool: "lean-ctx".into(),
462 tool_version: "0.0.0".into(),
463 project_hash: None,
464 source_session_id: None,
465 },
466 compatibility: CompatibilitySpec::default(),
467 stats: PackageStats::default(),
468 signature: None,
469 graph_summary: None,
470 marketplace: None,
471 };
472
473 let content = PackageContent::default();
474
475 reg.install(&manifest, &content).unwrap();
476 let list = reg.list().unwrap();
477 assert_eq!(list.len(), 1);
478 assert_eq!(list[0].name, "test-pkg");
479
480 let (loaded_m, _loaded_c) = reg.load_package("test-pkg", "1.0.0").unwrap();
481 assert_eq!(loaded_m.name, "test-pkg");
482
483 let removed = reg.remove("test-pkg", None).unwrap();
484 assert_eq!(removed, 1);
485 assert!(reg.list().unwrap().is_empty());
486 }
487
488 #[test]
489 fn export_import_round_trip() {
490 let dir = tempfile::tempdir().unwrap();
491 let reg = LocalRegistry::open_at(dir.path()).unwrap();
492
493 let content = PackageContent::default();
494 let content_json = serde_json::to_string(&content).unwrap();
495 let mut h = Sha256::new();
496 h.update(content_json.as_bytes());
497 let content_hash = crate::core::agent_identity::hex_encode(&h.finalize());
498
499 let manifest = PackageManifest {
500 schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
501 conformance_level: None,
502 kind: crate::core::context_package::manifest::PackageKind::default(),
503 name: "export-test".into(),
504 version: "2.0.0".into(),
505 description: "round trip test".into(),
506 author: Some("test".into()),
507 scope: None,
508 created_at: Utc::now(),
509 updated_at: None,
510 layers: vec![super::super::manifest::PackageLayer::Knowledge],
511 dependencies: vec![],
512 tags: vec![],
513 visibility: None,
514 integrity: {
515 let composite = format!("export-test:2.0.0:{content_hash}");
516 let mut h2 = Sha256::new();
517 h2.update(composite.as_bytes());
518 super::super::manifest::PackageIntegrity {
519 sha256: crate::core::agent_identity::hex_encode(&h2.finalize()),
520 content_hash,
521 byte_size: content_json.len() as u64,
522 }
523 },
524 provenance: super::super::manifest::PackageProvenance {
525 tool: "lean-ctx".into(),
526 tool_version: "0.0.0".into(),
527 project_hash: None,
528 source_session_id: None,
529 },
530 compatibility: CompatibilitySpec::default(),
531 stats: PackageStats::default(),
532 signature: None,
533 graph_summary: None,
534 marketplace: None,
535 };
536
537 reg.install(&manifest, &content).unwrap();
538
539 let export_path = dir.path().join("test.ctxpkg");
540 let bytes = reg
541 .export_to_file("export-test", "2.0.0", &export_path)
542 .unwrap();
543 assert!(bytes > 0);
544
545 let reg2 = LocalRegistry::open_at(&dir.path().join("other")).unwrap();
546 let imported = reg2.import_from_file(&export_path).unwrap();
547 assert_eq!(imported.name, "export-test");
548 assert_eq!(reg2.list().unwrap().len(), 1);
549 }
550
551 #[test]
552 fn import_rejects_tampered_signature() {
553 let dir = tempfile::tempdir().unwrap();
554 let reg = LocalRegistry::open_at(dir.path()).unwrap();
555
556 let content = PackageContent::default();
557 let content_json = serde_json::to_string(&content).unwrap();
558 let mut h = Sha256::new();
559 h.update(content_json.as_bytes());
560 let content_hash = crate::core::agent_identity::hex_encode(&h.finalize());
561 let composite = format!("signed-test:1.0.0:{content_hash}");
562 let mut h2 = Sha256::new();
563 h2.update(composite.as_bytes());
564
565 let mut manifest = PackageManifest {
566 schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
567 conformance_level: None,
568 kind: crate::core::context_package::manifest::PackageKind::default(),
569 name: "signed-test".into(),
570 version: "1.0.0".into(),
571 description: "signature gate test".into(),
572 author: None,
573 scope: None,
574 created_at: Utc::now(),
575 updated_at: None,
576 layers: vec![super::super::manifest::PackageLayer::Knowledge],
577 dependencies: vec![],
578 tags: vec![],
579 visibility: None,
580 integrity: super::super::manifest::PackageIntegrity {
581 sha256: crate::core::agent_identity::hex_encode(&h2.finalize()),
582 content_hash,
583 byte_size: content_json.len() as u64,
584 },
585 provenance: super::super::manifest::PackageProvenance {
586 tool: "lean-ctx".into(),
587 tool_version: "0.0.0".into(),
588 project_hash: None,
589 source_session_id: None,
590 },
591 compatibility: CompatibilitySpec::default(),
592 stats: PackageStats::default(),
593 signature: None,
594 graph_summary: None,
595 marketplace: None,
596 };
597
598 let signing_key = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
599 super::super::signing::sign_package(&mut manifest, &content, &signing_key);
600
601 let bundle = ExportBundle {
603 manifest: manifest.clone(),
604 content: content.clone(),
605 };
606 let good = dir.path().join("good.ctxpkg");
607 std::fs::write(&good, serde_json::to_string(&bundle).unwrap()).unwrap();
608 let reg_good = LocalRegistry::open_at(&dir.path().join("good-reg")).unwrap();
609 assert!(reg_good.import_from_file(&good).is_ok());
610
611 let mut tampered = manifest.clone();
613 if let Some(sig) = tampered.signature.as_mut() {
614 sig.value = format!("0000{}", &sig.value[4..]);
615 }
616 let bundle = ExportBundle {
617 manifest: tampered,
618 content,
619 };
620 let bad = dir.path().join("bad.ctxpkg");
621 std::fs::write(&bad, serde_json::to_string(&bundle).unwrap()).unwrap();
622 let err = reg.import_from_file(&bad).unwrap_err();
623 assert!(
624 err.contains("signature verification failed"),
625 "unexpected error: {err}"
626 );
627 }
628
629 #[test]
630 fn legacy_lctxpkg_extension_accepted() {
631 let dir = tempfile::tempdir().unwrap();
632 let reg = LocalRegistry::open_at(dir.path()).unwrap();
633
634 let content = PackageContent::default();
635 let content_json = serde_json::to_string(&content).unwrap();
636 let mut h = Sha256::new();
637 h.update(content_json.as_bytes());
638 let content_hash = crate::core::agent_identity::hex_encode(&h.finalize());
639 let composite = format!("legacy-test:1.0.0:{content_hash}");
640 let mut h2 = Sha256::new();
641 h2.update(composite.as_bytes());
642
643 let manifest = PackageManifest {
644 schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
645 conformance_level: None,
646 kind: crate::core::context_package::manifest::PackageKind::default(),
647 name: "legacy-test".into(),
648 version: "1.0.0".into(),
649 description: "legacy extension test".into(),
650 author: None,
651 scope: None,
652 created_at: Utc::now(),
653 updated_at: None,
654 layers: vec![super::super::manifest::PackageLayer::Knowledge],
655 dependencies: vec![],
656 tags: vec![],
657 visibility: None,
658 integrity: super::super::manifest::PackageIntegrity {
659 sha256: crate::core::agent_identity::hex_encode(&h2.finalize()),
660 content_hash,
661 byte_size: content_json.len() as u64,
662 },
663 provenance: super::super::manifest::PackageProvenance {
664 tool: "lean-ctx".into(),
665 tool_version: "0.0.0".into(),
666 project_hash: None,
667 source_session_id: None,
668 },
669 compatibility: CompatibilitySpec::default(),
670 stats: PackageStats::default(),
671 signature: None,
672 graph_summary: None,
673 marketplace: None,
674 };
675
676 reg.install(&manifest, &content).unwrap();
677
678 let legacy_path = dir.path().join("test.lctxpkg");
679 reg.export_to_file("legacy-test", "1.0.0", &legacy_path)
680 .unwrap();
681
682 let reg2 = LocalRegistry::open_at(&dir.path().join("other")).unwrap();
683 let imported = reg2.import_from_file(&legacy_path).unwrap();
684 assert_eq!(imported.name, "legacy-test");
685 }
686
687 #[test]
688 fn unsupported_extension_rejected() {
689 let dir = tempfile::tempdir().unwrap();
690 let reg = LocalRegistry::open_at(dir.path()).unwrap();
691 let bad_path = dir.path().join("test.json");
692 std::fs::write(&bad_path, "{}").unwrap();
693 assert!(reg.import_from_file(&bad_path).is_err());
694 }
695
696 #[test]
699 fn import_refuses_addon_kind_packs() {
700 let dir = tempfile::tempdir().unwrap();
701 let toml_path = dir.path().join("lean-ctx-addon.toml");
702 std::fs::write(
703 &toml_path,
704 r#"
705[addon]
706name = "gate-check"
707version = "1.0.0"
708description = "import gate test"
709
710[mcp]
711transport = "stdio"
712command = "gate-check"
713args = ["serve"]
714"#,
715 )
716 .unwrap();
717 let plan = crate::core::addons::publish::build_addon_pack(&toml_path, "acme")
718 .expect("build addon pack");
719
720 let pkg_path = dir.path().join("gate-check.ctxpkg");
721 std::fs::write(&pkg_path, &plan.bundle_json).unwrap();
722
723 let reg = LocalRegistry::open_at(dir.path()).unwrap();
724 let err = reg.import_from_file(&pkg_path).expect_err("must refuse");
725 assert!(err.contains("addon add"), "got: {err}");
726 }
727}