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            binary_sha256: self.mcp.sha256.clone(),
245            url: self.mcp.url.clone(),
246            headers: self.mcp.headers.clone(),
247            capabilities: self.capabilities.clone(),
248            // L4 routing: resolved from the explicit manifest field or derived
249            // from the addon's categories (#1096). Empty = generic L1-L3 only.
250            integration: self.integration_kind(),
251        }
252    }
253
254    /// True when the addon declares a runnable MCP endpoint (one-click
255    /// installable). A registry entry without a valid `[mcp]` block is *listed*
256    /// only and reports `false` here.
257    pub fn is_installable(&self) -> bool {
258        self.to_gateway_server().resolve().is_ok()
259    }
260}
261
262fn is_slug(s: &str) -> bool {
263    !s.is_empty()
264        && !s.starts_with('-')
265        && !s.ends_with('-')
266        && s.chars()
267            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    fn stdio_manifest() -> AddonManifest {
275        AddonManifest::from_toml(
276            r#"
277[addon]
278name = "demo"
279display_name = "Demo Addon"
280version = "1.2.3"
281description = "A demo"
282author = "tester"
283categories = ["search"]
284keywords = ["alpha", "beta"]
285
286[mcp]
287transport = "stdio"
288command = "demo-mcp"
289args = ["serve"]
290"#,
291        )
292        .expect("parse")
293    }
294
295    #[test]
296    fn parses_full_stdio_manifest() {
297        let m = stdio_manifest();
298        assert_eq!(m.addon.name, "demo");
299        assert_eq!(m.display_name(), "Demo Addon");
300        assert_eq!(m.mcp.transport, TransportKind::Stdio);
301        assert_eq!(m.mcp.command, "demo-mcp");
302        assert!(m.is_installable());
303        let srv = m.to_gateway_server();
304        assert_eq!(srv.name, "demo");
305        assert_eq!(srv.args, vec!["serve".to_string()]);
306        assert!(srv.enabled);
307    }
308
309    #[test]
310    fn listed_only_entry_is_not_installable() {
311        let m = AddonManifest::from_toml(
312            r#"
313[addon]
314name = "listed"
315description = "no mcp block"
316homepage = "https://example.com"
317"#,
318        )
319        .expect("parse");
320        assert!(m.validate().is_ok());
321        assert!(!m.is_installable(), "no [mcp] block → listed only");
322    }
323
324    #[test]
325    fn http_manifest_is_installable() {
326        let m = AddonManifest::from_toml(
327            r#"
328[addon]
329name = "remote"
330
331[mcp]
332transport = "http"
333url = "https://example.com/mcp"
334"#,
335        )
336        .expect("parse");
337        assert!(m.is_installable());
338        assert_eq!(m.to_gateway_server().transport, TransportKind::Http);
339    }
340
341    #[test]
342    fn display_name_falls_back_to_slug() {
343        let m = AddonManifest::from_toml("[addon]\nname = \"slug-only\"\n").expect("parse");
344        assert_eq!(m.display_name(), "slug-only");
345    }
346
347    #[test]
348    fn capabilities_block_parses_and_threads_to_gateway() {
349        let m = AddonManifest::from_toml(
350            r#"
351[addon]
352name = "caps"
353
354[mcp]
355transport = "stdio"
356command = "caps-mcp"
357
358[capabilities]
359network = "full"
360filesystem = "read_write"
361env = ["GITHUB_TOKEN"]
362"#,
363        )
364        .expect("parse");
365        let caps = m.capabilities.as_ref().expect("capabilities present");
366        assert!(caps.network_allowed());
367        assert!(caps.filesystem_writable());
368        assert_eq!(caps.env, vec!["GITHUB_TOKEN".to_string()]);
369        // Flows into the gateway server entry that actually runs.
370        assert_eq!(m.to_gateway_server().capabilities, m.capabilities);
371    }
372
373    #[test]
374    fn absent_capabilities_is_none() {
375        let m = stdio_manifest();
376        assert!(m.capabilities.is_none(), "no [capabilities] → legacy path");
377        assert!(m.to_gateway_server().capabilities.is_none());
378    }
379
380    #[test]
381    fn invalid_capability_env_name_fails_validation() {
382        let m = AddonManifest::from_toml(
383            "[addon]\nname = \"bad\"\n[capabilities]\nenv = [\"bad name\"]\n",
384        )
385        .expect("parse");
386        assert!(m.validate().is_err());
387    }
388
389    #[test]
390    fn rejects_missing_and_bad_names() {
391        assert!(AddonManifest::default().validate().is_err());
392        let bad = AddonManifest::from_toml("[addon]\nname = \"Bad Name\"\n").expect("parse");
393        assert!(bad.validate().is_err());
394        let bad2 = AddonManifest::from_toml("[addon]\nname = \"-lead\"\n").expect("parse");
395        assert!(bad2.validate().is_err());
396    }
397
398    #[test]
399    fn install_block_parses_validates_and_records_receipt() {
400        let m = AddonManifest::from_toml(
401            r#"
402[addon]
403name = "boot"
404
405[mcp]
406transport = "stdio"
407command = "boot"
408args = ["serve"]
409
410[install]
411manager = "uv"
412package = "boot-ai[mcp]"
413version = "1.4.2"
414bin = "boot"
415"#,
416        )
417        .expect("parse");
418        assert!(m.install.is_declared());
419        assert!(m.validate().is_ok());
420        assert!(m.is_installable(), "an installed-binary command resolves");
421        let receipt = m.install.to_receipt();
422        assert_eq!(receipt.manager, "uv");
423        assert_eq!(receipt.bin, "boot");
424        assert_eq!(
425            m.install.install_argv(),
426            ["tool", "install", "boot-ai[mcp]==1.4.2"]
427        );
428    }
429
430    #[test]
431    fn install_block_with_bad_pin_fails_manifest_validation() {
432        let m = AddonManifest::from_toml(
433            "[addon]\nname = \"boot\"\n[mcp]\ntransport = \"stdio\"\ncommand = \"boot\"\n\
434             [install]\nmanager = \"uv\"\npackage = \"boot\"\nversion = \"latest\"\n",
435        )
436        .expect("parse");
437        assert!(m.validate().is_err(), "floating version is rejected");
438    }
439
440    #[test]
441    fn absent_install_block_is_default() {
442        let m = stdio_manifest();
443        assert!(!m.install.is_declared(), "no [install] → no bootstrap");
444    }
445
446    #[test]
447    fn slug_validation() {
448        assert!(is_slug("lmd"));
449        assert!(is_slug("my-addon-2"));
450        assert!(!is_slug("Bad"));
451        assert!(!is_slug("-x"));
452        assert!(!is_slug("x-"));
453        assert!(!is_slug("under_score"));
454        assert!(!is_slug(""));
455    }
456
457    // ── [artifacts] — managed prebuilt binaries (GH #724/#725) ──
458
459    fn artifacts_manifest() -> AddonManifest {
460        AddonManifest::from_toml(
461            r#"
462[addon]
463name = "lean-md"
464version = "0.2.0"
465
466[mcp]
467transport = "stdio"
468command = "lean-md"
469args = ["mcp"]
470
471[artifacts.aarch64-apple-darwin]
472filename = "lean-md-aarch64-apple-darwin"
473url = "https://github.com/dasTholo/lean-md/releases/download/v0.2.0/lean-md-aarch64-apple-darwin"
474sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
475
476[artifacts.x86_64-unknown-linux-gnu]
477filename = "lean-md-x86_64-unknown-linux-gnu"
478url = "https://github.com/dasTholo/lean-md/releases/download/v0.2.0/lean-md-x86_64-unknown-linux-gnu"
479sha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
480"#,
481        )
482        .expect("parse")
483    }
484
485    #[test]
486    fn artifacts_block_parses_and_validates() {
487        let m = artifacts_manifest();
488        assert!(m.validate().is_ok());
489        assert_eq!(m.artifacts.len(), 2);
490        let asset = &m.artifacts["aarch64-apple-darwin"];
491        assert_eq!(asset.filename, "lean-md-aarch64-apple-darwin");
492        assert_eq!(asset.sha256, "a".repeat(64));
493    }
494
495    #[test]
496    fn unpinned_artifact_fails_validation() {
497        let mut m = artifacts_manifest();
498        m.artifacts.get_mut("aarch64-apple-darwin").unwrap().sha256 = String::new();
499        let err = m.validate().unwrap_err();
500        assert!(err.contains("sha256"), "got: {err}");
501    }
502
503    #[test]
504    fn artifact_missing_url_fails_validation() {
505        let mut m = artifacts_manifest();
506        m.artifacts.get_mut("aarch64-apple-darwin").unwrap().url = String::new();
507        let err = m.validate().unwrap_err();
508        assert!(err.contains("url"), "got: {err}");
509    }
510
511    #[test]
512    fn artifact_for_current_platform_resolves_by_triple() {
513        let m = artifacts_manifest();
514        let triple = super::super::artifact_install::current_target_triple();
515        assert_eq!(
516            m.artifact_for_current_platform().is_some(),
517            m.artifacts.contains_key(triple)
518        );
519    }
520
521    /// Manifests without `[artifacts]` (all pre-#725 entries) parse, validate
522    /// and serialize exactly as before — the field is additive-only.
523    #[test]
524    fn absent_artifacts_is_empty_and_not_serialized() {
525        let m = stdio_manifest();
526        assert!(m.artifacts.is_empty());
527        let toml = toml::to_string(&m).expect("serialize");
528        assert!(!toml.contains("[artifacts"), "got: {toml}");
529    }
530
531    // ── [[dependencies]] — depth-1 pack dependencies (GH #727) ──
532
533    const DEP_MANIFEST: &str = r#"
534[addon]
535name = "demo"
536version = "0.2.0"
537
538[mcp]
539command = "demo-bin"
540
541[mcp.env]
542LEAN_MD_SKILLS_DIR = "{pack_dir:@dasTholo/lean-md-skills}"
543
544[[dependencies]]
545name = "@dasTholo/lean-md-skills"
546version_req = "^0.2"
547"#;
548
549    #[test]
550    fn dependencies_parse_and_validate() {
551        let m = AddonManifest::from_toml(DEP_MANIFEST).expect("parses");
552        assert_eq!(m.dependencies.len(), 1);
553        assert_eq!(m.dependencies[0].name, "@dasTholo/lean-md-skills");
554        assert_eq!(m.dependencies[0].version_req, "^0.2");
555        assert!(!m.dependencies[0].optional);
556        m.validate().expect("valid");
557    }
558
559    #[test]
560    fn unscoped_dependency_name_is_rejected() {
561        let toml = DEP_MANIFEST.replace("@dasTholo/lean-md-skills", "lean-md-skills");
562        let err = AddonManifest::from_toml(&toml)
563            .expect("parses")
564            .validate()
565            .expect_err("unscoped");
566        assert!(err.contains("scoped `@ns/name`"), "{err}");
567    }
568
569    #[test]
570    fn bad_semver_range_is_rejected() {
571        let toml =
572            DEP_MANIFEST.replace(r#"version_req = "^0.2""#, r#"version_req = "not-a-range""#);
573        let err = AddonManifest::from_toml(&toml)
574            .expect("parses")
575            .validate()
576            .expect_err("bad range");
577        assert!(err.contains("invalid version range"), "{err}");
578    }
579
580    #[test]
581    fn optional_dependency_behind_a_placeholder_is_rejected() {
582        let toml = format!("{DEP_MANIFEST}optional = true\n");
583        let err = AddonManifest::from_toml(&toml)
584            .expect("parses")
585            .validate()
586            .expect_err("optional + placeholder");
587        assert!(err.contains("optional dependency"), "{err}");
588    }
589
590    #[test]
591    fn placeholder_naming_an_undeclared_pack_is_rejected() {
592        let toml = DEP_MANIFEST.replace(
593            "{pack_dir:@dasTholo/lean-md-skills}",
594            "{pack_dir:@dasTholo/other}",
595        );
596        let err = AddonManifest::from_toml(&toml)
597            .expect("parses")
598            .validate()
599            .expect_err("undeclared pack");
600        assert!(err.contains("not declared in [[dependencies]]"), "{err}");
601    }
602
603    #[test]
604    fn unknown_placeholder_scheme_is_rejected() {
605        let toml = DEP_MANIFEST.replace("pack_dir:", "bin_dir:");
606        let err = AddonManifest::from_toml(&toml)
607            .expect("parses")
608            .validate()
609            .expect_err("unknown scheme");
610        assert!(err.contains("unknown placeholder"), "{err}");
611    }
612
613    /// Characterization of [`crate::core::addons::pack_env::expand_pack_env`]
614    /// (GH #727): a `{pack_dir:@ns/name}` placeholder resolves against the
615    /// resolved-dependency slice built from `AddonManifest::dependencies`,
616    /// yielding the versioned on-disk store path. This asserts the *expansion*
617    /// only — it hand-builds the `ResolvedDep` slice and does not exercise
618    /// `cmd_add`'s resolve/install wiring.
619    ///
620    /// The self-dependency guard on the addon path (Finding A) is covered
621    /// elsewhere, not here: the root-reference derivation by
622    /// `cli::addon_cmd::addon_self_ref` (unit-tested in that module) and the
623    /// scoped-vs-bare refusal by
624    /// `context_package::deps::addon_scoped_self_dependency_is_refused`. The
625    /// end-to-end install path stays network-bound and is plan-forbidden as a
626    /// live-registry integration test.
627    #[test]
628    fn expand_pack_env_maps_declared_dependency_to_pack_dir() {
629        use crate::core::context_package::deps::ResolvedDep;
630        use crate::core::context_package::remote::parse_remote_ref;
631
632        let m = AddonManifest::from_toml(DEP_MANIFEST).expect("parses");
633        m.validate().expect("valid");
634
635        // Simulate the slice the install step produces from `manifest.dependencies`.
636        let resolved: Vec<ResolvedDep> = m
637            .dependencies
638            .iter()
639            .map(|d| {
640                let r = parse_remote_ref(&d.name).expect("scoped");
641                ResolvedDep {
642                    name: d.name.clone(),
643                    namespace: r.namespace,
644                    slug: r.name,
645                    version: "0.2.0".into(),
646                    artifact_sha256: "a".repeat(64),
647                }
648            })
649            .collect();
650
651        let out = crate::core::addons::pack_env::expand_pack_env(
652            &m.mcp.env,
653            &resolved,
654            std::path::Path::new("/store"),
655        )
656        .expect("expands against manifest.dependencies");
657        // Segment names are explicit here (only the separator comes from the
658        // platform): `Path::join` is production's own separator, so this
659        // asserts the real invariant instead of duplicating the code under test.
660        let expected = std::path::Path::new("/store")
661            .join("skills")
662            .join("@dasTholo__lean-md-skills")
663            .join("0.2.0")
664            .display()
665            .to_string();
666        assert_eq!(out["LEAN_MD_SKILLS_DIR"], expected);
667    }
668}