use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MuragentManifest {
pub schema: String,
pub exported_at: String,
pub exporter: ExporterInfo,
pub agent: AgentRef,
pub required_surfaces: Vec<Surface>,
#[serde(default)]
pub optional_capabilities: Vec<String>,
#[serde(default)]
pub mcp_servers: Vec<McpServerRef>,
pub icon: IconHashes,
#[serde(default)]
pub sanitized: SanitizedReport,
#[serde(skip_serializing_if = "Option::is_none")]
pub hub: Option<HubBlock>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commander: Option<CommanderBlock>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deployment: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assignment: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_hint: Option<ModelHint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExporterInfo {
pub mur_version: String,
pub tool: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_hub_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_commander_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentRef {
pub slug: String,
pub display_name: String,
pub bundle_id: String,
pub url_scheme: String,
pub original_uuid: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ModelTier {
Small,
Mid,
Frontier,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ModelHint {
pub provider: String,
pub name: String,
pub tier: ModelTier,
#[serde(default)]
pub min_ram_gb: u32,
pub local_capable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Surface {
Hub,
Commander,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerRef {
pub name: String,
pub command_basename: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IconHashes {
#[serde(default)]
pub formats: Vec<String>,
#[serde(default)]
pub hash: IconHashMap,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IconHashMap {
#[serde(skip_serializing_if = "Option::is_none")]
pub icns: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ico: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub png: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SanitizedReport {
#[serde(default)]
pub removed_fields: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HubBlock {
pub appearance: HubAppearance,
#[serde(skip_serializing_if = "Option::is_none")]
pub voice: Option<HubVoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pet: Option<HubPet>,
#[serde(default)]
pub url_scheme_overrides: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HubAppearance {
pub style_preset: String,
pub behavior_preset: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HubVoice {
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HubPet {
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommanderBlock {
pub chat_platforms: Vec<String>,
#[serde(default)]
pub workflows: Vec<CommanderWorkflowRef>,
#[serde(default)]
pub programs: Vec<CommanderProgramRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jira: Option<CommanderJira>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sub_agents: Option<CommanderSubAgents>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schedule_defaults: Option<CommanderScheduleDefaults>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommanderWorkflowRef {
pub name: String,
pub file: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub schedule: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommanderProgramRef {
pub file: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommanderJira {
pub base_url: String,
pub secret: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommanderSubAgents {
pub max_concurrent: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommanderScheduleDefaults {
pub timezone: String,
}
impl MuragentManifest {
pub fn is_v2(&self) -> bool {
self.schema == "mur-agent/2"
}
pub fn validate_bundle_id(&self) -> Result<(), String> {
let expected = format!("run.mur.agent.{}", self.agent.slug);
if self.agent.bundle_id != expected {
return Err(format!(
"bundle_id '{}' does not match expected '{}'",
self.agent.bundle_id, expected
));
}
Ok(())
}
}
#[cfg(test)]
mod model_hint_tests {
use super::*;
#[test]
fn manifest_round_trips_without_model_hint() {
let yaml = "\
schema: mur-agent/2
exported_at: '2026-05-29T00:00:00Z'
exporter: { mur_version: 1.0.0, tool: mur }
agent: { slug: coach, display_name: Coach, bundle_id: run.mur.agent.coach, url_scheme: muragent-coach, original_uuid: u1 }
required_surfaces: [hub]
icon: {}
";
let m: MuragentManifest = serde_yaml_ng::from_str(yaml).unwrap();
assert!(m.model_hint.is_none());
}
#[test]
fn model_hint_serializes_and_parses() {
let hint = ModelHint {
provider: "ollama".into(),
name: "llama3.2:3b".into(),
tier: ModelTier::Small,
min_ram_gb: 8,
local_capable: true,
};
let s = serde_yaml_ng::to_string(&hint).unwrap();
let back: ModelHint = serde_yaml_ng::from_str(&s).unwrap();
assert_eq!(hint, back);
assert!(s.contains("tier: small"));
}
}