1use std::{borrow::Cow, str::FromStr};
4
5use meerkat_core::Config;
6use serde::{Deserialize, Serialize};
7
8#[derive(
11 Debug,
12 Clone,
13 Copy,
14 PartialEq,
15 Eq,
16 PartialOrd,
17 Ord,
18 Hash,
19 Serialize,
20 Deserialize,
21 strum::EnumIter,
22 strum::EnumString,
23 strum::Display,
24)]
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
26#[serde(rename_all = "snake_case")]
27#[strum(serialize_all = "snake_case")]
28pub enum CapabilityId {
29 Sessions,
30 Streaming,
31 StructuredOutput,
32 Hooks,
33 Builtins,
34 Shell,
35 Comms,
36 MemoryStore,
37 Schedule,
38 WorkGraph,
39 SessionStore,
40 SessionCompaction,
41 Skills,
42 McpLive,
43 AdaptiveFlow,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct MobpackCapabilityRequirement<'a> {
58 raw: &'a str,
59 id: MobpackCapabilityId,
60}
61
62impl<'a> MobpackCapabilityRequirement<'a> {
63 pub fn parse(raw: &'a str) -> Self {
64 let id = CapabilityId::from_str(raw).map_or_else(
65 |_| {
66 HostProcessCapabilityId::parse(raw)
67 .map(MobpackCapabilityId::HostProcess)
68 .or_else(|| {
69 DeploySurfaceCapabilityId::parse(raw)
70 .map(MobpackCapabilityId::DeploySurface)
71 })
72 .unwrap_or(MobpackCapabilityId::Unknown)
73 },
74 MobpackCapabilityId::Known,
75 );
76 Self { raw, id }
77 }
78
79 pub fn raw(self) -> &'a str {
80 self.raw
81 }
82
83 pub fn id(self) -> MobpackCapabilityId {
84 self.id
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum MobpackCapabilityId {
91 Known(CapabilityId),
92 HostProcess(HostProcessCapabilityId),
93 DeploySurface(DeploySurfaceCapabilityId),
94 Unknown,
95}
96
97pub fn mobpack_capability_known_to_host(capability: MobpackCapabilityId) -> bool {
110 match capability {
111 MobpackCapabilityId::Known(_)
112 | MobpackCapabilityId::HostProcess(_)
113 | MobpackCapabilityId::DeploySurface(_) => true,
114 MobpackCapabilityId::Unknown => false,
115 }
116}
117
118pub fn known_mobpack_capability_tokens() -> Vec<String> {
125 let mut tokens: Vec<String> = <CapabilityId as strum::IntoEnumIterator>::iter()
126 .map(|id| id.to_string())
127 .collect();
128 tokens.extend(
129 <HostProcessCapabilityId as strum::IntoEnumIterator>::iter()
130 .map(|id| id.as_str().to_string()),
131 );
132 tokens.extend(
133 <DeploySurfaceCapabilityId as strum::IntoEnumIterator>::iter()
134 .map(|id| id.as_str().to_string()),
135 );
136 tokens
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumIter)]
145pub enum DeploySurfaceCapabilityId {
146 Core,
147 Mcp,
148 Rpc,
149}
150
151impl DeploySurfaceCapabilityId {
152 pub fn parse(raw: &str) -> Option<Self> {
153 match raw {
154 "core" => Some(Self::Core),
155 "mcp" => Some(Self::Mcp),
156 "rpc" => Some(Self::Rpc),
157 _ => None,
158 }
159 }
160
161 pub fn as_str(self) -> &'static str {
162 match self {
163 Self::Core => "core",
164 Self::Mcp => "mcp",
165 Self::Rpc => "rpc",
166 }
167 }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumIter)]
172pub enum HostProcessCapabilityId {
173 McpStdio,
174 ProcessSpawn,
175}
176
177impl HostProcessCapabilityId {
178 pub fn parse(raw: &str) -> Option<Self> {
179 match raw {
180 "mcp_stdio" => Some(Self::McpStdio),
181 "process_spawn" => Some(Self::ProcessSpawn),
182 _ => None,
183 }
184 }
185
186 pub fn as_str(self) -> &'static str {
187 match self {
188 Self::McpStdio => "mcp_stdio",
189 Self::ProcessSpawn => "process_spawn",
190 }
191 }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum BrowserMobpackCapabilityDecision {
197 Allowed,
198 Forbidden { capability: MobpackCapabilityId },
199}
200
201impl BrowserMobpackCapabilityDecision {
202 pub fn is_forbidden(self) -> bool {
203 matches!(self, Self::Forbidden { .. })
204 }
205}
206
207pub fn browser_mobpack_capability_decision(
208 capability: MobpackCapabilityId,
209) -> BrowserMobpackCapabilityDecision {
210 match capability {
211 MobpackCapabilityId::Known(CapabilityId::Shell) | MobpackCapabilityId::HostProcess(_) => {
212 BrowserMobpackCapabilityDecision::Forbidden { capability }
213 }
214 MobpackCapabilityId::Known(_)
215 | MobpackCapabilityId::DeploySurface(_)
216 | MobpackCapabilityId::Unknown => BrowserMobpackCapabilityDecision::Allowed,
217 }
218}
219
220#[derive(
222 Debug,
223 Clone,
224 Copy,
225 PartialEq,
226 Eq,
227 Hash,
228 Serialize,
229 Deserialize,
230 strum::EnumString,
231 strum::Display,
232)]
233#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
234#[serde(rename_all = "snake_case")]
235#[strum(serialize_all = "snake_case")]
236pub enum CapabilityProtocol {
237 Rpc,
238 Rest,
239 Mcp,
240 Cli,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
245#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
246pub enum CapabilityScope {
247 Universal,
249 Extension {
251 protocols: Cow<'static, [CapabilityProtocol]>,
252 },
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
258pub enum CapabilityStatus {
259 Available,
261 DisabledByPolicy { description: Cow<'static, str> },
263 NotCompiled { feature: Cow<'static, str> },
265 NotSupportedByProtocol { reason: Cow<'static, str> },
267}
268
269#[derive(Clone, Copy)]
270pub struct FeatureCapabilityPolicy {
271 enabled: fn(&Config) -> bool,
272 disabled_description: &'static str,
273}
274
275impl FeatureCapabilityPolicy {
276 pub const fn new(enabled: fn(&Config) -> bool, disabled_description: &'static str) -> Self {
277 Self {
278 enabled,
279 disabled_description,
280 }
281 }
282
283 pub fn is_enabled(self, config: &Config) -> bool {
284 (self.enabled)(config)
285 }
286
287 pub const fn disabled_description(self) -> &'static str {
288 self.disabled_description
289 }
290}
291
292pub struct CapabilityRegistration {
296 pub id: CapabilityId,
297 pub description: &'static str,
298 pub scope: CapabilityScope,
299 pub requires_feature: Option<&'static str>,
300 pub prerequisites: &'static [CapabilityId],
301 pub status_resolver: Option<fn(&Config) -> CapabilityStatus>,
302}
303
304inventory::collect!(CapabilityRegistration);
305
306inventory::submit! {
308 CapabilityRegistration {
309 id: CapabilityId::Sessions,
310 description: "Agent loop and session lifecycle",
311 scope: CapabilityScope::Universal,
312 requires_feature: None,
313 prerequisites: &[],
314 status_resolver: None,
315 }
316}
317
318inventory::submit! {
319 CapabilityRegistration {
320 id: CapabilityId::Streaming,
321 description: "Event streaming during agent execution",
322 scope: CapabilityScope::Universal,
323 requires_feature: None,
324 prerequisites: &[],
325 status_resolver: None,
326 }
327}
328
329inventory::submit! {
330 CapabilityRegistration {
331 id: CapabilityId::StructuredOutput,
332 description: "Schema-validated JSON output extraction",
333 scope: CapabilityScope::Universal,
334 requires_feature: None,
335 prerequisites: &[],
336 status_resolver: None,
337 }
338}
339
340pub fn build_capabilities() -> Vec<&'static CapabilityRegistration> {
343 let mut caps: Vec<&'static CapabilityRegistration> = inventory::iter::<CapabilityRegistration>
344 .into_iter()
345 .collect();
346 caps.sort_by_key(|r| r.id);
347 caps
348}
349
350pub fn resolve_capabilities(
354 config: &Config,
355) -> Vec<(&'static CapabilityRegistration, CapabilityStatus)> {
356 build_capabilities()
357 .into_iter()
358 .map(|reg| {
359 let status = match reg.status_resolver {
360 Some(resolver) => resolver(config),
361 None => CapabilityStatus::Available,
362 };
363 (reg, status)
364 })
365 .collect()
366}
367
368pub fn available_capabilities(config: &Config) -> Vec<CapabilityId> {
371 resolve_capabilities(config)
372 .into_iter()
373 .filter_map(|(reg, status)| matches!(status, CapabilityStatus::Available).then_some(reg.id))
374 .collect()
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use meerkat_core::Config;
381
382 #[test]
383 fn test_build_capabilities_finds_registered() {
384 let caps = build_capabilities();
385 assert!(
386 caps.iter().any(|c| c.id == CapabilityId::Sessions),
387 "Should find the test-registered Sessions capability"
388 );
389 }
390
391 #[test]
392 fn test_build_capabilities_sorted() {
393 let caps = build_capabilities();
394 if caps.len() >= 2 {
395 for window in caps.windows(2) {
396 assert!(
397 window[0].id <= window[1].id,
398 "Capabilities should be sorted by ordinal"
399 );
400 }
401 }
402 }
403
404 #[test]
405 fn available_capabilities_always_include_unconditional_entries() {
406 let config = Config::default();
407 let caps = available_capabilities(&config);
408 assert!(caps.contains(&CapabilityId::Sessions));
409 assert!(caps.contains(&CapabilityId::Streaming));
410 assert!(caps.contains(&CapabilityId::StructuredOutput));
411 }
412
413 #[test]
414 fn mobpack_capability_requirement_classifies_known_capabilities() {
415 let requirement = MobpackCapabilityRequirement::parse("comms");
416
417 assert_eq!(
418 requirement.id(),
419 MobpackCapabilityId::Known(CapabilityId::Comms)
420 );
421 assert_eq!(requirement.raw(), "comms");
422 }
423
424 #[test]
425 fn mobpack_capability_requirement_classifies_host_process_capabilities() {
426 assert_eq!(
427 MobpackCapabilityRequirement::parse("mcp_stdio").id(),
428 MobpackCapabilityId::HostProcess(HostProcessCapabilityId::McpStdio)
429 );
430 assert_eq!(
431 MobpackCapabilityRequirement::parse("process_spawn").id(),
432 MobpackCapabilityId::HostProcess(HostProcessCapabilityId::ProcessSpawn)
433 );
434 }
435
436 #[test]
437 fn browser_mobpack_policy_forbids_shell_and_host_process_capabilities() {
438 for raw in ["shell", "mcp_stdio", "process_spawn"] {
439 assert!(
440 browser_mobpack_capability_decision(MobpackCapabilityRequirement::parse(raw).id())
441 .is_forbidden(),
442 "{raw} should be forbidden in browser mobpacks"
443 );
444 }
445 }
446
447 #[test]
448 fn adaptive_flow_classifies_as_known_capability() {
449 let requirement = MobpackCapabilityRequirement::parse("adaptive_flow");
450 assert_eq!(
451 requirement.id(),
452 MobpackCapabilityId::Known(CapabilityId::AdaptiveFlow)
453 );
454 assert_eq!(CapabilityId::AdaptiveFlow.to_string(), "adaptive_flow");
455 }
456
457 #[test]
458 fn host_knows_every_typed_capability_and_rejects_unknown() {
459 for raw in ["comms", "adaptive_flow", "mcp_stdio", "core"] {
460 assert!(
461 mobpack_capability_known_to_host(MobpackCapabilityRequirement::parse(raw).id()),
462 "{raw} must be known to this host build"
463 );
464 }
465 assert!(!mobpack_capability_known_to_host(
466 MobpackCapabilityRequirement::parse("capability-from-the-future").id()
467 ));
468 }
469
470 #[test]
471 fn known_tokens_cover_all_requirement_families_and_round_trip() {
472 let tokens = known_mobpack_capability_tokens();
473 for expected in [
474 "sessions",
475 "adaptive_flow",
476 "mcp_stdio",
477 "process_spawn",
478 "core",
479 ] {
480 assert!(
481 tokens.iter().any(|t| t == expected),
482 "known token set must contain {expected}: {tokens:?}"
483 );
484 }
485 for token in &tokens {
487 assert!(
488 mobpack_capability_known_to_host(MobpackCapabilityRequirement::parse(token).id()),
489 "advertised token {token} must round-trip as known"
490 );
491 }
492 }
493
494 #[test]
495 fn browser_mobpack_policy_allows_safe_known_and_unknown_capabilities() {
496 assert_eq!(
497 browser_mobpack_capability_decision(MobpackCapabilityRequirement::parse("comms").id()),
498 BrowserMobpackCapabilityDecision::Allowed
499 );
500 assert_eq!(
501 browser_mobpack_capability_decision(
502 MobpackCapabilityRequirement::parse("vendor.custom").id()
503 ),
504 BrowserMobpackCapabilityDecision::Allowed
505 );
506 }
507}