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}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct ExporterInfo {
36    pub mur_version: String,
37    pub tool: String,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub min_hub_version: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub min_commander_version: Option<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct AgentRef {
46    pub slug: String,
47    pub display_name: String,
48    pub bundle_id: String,
49    pub url_scheme: String,
50    pub original_uuid: String,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "snake_case")]
55pub enum Surface {
56    Hub,
57    Commander,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct McpServerRef {
62    pub name: String,
63    pub command_basename: String,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct IconHashes {
68    #[serde(default)]
69    pub formats: Vec<String>,
70    #[serde(default)]
71    pub hash: IconHashMap,
72}
73
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct IconHashMap {
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub icns: Option<String>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub ico: Option<String>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub png: Option<String>,
82}
83
84#[derive(Debug, Clone, Default, Serialize, Deserialize)]
85pub struct SanitizedReport {
86    #[serde(default)]
87    pub removed_fields: Vec<String>,
88}
89
90// ─── Hub-specific block ───
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct HubBlock {
94    pub appearance: HubAppearance,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub voice: Option<HubVoice>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub pet: Option<HubPet>,
99    #[serde(default)]
100    pub url_scheme_overrides: Vec<String>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct HubAppearance {
105    pub style_preset: String,
106    pub behavior_preset: String,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct HubVoice {
111    pub enabled: bool,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct HubPet {
116    pub enabled: bool,
117}
118
119// ─── Commander-specific block ───
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct CommanderBlock {
123    pub chat_platforms: Vec<String>,
124    #[serde(default)]
125    pub workflows: Vec<CommanderWorkflowRef>,
126    #[serde(default)]
127    pub programs: Vec<CommanderProgramRef>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub jira: Option<CommanderJira>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub sub_agents: Option<CommanderSubAgents>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub schedule_defaults: Option<CommanderScheduleDefaults>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct CommanderWorkflowRef {
138    pub name: String,
139    pub file: String,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub schedule: Option<String>,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct CommanderProgramRef {
146    pub file: String,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct CommanderJira {
151    pub base_url: String,
152    pub secret: String,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct CommanderSubAgents {
157    pub max_concurrent: u32,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct CommanderScheduleDefaults {
162    pub timezone: String,
163}
164
165// ─── Validation helpers ───
166
167impl MuragentManifest {
168    /// Schema version must be exactly `mur-agent/2`.
169    pub fn is_v2(&self) -> bool {
170        self.schema == "mur-agent/2"
171    }
172
173    /// Slug must match `run.mur.agent.<slug>` bundle ID pattern.
174    pub fn validate_bundle_id(&self) -> Result<(), String> {
175        let expected = format!("run.mur.agent.{}", self.agent.slug);
176        if self.agent.bundle_id != expected {
177            return Err(format!(
178                "bundle_id '{}' does not match expected '{}'",
179                self.agent.bundle_id, expected
180            ));
181        }
182        Ok(())
183    }
184}