Skip to main content

mur_common/muragent/
manifest.rs

1//! `.muragent` v2 manifest schema types.
2//!
3//! Schema version: `mur-agent/2`. No backwards compat with `mur-agent-package/1`.
4
5use serde::{Deserialize, Serialize};
6
7/// Top-level manifest as written to `manifest.yaml` inside a `.muragent`.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct MuragentManifest {
10    pub schema: String,
11    pub exported_at: String,
12    pub exporter: ExporterInfo,
13    pub agent: AgentRef,
14    pub required_surfaces: Vec<Surface>,
15    #[serde(default)]
16    pub optional_capabilities: Vec<String>,
17    #[serde(default)]
18    pub mcp_servers: Vec<McpServerRef>,
19    pub icon: IconHashes,
20    #[serde(default)]
21    pub sanitized: SanitizedReport,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub hub: Option<HubBlock>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub commander: Option<CommanderBlock>,
26    /// Reserved for future specs; v1 must ignore.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub deployment: Option<serde_json::Value>,
29    /// Reserved for future specs; v1 must ignore.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub assignment: Option<serde_json::Value>,
32    /// Model backend hint for the recipient's first-run resolution (§7.1).
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub model_hint: Option<ModelHint>,
35    /// `Some("official")` for agents published from the official catalog.
36    /// Tamper-evident via `predicate.manifest_sha256` in the DSSE-signed
37    /// statement (the canonical JSON derived from `manifest.yaml`), so
38    /// stripping it invalidates the package signature.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub distribution: Option<String>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ExporterInfo {
45    pub mur_version: String,
46    pub tool: String,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub min_hub_version: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub min_commander_version: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct AgentRef {
55    pub slug: String,
56    pub display_name: String,
57    pub bundle_id: String,
58    pub url_scheme: String,
59    pub original_uuid: String,
60}
61
62#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
63#[serde(rename_all = "snake_case")]
64pub enum ModelTier {
65    Small,
66    Mid,
67    Frontier,
68}
69
70/// Declares what kind of model the agent was authored against, so the
71/// recipient's first-run wizard can resolve a backend (no weights travel).
72/// See spec §7.1.
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74pub struct ModelHint {
75    pub provider: String,
76    pub name: String,
77    pub tier: ModelTier,
78    #[serde(default)]
79    pub min_ram_gb: u32,
80    pub local_capable: bool,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
84#[serde(rename_all = "snake_case")]
85pub enum Surface {
86    Hub,
87    Commander,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct McpServerRef {
92    pub name: String,
93    pub command_basename: String,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct IconHashes {
98    #[serde(default)]
99    pub formats: Vec<String>,
100    #[serde(default)]
101    pub hash: IconHashMap,
102}
103
104#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105pub struct IconHashMap {
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub icns: Option<String>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub ico: Option<String>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub png: Option<String>,
112}
113
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115pub struct SanitizedReport {
116    #[serde(default)]
117    pub removed_fields: Vec<String>,
118}
119
120// ─── Hub-specific block ───
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct HubBlock {
124    pub appearance: HubAppearance,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub voice: Option<HubVoice>,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub pet: Option<HubPet>,
129    #[serde(default)]
130    pub url_scheme_overrides: Vec<String>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct HubAppearance {
135    pub style_preset: String,
136    pub behavior_preset: String,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct HubVoice {
141    pub enabled: bool,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct HubPet {
146    pub enabled: bool,
147}
148
149// ─── Commander-specific block ───
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct CommanderBlock {
153    pub chat_platforms: Vec<String>,
154    #[serde(default)]
155    pub workflows: Vec<CommanderWorkflowRef>,
156    #[serde(default)]
157    pub programs: Vec<CommanderProgramRef>,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub jira: Option<CommanderJira>,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub sub_agents: Option<CommanderSubAgents>,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub schedule_defaults: Option<CommanderScheduleDefaults>,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct CommanderWorkflowRef {
168    pub name: String,
169    pub file: String,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub schedule: Option<String>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct CommanderProgramRef {
176    pub file: String,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct CommanderJira {
181    pub base_url: String,
182    pub secret: String,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct CommanderSubAgents {
187    pub max_concurrent: u32,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct CommanderScheduleDefaults {
192    pub timezone: String,
193}
194
195// ─── Validation helpers ───
196
197impl MuragentManifest {
198    /// Schema version must be exactly `mur-agent/2`.
199    pub fn is_v2(&self) -> bool {
200        self.schema == "mur-agent/2"
201    }
202
203    /// Slug must match `run.mur.agent.<slug>` bundle ID pattern.
204    pub fn validate_bundle_id(&self) -> Result<(), String> {
205        let expected = format!("run.mur.agent.{}", self.agent.slug);
206        if self.agent.bundle_id != expected {
207            return Err(format!(
208                "bundle_id '{}' does not match expected '{}'",
209                self.agent.bundle_id, expected
210            ));
211        }
212        Ok(())
213    }
214}
215
216#[cfg(test)]
217mod model_hint_tests {
218    use super::*;
219
220    #[test]
221    fn manifest_round_trips_without_model_hint() {
222        let yaml = "\
223schema: mur-agent/2
224exported_at: '2026-05-29T00:00:00Z'
225exporter: { mur_version: 1.0.0, tool: mur }
226agent: { slug: coach, display_name: Coach, bundle_id: run.mur.agent.coach, url_scheme: muragent-coach, original_uuid: u1 }
227required_surfaces: [hub]
228icon: {}
229";
230        let m: MuragentManifest = serde_yaml_ng::from_str(yaml).unwrap();
231        assert!(m.model_hint.is_none());
232    }
233
234    #[test]
235    fn model_hint_serializes_and_parses() {
236        let hint = ModelHint {
237            provider: "ollama".into(),
238            name: "llama3.2:3b".into(),
239            tier: ModelTier::Small,
240            min_ram_gb: 8,
241            local_capable: true,
242        };
243        let s = serde_yaml_ng::to_string(&hint).unwrap();
244        let back: ModelHint = serde_yaml_ng::from_str(&s).unwrap();
245        assert_eq!(hint, back);
246        assert!(s.contains("tier: small"));
247    }
248}
249
250#[cfg(test)]
251mod distribution_marker_tests {
252    use super::*;
253
254    #[test]
255    fn distribution_marker_roundtrips_and_defaults_none() {
256        let yaml = "\
257schema: mur-agent/2
258exported_at: '2026-05-29T00:00:00Z'
259exporter: { mur_version: 1.0.0, tool: mur }
260agent: { slug: coach, display_name: Coach, bundle_id: run.mur.agent.coach, url_scheme: muragent-coach, original_uuid: u1 }
261required_surfaces: [hub]
262icon: {}
263";
264        let m: MuragentManifest = serde_yaml_ng::from_str(yaml).expect("legacy yaml parses");
265        assert!(m.distribution.is_none());
266        let mut m2 = m.clone();
267        m2.distribution = Some("official".into());
268        let round: MuragentManifest =
269            serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&m2).unwrap()).unwrap();
270        assert_eq!(round.distribution.as_deref(), Some("official"));
271    }
272}