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}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct ExporterInfo {
39    pub mur_version: String,
40    pub tool: String,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub min_hub_version: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub min_commander_version: Option<String>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct AgentRef {
49    pub slug: String,
50    pub display_name: String,
51    pub bundle_id: String,
52    pub url_scheme: String,
53    pub original_uuid: String,
54}
55
56#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "snake_case")]
58pub enum ModelTier {
59    Small,
60    Mid,
61    Frontier,
62}
63
64/// Declares what kind of model the agent was authored against, so the
65/// recipient's first-run wizard can resolve a backend (no weights travel).
66/// See spec §7.1.
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68pub struct ModelHint {
69    pub provider: String,
70    pub name: String,
71    pub tier: ModelTier,
72    #[serde(default)]
73    pub min_ram_gb: u32,
74    pub local_capable: bool,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78#[serde(rename_all = "snake_case")]
79pub enum Surface {
80    Hub,
81    Commander,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct McpServerRef {
86    pub name: String,
87    pub command_basename: String,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct IconHashes {
92    #[serde(default)]
93    pub formats: Vec<String>,
94    #[serde(default)]
95    pub hash: IconHashMap,
96}
97
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
99pub struct IconHashMap {
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub icns: Option<String>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub ico: Option<String>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub png: Option<String>,
106}
107
108#[derive(Debug, Clone, Default, Serialize, Deserialize)]
109pub struct SanitizedReport {
110    #[serde(default)]
111    pub removed_fields: Vec<String>,
112}
113
114// ─── Hub-specific block ───
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct HubBlock {
118    pub appearance: HubAppearance,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub voice: Option<HubVoice>,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub pet: Option<HubPet>,
123    #[serde(default)]
124    pub url_scheme_overrides: Vec<String>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct HubAppearance {
129    pub style_preset: String,
130    pub behavior_preset: String,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct HubVoice {
135    pub enabled: bool,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct HubPet {
140    pub enabled: bool,
141}
142
143// ─── Commander-specific block ───
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct CommanderBlock {
147    pub chat_platforms: Vec<String>,
148    #[serde(default)]
149    pub workflows: Vec<CommanderWorkflowRef>,
150    #[serde(default)]
151    pub programs: Vec<CommanderProgramRef>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub jira: Option<CommanderJira>,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub sub_agents: Option<CommanderSubAgents>,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub schedule_defaults: Option<CommanderScheduleDefaults>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct CommanderWorkflowRef {
162    pub name: String,
163    pub file: String,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub schedule: Option<String>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct CommanderProgramRef {
170    pub file: String,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct CommanderJira {
175    pub base_url: String,
176    pub secret: String,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct CommanderSubAgents {
181    pub max_concurrent: u32,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct CommanderScheduleDefaults {
186    pub timezone: String,
187}
188
189// ─── Validation helpers ───
190
191impl MuragentManifest {
192    /// Schema version must be exactly `mur-agent/2`.
193    pub fn is_v2(&self) -> bool {
194        self.schema == "mur-agent/2"
195    }
196
197    /// Slug must match `run.mur.agent.<slug>` bundle ID pattern.
198    pub fn validate_bundle_id(&self) -> Result<(), String> {
199        let expected = format!("run.mur.agent.{}", self.agent.slug);
200        if self.agent.bundle_id != expected {
201            return Err(format!(
202                "bundle_id '{}' does not match expected '{}'",
203                self.agent.bundle_id, expected
204            ));
205        }
206        Ok(())
207    }
208}
209
210#[cfg(test)]
211mod model_hint_tests {
212    use super::*;
213
214    #[test]
215    fn manifest_round_trips_without_model_hint() {
216        let yaml = "\
217schema: mur-agent/2
218exported_at: '2026-05-29T00:00:00Z'
219exporter: { mur_version: 1.0.0, tool: mur }
220agent: { slug: coach, display_name: Coach, bundle_id: run.mur.agent.coach, url_scheme: muragent-coach, original_uuid: u1 }
221required_surfaces: [hub]
222icon: {}
223";
224        let m: MuragentManifest = serde_yaml_ng::from_str(yaml).unwrap();
225        assert!(m.model_hint.is_none());
226    }
227
228    #[test]
229    fn model_hint_serializes_and_parses() {
230        let hint = ModelHint {
231            provider: "ollama".into(),
232            name: "llama3.2:3b".into(),
233            tier: ModelTier::Small,
234            min_ram_gb: 8,
235            local_capable: true,
236        };
237        let s = serde_yaml_ng::to_string(&hint).unwrap();
238        let back: ModelHint = serde_yaml_ng::from_str(&s).unwrap();
239        assert_eq!(hint, back);
240        assert!(s.contains("tier: small"));
241    }
242}