Skip to main content

oxide_k/
manifest.rs

1//! # Module manifests
2//!
3//! `module.json` is the discovery file produced by `oxide-gen` for every
4//! generated crate. It mirrors [`ModuleMetadata`] and adds the fields the
5//! kernel needs to locate the generated binary, skill descriptor, and MCP
6//! configuration.
7//!
8//! The [`Kernel::register_module_from_manifest`](crate::Kernel::register_module_from_manifest)
9//! helper reads a manifest file and records the corresponding module in the
10//! state registry, putting it into the `Loaded` lifecycle state. Actually
11//! *running* the generated binary as a child process is out of scope for the
12//! current bootstrap and will be wired up alongside a sandboxed process
13//! supervisor in a later iteration.
14
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19use crate::error::{KernelError, Result};
20use crate::module::{ModuleKind, ModuleMetadata, ModuleState};
21use crate::registry::StateRegistry;
22use crate::Kernel;
23
24/// The on-disk shape of a `module.json` file emitted by `oxide-gen`.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ModuleManifest {
27    /// Stable module id (snake_case).
28    pub id: String,
29    /// Human-readable display name.
30    pub name: String,
31    /// Semantic version.
32    pub version: String,
33    /// Module kind / execution layer.
34    pub kind: ModuleKind,
35    /// Free-form description.
36    pub description: Option<String>,
37    /// Source spec format (`openapi`, `graphql`, `grpc`).
38    pub spec_kind: Option<String>,
39    /// CLI binary name (relative to the crate root or on `$PATH`).
40    pub binary: Option<String>,
41    /// Path (relative) to the Claude Code skill descriptor.
42    pub skill: Option<String>,
43    /// Path (relative) to the MCP server configuration.
44    pub mcp: Option<String>,
45    /// Default base URL baked into the generated client.
46    #[serde(default)]
47    pub base_url: Option<String>,
48    /// List of operation ids exposed by the module.
49    #[serde(default)]
50    pub operations: Vec<String>,
51    /// Capability tokens this module is granted on the bus. Populated by
52    /// the kernel when registering the module; used with
53    /// [`crate::bus::MessageBus::publish_with_capability`].
54    #[serde(default)]
55    pub capabilities: Vec<String>,
56}
57
58impl ModuleManifest {
59    /// Parse a manifest from raw JSON.
60    pub fn from_json(raw: &str) -> Result<Self> {
61        Ok(serde_json::from_str(raw)?)
62    }
63
64    /// Load a manifest from disk. Accepts either the manifest file directly
65    /// or a directory containing `module.json`.
66    pub fn load(path: &Path) -> Result<Self> {
67        let manifest_path = if path.is_dir() {
68            path.join("module.json")
69        } else {
70            path.to_path_buf()
71        };
72        let raw = std::fs::read_to_string(&manifest_path).map_err(|e| {
73            KernelError::Other(anyhow::anyhow!(
74                "failed to read manifest {}: {e}",
75                manifest_path.display()
76            ))
77        })?;
78        Self::from_json(&raw)
79    }
80
81    /// Derive the [`ModuleMetadata`] the kernel uses for registry storage.
82    pub fn metadata(&self) -> ModuleMetadata {
83        ModuleMetadata {
84            id: self.id.clone(),
85            name: self.name.clone(),
86            version: self.version.clone(),
87            kind: self.kind,
88            description: self.description.clone(),
89        }
90    }
91}
92
93/// Resolve the on-disk paths referenced by a manifest, anchored at
94/// `base_dir`.
95#[derive(Debug, Clone)]
96pub struct ResolvedManifest {
97    /// The parsed manifest.
98    pub manifest: ModuleManifest,
99    /// Absolute path to the binary, if specified.
100    pub binary_path: Option<PathBuf>,
101    /// Absolute path to the SKILL.md file, if specified.
102    pub skill_path: Option<PathBuf>,
103    /// Absolute path to the MCP config, if specified.
104    pub mcp_path: Option<PathBuf>,
105}
106
107impl ResolvedManifest {
108    /// Resolve every path field against `base_dir`.
109    pub fn resolve(manifest: ModuleManifest, base_dir: &Path) -> Self {
110        let join = |relative: &Option<String>| relative.as_deref().map(|s| base_dir.join(s));
111        Self {
112            binary_path: join(&manifest.binary),
113            skill_path: join(&manifest.skill),
114            mcp_path: join(&manifest.mcp),
115            manifest,
116        }
117    }
118}
119
120impl Kernel {
121    /// Register a module described by a `module.json` manifest with the
122    /// kernel's state registry.
123    ///
124    /// `path` may be the manifest file or its parent directory. The module is
125    /// recorded in [`ModuleState::Loaded`]. Starting the underlying binary is
126    /// the responsibility of a future process supervisor.
127    pub async fn register_module_from_manifest(&self, path: &Path) -> Result<ResolvedManifest> {
128        let manifest = ModuleManifest::load(path)?;
129        let base_dir = if path.is_dir() {
130            path.to_path_buf()
131        } else {
132            path.parent()
133                .unwrap_or_else(|| Path::new("."))
134                .to_path_buf()
135        };
136        let resolved = ResolvedManifest::resolve(manifest, &base_dir);
137        let metadata = resolved.manifest.metadata();
138        self.registry()
139            .upsert_module(&metadata, ModuleState::Loaded)
140            .await?;
141        tracing::info!(
142            id = %metadata.id,
143            kind = ?metadata.kind,
144            "registered module from manifest"
145        );
146        Ok(resolved)
147    }
148}
149
150/// Convenience for callers that already own a [`StateRegistry`] but no full
151/// [`Kernel`] (e.g. CLI tools, tests).
152pub async fn register_in_registry(
153    registry: &StateRegistry,
154    manifest: &ModuleManifest,
155) -> Result<()> {
156    registry
157        .upsert_module(&manifest.metadata(), ModuleState::Loaded)
158        .await
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    const SAMPLE: &str = r#"
166    {
167      "id": "petstore",
168      "name": "Pet Store",
169      "version": "1.0.0",
170      "kind": "native",
171      "description": "Generated client for the Pet Store API.",
172      "spec_kind": "openapi",
173      "binary": "petstore-cli",
174      "skill": "SKILL.md",
175      "mcp": "mcp.json",
176      "base_url": "https://petstore.example.com/v1",
177      "operations": ["list_pets", "create_pet", "get_pet"]
178    }"#;
179
180    #[test]
181    fn parses_sample_manifest() {
182        let m = ModuleManifest::from_json(SAMPLE).unwrap();
183        assert_eq!(m.id, "petstore");
184        assert_eq!(m.kind, ModuleKind::Native);
185        assert_eq!(m.operations.len(), 3);
186    }
187
188    #[tokio::test]
189    async fn registers_manifest_in_registry() {
190        let kernel = Kernel::in_memory().await.unwrap();
191        let dir = tempfile::tempdir().unwrap();
192        let manifest_path = dir.path().join("module.json");
193        std::fs::write(&manifest_path, SAMPLE).unwrap();
194
195        let resolved = kernel
196            .register_module_from_manifest(&manifest_path)
197            .await
198            .unwrap();
199        assert_eq!(resolved.manifest.id, "petstore");
200        assert_eq!(
201            resolved.binary_path.as_ref().unwrap(),
202            &dir.path().join("petstore-cli")
203        );
204
205        let rec = kernel
206            .registry()
207            .get_module("petstore")
208            .await
209            .unwrap()
210            .expect("record");
211        assert_eq!(rec.state, ModuleState::Loaded);
212        assert_eq!(rec.version, "1.0.0");
213    }
214}