Skip to main content

lean_ctx/core/addons/
manifest.rs

1//! The `lean-ctx-addon.toml` manifest — the contract an addon author writes.
2//!
3//! The same shape is reused as a registry entry (see [`super::registry`]) so a
4//! curated catalog and a hand-written manifest deserialize into one type. An
5//! addon declares metadata (`[addon]`) and how lean-ctx runs its MCP server
6//! (`[mcp]`). A registry entry without a runnable `[mcp]` block is *listed*
7//! only (a directory entry that links to its homepage) — never installable
8//! with fabricated wiring.
9
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::path::Path;
13
14use super::bootstrap::AddonInstall;
15use super::capabilities::AddonCapabilities;
16use crate::core::mcp_catalog::{GatewayServer, TransportKind};
17
18/// `[addon]` — human + catalog metadata.
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20#[serde(default)]
21pub struct AddonMeta {
22    /// Stable slug (`[a-z0-9-]`); becomes the gateway server name.
23    pub name: String,
24    /// Human-friendly name for UIs (falls back to `name`).
25    pub display_name: String,
26    /// Author-declared version (free-form; may be empty for listed-only entries).
27    pub version: String,
28    /// One-line description shown in `addon list` / the website.
29    pub description: String,
30    /// Maintainer / org.
31    pub author: String,
32    /// Project homepage or repository URL.
33    pub homepage: String,
34    /// SPDX license id (e.g. `Apache-2.0`).
35    pub license: String,
36    /// Coarse buckets for browsing (e.g. `plans`, `workflow`, `search`).
37    pub categories: Vec<String>,
38    /// Typed-integration adapter for the gateway output pipeline (#1096, L4).
39    /// Empty = derive from [`Self::categories`]. An explicit value forces a
40    /// specific adapter: `codebase-pack` | `code-graph` | `code-symbols` |
41    /// `memory` | `compression` | `none`. Recorded into the installed
42    /// `[[gateway.servers]]` entry so the proxy can route output without a
43    /// catalog lookup on the hot path.
44    pub integration: String,
45    /// Free-form search keywords.
46    pub keywords: Vec<String>,
47    /// Minimum lean-ctx version the addon targets (informational).
48    pub min_lean_ctx: String,
49    /// Trust tier. `true` **only** for entries audited and vouched by
50    /// maintainers in the curated registry; community submissions stay `false`.
51    /// Author-set in a local manifest is meaningless — trust is conferred by the
52    /// registry the entry ships in, not by the entry claiming it.
53    pub verified: bool,
54}
55
56/// `[mcp]` — how lean-ctx launches/connects to the addon's MCP server.
57///
58/// Mirrors [`GatewayServer`]'s transport fields so installation is a direct
59/// translation. Absent (default) → the entry is listed-only, not installable.
60#[derive(Debug, Clone, Default, Serialize, Deserialize)]
61#[serde(default)]
62pub struct AddonMcp {
63    /// `stdio` (spawn `command`) or `http` (connect to `url`).
64    pub transport: TransportKind,
65    /// Executable to spawn (stdio transport).
66    pub command: String,
67    /// Arguments passed to `command`.
68    pub args: Vec<String>,
69    /// Extra environment variables for the child process.
70    pub env: BTreeMap<String, String>,
71    /// Optional SHA-256 pin of the stdio `command` binary (P3 supply-chain). The
72    /// value `sha256sum`/`shasum -a 256` prints; the gateway refuses to spawn a
73    /// binary whose hash does not match. Empty = unpinned.
74    pub sha256: String,
75    /// Streamable-HTTP endpoint (http transport).
76    pub url: String,
77    /// Extra request headers (e.g. auth) for the http transport.
78    pub headers: BTreeMap<String, String>,
79}
80
81/// A full addon manifest / registry entry.
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct AddonManifest {
84    pub addon: AddonMeta,
85    #[serde(default)]
86    pub mcp: AddonMcp,
87    /// `[capabilities]` — declared permissions (network/filesystem/env). Absent
88    /// (`None`) keeps the legacy `addons.sandbox` behaviour; present opts the
89    /// addon into the per-addon, secure-by-default capability model (P1).
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub capabilities: Option<AddonCapabilities>,
92    /// `[pricing]` — optional commerce metadata for a sellable addon (Track B).
93    /// Absent (`None`) ⇒ free. A paid entry must clear
94    /// [`super::commerce::paid_listing_gate`] before it may be listed/sold.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub pricing: Option<super::commerce::AddonPricing>,
97    /// `[install]` — optional bootstrap: provision the addon's upstream package
98    /// via a pinned package manager on `add` (#1105, Phase 2). Absent (empty) ⇒
99    /// the `[mcp]` command is expected to be runnable already (an installed
100    /// binary or an ephemeral `npx`/`uvx` runner).
101    #[serde(default, skip_serializing_if = "AddonInstall::is_absent")]
102    pub install: AddonInstall,
103    /// `[artifacts]` — optional prebuilt binaries keyed by Rust target triple
104    /// (GH #724/#725, Phase 1). When the current platform has an entry, `add`
105    /// downloads it into the managed bin dir (never `PATH`), pins its SHA-256
106    /// as the spawn-time binhash, and rewrites the gateway command to the
107    /// absolute managed path. Resolution order: `artifacts` → `[install]`
108    /// bootstrap → `[mcp] command` on `PATH`.
109    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
110    pub artifacts: BTreeMap<String, super::artifact_install::ArtifactAsset>,
111    /// `[[dependencies]]` — context packages this addon needs at runtime
112    /// (depth-1, GH #727). Forwarded verbatim into the published pack's
113    /// `PackageManifest.dependencies`, where the existing resolver consumes it.
114    /// A `{pack_dir:@ns/name}` placeholder in `[mcp.env]` may only name a
115    /// non-optional dependency declared here.
116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
117    pub dependencies: Vec<crate::core::context_package::manifest::PackageDependency>,
118}
119
120impl AddonManifest {
121    /// Parse a manifest from TOML text (author's `lean-ctx-addon.toml`).
122    pub fn from_toml(text: &str) -> Result<Self, String> {
123        toml::from_str(text).map_err(|e| format!("invalid addon manifest: {e}"))
124    }
125
126    /// Read + parse + validate a manifest file from disk.
127    pub fn from_path(path: &Path) -> Result<Self, String> {
128        let raw = std::fs::read_to_string(path)
129            .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
130        let manifest = Self::from_toml(&raw)?;
131        manifest.validate()?;
132        Ok(manifest)
133    }
134
135    /// Resolve the typed-integration adapter kind for this addon: the explicit
136    /// `addon.integration` if set, otherwise derived from `addon.categories`.
137    /// Returns the canonical adapter slug (or empty for none).
138    pub fn integration_kind(&self) -> String {
139        use crate::core::mcp_catalog::adapters::IntegrationKind;
140        let explicit = self.addon.integration.trim();
141        let kind = if explicit.is_empty() {
142            IntegrationKind::from_categories(&self.addon.categories)
143        } else {
144            IntegrationKind::parse(explicit)
145        };
146        kind.as_str().to_string()
147    }
148
149    /// Human name for display (falls back to the slug).
150    pub fn display_name(&self) -> &str {
151        if self.addon.display_name.trim().is_empty() {
152            &self.addon.name
153        } else {
154            &self.addon.display_name
155        }
156    }
157
158    /// Validate required metadata. Does **not** require a runnable `[mcp]`
159    /// block — that is [`Self::is_installable`].
160    pub fn validate(&self) -> Result<(), String> {
161        let name = self.addon.name.trim();
162        if name.is_empty() {
163            return Err("addon manifest is missing `addon.name`".into());
164        }
165        if !is_slug(name) {
166            return Err(format!(
167                "addon name `{name}` must be a slug (lowercase letters, digits and dashes, \
168                 no leading/trailing dash)"
169            ));
170        }
171        if let Some(caps) = &self.capabilities {
172            caps.validate()?;
173        }
174        self.install.validate()?;
175        for (triple, asset) in &self.artifacts {
176            if asset.filename.trim().is_empty() {
177                return Err(format!(
178                    "addon `{name}` artifact for `{triple}` is missing `filename`"
179                ));
180            }
181            if asset.url.trim().is_empty() {
182                return Err(format!(
183                    "addon `{name}` artifact for `{triple}` is missing `url`"
184                ));
185            }
186            if asset.sha256.trim().is_empty() {
187                return Err(format!(
188                    "addon `{name}` artifact for `{triple}` is missing `sha256` — a managed \
189                     binary must be pinned"
190                ));
191            }
192        }
193        for dep in &self.dependencies {
194            if crate::core::context_package::remote::parse_remote_ref(&dep.name).is_none() {
195                return Err(format!(
196                    "addon `{name}` dependency `{}` must be a scoped `@ns/name` reference",
197                    dep.name
198                ));
199            }
200            crate::core::context_package::deps::parse_version_req(&dep.version_req)
201                .map_err(|e| format!("addon `{name}` dependency `{}`: {e}", dep.name))?;
202        }
203
204        // A `{pack_dir:…}` placeholder may only name a declared, non-optional
205        // dependency: `addon add` never resolves optional deps, so the placeholder
206        // could never expand. Both are manifest-parse errors, never install-time ones.
207        for (key, value) in &self.mcp.env {
208            let refs = super::pack_env::referenced_packs(value)
209                .map_err(|e| format!("addon `{name}` [mcp.env] `{key}`: {e}"))?;
210            for pack in refs {
211                let Some(dep) = self.dependencies.iter().find(|d| d.name == pack) else {
212                    return Err(format!(
213                        "addon `{name}` [mcp.env] `{key}`: `{{pack_dir:{pack}}}` names a pack that is \
214                         not declared in [[dependencies]]"
215                    ));
216                };
217                if dep.optional {
218                    return Err(format!(
219                        "addon `{name}` [mcp.env] `{key}`: `{{pack_dir:{pack}}}` refers to an optional \
220                         dependency — optional dependencies are never resolved, so the placeholder \
221                         could never expand"
222                    ));
223                }
224            }
225        }
226        Ok(())
227    }
228
229    /// The prebuilt artifact for the running platform, if this addon ships one.
230    pub fn artifact_for_current_platform(&self) -> Option<&super::artifact_install::ArtifactAsset> {
231        self.artifacts
232            .get(super::artifact_install::current_target_triple())
233    }
234
235    /// The gateway server entry this addon installs.
236    pub fn to_gateway_server(&self) -> GatewayServer {
237        GatewayServer {
238            name: self.addon.name.clone(),
239            transport: self.mcp.transport,
240            enabled: true,
241            command: self.mcp.command.clone(),
242            args: self.mcp.args.clone(),
243            env: self.mcp.env.clone(),
244            secret_env: BTreeMap::new(),
245            binary_sha256: self.mcp.sha256.clone(),
246            url: self.mcp.url.clone(),
247            headers: self.mcp.headers.clone(),
248            secret_headers: BTreeMap::new(),
249            capabilities: self.capabilities.clone(),
250            // L4 routing: resolved from the explicit manifest field or derived
251            // from the addon's categories (#1096). Empty = generic L1-L3 only.
252            integration: self.integration_kind(),
253        }
254    }
255
256    /// True when the addon declares a runnable MCP endpoint (one-click
257    /// installable). A registry entry without a valid `[mcp]` block is *listed*
258    /// only and reports `false` here.
259    pub fn is_installable(&self) -> bool {
260        self.to_gateway_server().resolve().is_ok()
261    }
262}
263
264fn is_slug(s: &str) -> bool {
265    !s.is_empty()
266        && !s.starts_with('-')
267        && !s.ends_with('-')
268        && s.chars()
269            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    fn stdio_manifest() -> AddonManifest {
277        AddonManifest::from_toml(
278            r#"
279[addon]
280name = "demo"
281display_name = "Demo Addon"
282version = "1.2.3"
283description = "A demo"
284author = "tester"
285categories = ["search"]
286keywords = ["alpha", "beta"]
287
288[mcp]
289transport = "stdio"
290command = "demo-mcp"
291args = ["serve"]
292"#,
293        )
294        .expect("parse")
295    }
296
297    #[test]
298    fn parses_full_stdio_manifest() {
299        let m = stdio_manifest();
300        assert_eq!(m.addon.name, "demo");
301        assert_eq!(m.display_name(), "Demo Addon");
302        assert_eq!(m.mcp.transport, TransportKind::Stdio);
303        assert_eq!(m.mcp.command, "demo-mcp");
304        assert!(m.is_installable());
305        let srv = m.to_gateway_server();
306        assert_eq!(srv.name, "demo");
307        assert_eq!(srv.args, vec!["serve".to_string()]);
308        assert!(srv.enabled);
309    }
310
311    #[test]
312    fn listed_only_entry_is_not_installable() {
313        let m = AddonManifest::from_toml(
314            r#"
315[addon]
316name = "listed"
317description = "no mcp block"
318homepage = "https://example.com"
319"#,
320        )
321        .expect("parse");
322        assert!(m.validate().is_ok());
323        assert!(!m.is_installable(), "no [mcp] block → listed only");
324    }
325
326    #[test]
327    fn http_manifest_is_installable() {
328        let m = AddonManifest::from_toml(
329            r#"
330[addon]
331name = "remote"
332
333[mcp]
334transport = "http"
335url = "https://example.com/mcp"
336"#,
337        )
338        .expect("parse");
339        assert!(m.is_installable());
340        assert_eq!(m.to_gateway_server().transport, TransportKind::Http);
341    }
342
343    #[test]
344    fn display_name_falls_back_to_slug() {
345        let m = AddonManifest::from_toml("[addon]\nname = \"slug-only\"\n").expect("parse");
346        assert_eq!(m.display_name(), "slug-only");
347    }
348
349    #[test]
350    fn capabilities_block_parses_and_threads_to_gateway() {
351        let m = AddonManifest::from_toml(
352            r#"
353[addon]
354name = "caps"
355
356[mcp]
357transport = "stdio"
358command = "caps-mcp"
359
360[capabilities]
361network = "full"
362filesystem = "read_write"
363env = ["GITHUB_TOKEN"]
364"#,
365        )
366        .expect("parse");
367        let caps = m.capabilities.as_ref().expect("capabilities present");
368        assert!(caps.network_allowed());
369        assert!(caps.filesystem_writable());
370        assert_eq!(caps.env, vec!["GITHUB_TOKEN".to_string()]);
371        // Flows into the gateway server entry that actually runs.
372        assert_eq!(m.to_gateway_server().capabilities, m.capabilities);
373    }
374
375    #[test]
376    fn absent_capabilities_is_none() {
377        let m = stdio_manifest();
378        assert!(m.capabilities.is_none(), "no [capabilities] → legacy path");
379        assert!(m.to_gateway_server().capabilities.is_none());
380    }
381
382    #[test]
383    fn invalid_capability_env_name_fails_validation() {
384        let m = AddonManifest::from_toml(
385            "[addon]\nname = \"bad\"\n[capabilities]\nenv = [\"bad name\"]\n",
386        )
387        .expect("parse");
388        assert!(m.validate().is_err());
389    }
390
391    #[test]
392    fn rejects_missing_and_bad_names() {
393        assert!(AddonManifest::default().validate().is_err());
394        let bad = AddonManifest::from_toml("[addon]\nname = \"Bad Name\"\n").expect("parse");
395        assert!(bad.validate().is_err());
396        let bad2 = AddonManifest::from_toml("[addon]\nname = \"-lead\"\n").expect("parse");
397        assert!(bad2.validate().is_err());
398    }
399
400    #[test]
401    fn install_block_parses_validates_and_records_receipt() {
402        let m = AddonManifest::from_toml(
403            r#"
404[addon]
405name = "boot"
406
407[mcp]
408transport = "stdio"
409command = "boot"
410args = ["serve"]
411
412[install]
413manager = "uv"
414package = "boot-ai[mcp]"
415version = "1.4.2"
416bin = "boot"
417"#,
418        )
419        .expect("parse");
420        assert!(m.install.is_declared());
421        assert!(m.validate().is_ok());
422        assert!(m.is_installable(), "an installed-binary command resolves");
423        let receipt = m.install.to_receipt();
424        assert_eq!(receipt.manager, "uv");
425        assert_eq!(receipt.bin, "boot");
426        assert_eq!(
427            m.install.install_argv(),
428            ["tool", "install", "boot-ai[mcp]==1.4.2"]
429        );
430    }
431
432    #[test]
433    fn install_block_with_bad_pin_fails_manifest_validation() {
434        let m = AddonManifest::from_toml(
435            "[addon]\nname = \"boot\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"boot\"\n\
436             [install]\nmanager = \"uv\"\npackage = \"boot\"\nversion = \"latest\"\n",
437        )
438        .expect("parse");
439        assert!(m.validate().is_err(), "floating version is rejected");
440    }
441
442    #[test]
443    fn absent_install_block_is_default() {
444        let m = stdio_manifest();
445        assert!(!m.install.is_declared(), "no [install] → no bootstrap");
446    }
447
448    #[test]
449    fn slug_validation() {
450        assert!(is_slug("lmd"));
451        assert!(is_slug("my-addon-2"));
452        assert!(!is_slug("Bad"));
453        assert!(!is_slug("-x"));
454        assert!(!is_slug("x-"));
455        assert!(!is_slug("under_score"));
456        assert!(!is_slug(""));
457    }
458
459    // ── [artifacts] — managed prebuilt binaries (GH #724/#725) ──
460
461    fn artifacts_manifest() -> AddonManifest {
462        AddonManifest::from_toml(
463            r#"
464[addon]
465name = "lean-md"
466version = "0.2.0"
467
468[mcp]
469transport = "stdio"
470command = "lean-md"
471args = ["mcp"]
472
473[artifacts.aarch64-apple-darwin]
474filename = "lean-md-aarch64-apple-darwin"
475url = "https://github.com/dasTholo/lean-md/releases/download/v0.2.0/lean-md-aarch64-apple-darwin"
476sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
477
478[artifacts.x86_64-unknown-linux-gnu]
479filename = "lean-md-x86_64-unknown-linux-gnu"
480url = "https://github.com/dasTholo/lean-md/releases/download/v0.2.0/lean-md-x86_64-unknown-linux-gnu"
481sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
482"#,
483        )
484        .expect("parse")
485    }
486
487    #[test]
488    fn artifacts_block_parses_and_validates() {
489        let m = artifacts_manifest();
490        assert!(m.validate().is_ok());
491        assert_eq!(m.artifacts.len(), 2);
492        let asset = &m.artifacts["aarch64-apple-darwin"];
493        assert_eq!(asset.filename, "lean-md-aarch64-apple-darwin");
494        assert_eq!(asset.sha256, "a".repeat(64));
495    }
496
497    #[test]
498    fn unpinned_artifact_fails_validation() {
499        let mut m = artifacts_manifest();
500        m.artifacts.get_mut("aarch64-apple-darwin").unwrap().sha256 = String::new();
501        let err = m.validate().unwrap_err();
502        assert!(err.contains("sha256"), "got: {err}");
503    }
504
505    #[test]
506    fn artifact_missing_url_fails_validation() {
507        let mut m = artifacts_manifest();
508        m.artifacts.get_mut("aarch64-apple-darwin").unwrap().url = String::new();
509        let err = m.validate().unwrap_err();
510        assert!(err.contains("url"), "got: {err}");
511    }
512
513    #[test]
514    fn artifact_for_current_platform_resolves_by_triple() {
515        let m = artifacts_manifest();
516        let triple = super::super::artifact_install::current_target_triple();
517        assert_eq!(
518            m.artifact_for_current_platform().is_some(),
519            m.artifacts.contains_key(triple)
520        );
521    }
522
523    /// Manifests without `[artifacts]` (all pre-#725 entries) parse, validate
524    /// and serialize exactly as before — the field is additive-only.
525    #[test]
526    fn absent_artifacts_is_empty_and_not_serialized() {
527        let m = stdio_manifest();
528        assert!(m.artifacts.is_empty());
529        let toml = toml::to_string(&m).expect("serialize");
530        assert!(!toml.contains("[artifacts"), "got: {toml}");
531    }
532
533    // ── [[dependencies]] — depth-1 pack dependencies (GH #727) ──
534
535    const DEP_MANIFEST: &str = r#"
536[addon]
537name = "demo"
538version = "0.2.0"
539
540[mcp]
541command = "demo-bin"
542
543[mcp.env]
544LEAN_MD_SKILLS_DIR = "{pack_dir:@dasTholo/lean-md-skills}"
545
546[[dependencies]]
547name = "@dasTholo/lean-md-skills"
548version_req = "^0.2"
549"#;
550
551    #[test]
552    fn dependencies_parse_and_validate() {
553        let m = AddonManifest::from_toml(DEP_MANIFEST).expect("parses");
554        assert_eq!(m.dependencies.len(), 1);
555        assert_eq!(m.dependencies[0].name, "@dasTholo/lean-md-skills");
556        assert_eq!(m.dependencies[0].version_req, "^0.2");
557        assert!(!m.dependencies[0].optional);
558        m.validate().expect("valid");
559    }
560
561    #[test]
562    fn unscoped_dependency_name_is_rejected() {
563        let toml = DEP_MANIFEST.replace("@dasTholo/lean-md-skills", "lean-md-skills");
564        let err = AddonManifest::from_toml(&toml)
565            .expect("parses")
566            .validate()
567            .expect_err("unscoped");
568        assert!(err.contains("scoped `@ns/name`"), "{err}");
569    }
570
571    #[test]
572    fn bad_semver_range_is_rejected() {
573        let toml =
574            DEP_MANIFEST.replace(r#"version_req = "^0.2""#, r#"version_req = "not-a-range""#);
575        let err = AddonManifest::from_toml(&toml)
576            .expect("parses")
577            .validate()
578            .expect_err("bad range");
579        assert!(err.contains("invalid version range"), "{err}");
580    }
581
582    #[test]
583    fn optional_dependency_behind_a_placeholder_is_rejected() {
584        let toml = format!("{DEP_MANIFEST}optional = true\n");
585        let err = AddonManifest::from_toml(&toml)
586            .expect("parses")
587            .validate()
588            .expect_err("optional + placeholder");
589        assert!(err.contains("optional dependency"), "{err}");
590    }
591
592    #[test]
593    fn placeholder_naming_an_undeclared_pack_is_rejected() {
594        let toml = DEP_MANIFEST.replace(
595            "{pack_dir:@dasTholo/lean-md-skills}",
596            "{pack_dir:@dasTholo/other}",
597        );
598        let err = AddonManifest::from_toml(&toml)
599            .expect("parses")
600            .validate()
601            .expect_err("undeclared pack");
602        assert!(err.contains("not declared in [[dependencies]]"), "{err}");
603    }
604
605    #[test]
606    fn unknown_placeholder_scheme_is_rejected() {
607        let toml = DEP_MANIFEST.replace("pack_dir:", "bin_dir:");
608        let err = AddonManifest::from_toml(&toml)
609            .expect("parses")
610            .validate()
611            .expect_err("unknown scheme");
612        assert!(err.contains("unknown placeholder"), "{err}");
613    }
614
615    /// Characterization of [`crate::core::addons::pack_env::expand_pack_env`]
616    /// (GH #727): a `{pack_dir:@ns/name}` placeholder resolves against the
617    /// resolved-dependency slice built from `AddonManifest::dependencies`,
618    /// yielding the versioned on-disk store path. This asserts the *expansion*
619    /// only — it hand-builds the `ResolvedDep` slice and does not exercise
620    /// `cmd_add`'s resolve/install wiring.
621    ///
622    /// The self-dependency guard on the addon path (Finding A) is covered
623    /// elsewhere, not here: the root-reference derivation by
624    /// `cli::addon_cmd::addon_self_ref` (unit-tested in that module) and the
625    /// scoped-vs-bare refusal by
626    /// `context_package::deps::addon_scoped_self_dependency_is_refused`. The
627    /// end-to-end install path stays network-bound and is plan-forbidden as a
628    /// live-registry integration test.
629    #[test]
630    fn expand_pack_env_maps_declared_dependency_to_pack_dir() {
631        use crate::core::context_package::deps::ResolvedDep;
632        use crate::core::context_package::remote::parse_remote_ref;
633
634        let m = AddonManifest::from_toml(DEP_MANIFEST).expect("parses");
635        m.validate().expect("valid");
636
637        // Simulate the slice the install step produces from `manifest.dependencies`.
638        let resolved: Vec<ResolvedDep> = m
639            .dependencies
640            .iter()
641            .map(|d| {
642                let r = parse_remote_ref(&d.name).expect("scoped");
643                ResolvedDep {
644                    name: d.name.clone(),
645                    namespace: r.namespace,
646                    slug: r.name,
647                    version: "0.2.0".into(),
648                    artifact_sha256: "a".repeat(64),
649                }
650            })
651            .collect();
652
653        let out = crate::core::addons::pack_env::expand_pack_env(
654            &m.mcp.env,
655            &resolved,
656            std::path::Path::new("/store"),
657        )
658        .expect("expands against manifest.dependencies");
659        // Segment names are explicit here (only the separator comes from the
660        // platform): `Path::join` is production's own separator, so this
661        // asserts the real invariant instead of duplicating the code under test.
662        let expected = std::path::Path::new("/store")
663            .join("skills")
664            .join("@dasTholo__lean-md-skills")
665            .join("0.2.0")
666            .display()
667            .to_string();
668        assert_eq!(out["LEAN_MD_SKILLS_DIR"], expected);
669    }
670}