Skip to main content

rbnx/cmd/
inspect.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// `rbnx caps / describe / tools / channels / inspect` — atlas introspection.
3//
4// All these are read-only views over `AtlasClient::query_capabilities` and
5// `AtlasClient::inner().inspect_atlas`. No state mutation, no Connect.
6
7use anyhow::{Context, Result};
8use colored::*;
9use robonix_atlas::client::AtlasClient;
10use robonix_atlas::pb as atlas_pb;
11use serde_json::Value;
12
13fn transport_name(t: i32) -> &'static str {
14    match atlas_pb::Transport::try_from(t).unwrap_or(atlas_pb::Transport::Unspecified) {
15        atlas_pb::Transport::Grpc => "grpc",
16        atlas_pb::Transport::Ros2 => "ros2",
17        atlas_pb::Transport::Mcp => "mcp",
18        atlas_pb::Transport::Unspecified => "?",
19    }
20}
21
22fn state_name(s: i32) -> &'static str {
23    match atlas_pb::LifecycleState::try_from(s)
24        .unwrap_or(atlas_pb::LifecycleState::StateUnspecified)
25    {
26        atlas_pb::LifecycleState::StateRegistered => "REGISTERED",
27        atlas_pb::LifecycleState::StateInactive => "INACTIVE",
28        atlas_pb::LifecycleState::StateActive => "ACTIVE",
29        atlas_pb::LifecycleState::StateError => "ERROR",
30        atlas_pb::LifecycleState::StateTerminated => "TERMINATED",
31        atlas_pb::LifecycleState::StateUnspecified => "?",
32    }
33}
34
35/// Color the `[STATE]` tag the same way every printer does it. Picked to
36/// match the boot-log feel: green=running healthy, yellow=came up but
37/// nothing's driving it yet, red=problem, dim=quiescent.
38fn state_tag(s: i32) -> colored::ColoredString {
39    let label = format!("[{}]", state_name(s));
40    match atlas_pb::LifecycleState::try_from(s)
41        .unwrap_or(atlas_pb::LifecycleState::StateUnspecified)
42    {
43        atlas_pb::LifecycleState::StateActive => label.green().bold(),
44        atlas_pb::LifecycleState::StateInactive => label.yellow(),
45        atlas_pb::LifecycleState::StateRegistered => label.blue(),
46        atlas_pb::LifecycleState::StateTerminated => label.dimmed(),
47        atlas_pb::LifecycleState::StateError => label.red().bold(),
48        atlas_pb::LifecycleState::StateUnspecified => label.dimmed(),
49    }
50}
51
52async fn connect(endpoint: &str) -> Result<AtlasClient> {
53    AtlasClient::connect(endpoint)
54        .await
55        .with_context(|| format!("connect to atlas at '{endpoint}'"))
56}
57
58pub async fn providers(endpoint: &str, json: bool, verbose: bool) -> Result<()> {
59    let mut atlas = connect(endpoint).await?;
60    let providers = atlas
61        .query_capabilities("", "", atlas_pb::Transport::Unspecified)
62        .await?;
63    if json {
64        let serialised: Vec<_> = providers
65            .iter()
66            .map(|r| {
67                serde_json::json!({
68                    "provider_id":  r.id,
69                    "namespace":      r.namespace,
70                    "state":          state_name(r.state),
71                    "state_detail":   r.state_detail,
72                    "capabilities":     r.capabilities.iter().map(|i| serde_json::json!({
73                        "contract_id": i.contract_id,
74                        "transport":   transport_name(i.transport),
75                        "namespace_mismatch": i.namespace_mismatch,
76                    })).collect::<Vec<_>>(),
77                })
78            })
79            .collect();
80        println!("{}", serde_json::to_string_pretty(&serialised)?);
81        return Ok(());
82    }
83
84    if providers.is_empty() {
85        println!("{} no providers registered", "[providers]".yellow().bold());
86        return Ok(());
87    }
88    // Default: one row per provider, no capabilities. -v expands the
89    // capability list (lspci -tv style — quick scan vs full dump).
90    for provider in &providers {
91        let detail = if provider.state_detail.is_empty() {
92            String::new()
93        } else {
94            format!(" — {}", provider.state_detail)
95        };
96        let cap_count_hint = if verbose {
97            String::new()
98        } else {
99            format!(" ({} caps)", provider.capabilities.len())
100                .dimmed()
101                .to_string()
102        };
103        let namespace_hint = if provider
104            .capabilities
105            .iter()
106            .any(|cap| cap.namespace_mismatch)
107        {
108            " [namespace mismatch]".yellow().to_string()
109        } else {
110            String::new()
111        };
112        println!(
113            "{} {} {} {}{}{}{}",
114            "●".green(),
115            provider.id.bold(),
116            state_tag(provider.state),
117            provider.namespace.dimmed(),
118            cap_count_hint,
119            namespace_hint,
120            detail.dimmed()
121        );
122        if verbose {
123            for cap in &provider.capabilities {
124                let mismatch = if cap.namespace_mismatch {
125                    " [namespace mismatch]".yellow().to_string()
126                } else {
127                    String::new()
128                };
129                println!(
130                    "    {} {} {}{}",
131                    "└─".dimmed(),
132                    cap.contract_id,
133                    format!("({})", transport_name(cap.transport)).dimmed(),
134                    mismatch
135                );
136            }
137        }
138    }
139    if !verbose {
140        println!(
141            "\n{} pass {} for the per-provider capability list",
142            "tip:".dimmed(),
143            "-v".bold()
144        );
145    }
146    Ok(())
147}
148
149pub async fn describe(endpoint: &str, provider_id: Option<&str>, json: bool) -> Result<()> {
150    let mut atlas = connect(endpoint).await?;
151    let cap_filter = provider_id.unwrap_or("");
152    let providers = atlas
153        .query_capabilities(cap_filter, "", atlas_pb::Transport::Unspecified)
154        .await?;
155    if providers.is_empty() {
156        println!("{} no matching providers", "[describe]".yellow().bold());
157        return Ok(());
158    }
159    for provider in &providers {
160        // Atlas only stores the CAPABILITY.md path; consumers read the
161        // file off the local filesystem themselves.
162        let md = if provider.capability_md_path.is_empty() {
163            String::new()
164        } else {
165            std::fs::read_to_string(&provider.capability_md_path).unwrap_or_default()
166        };
167        if json {
168            let value = serde_json::json!({
169                "provider_id":   provider.id,
170                "namespace":       provider.namespace,
171                "state":           state_name(provider.state),
172                "capabilities":      provider.capabilities.iter().map(|i| serde_json::json!({
173                    "contract_id": i.contract_id,
174                    "transport":   transport_name(i.transport),
175                    "namespace_mismatch": i.namespace_mismatch,
176                })).collect::<Vec<_>>(),
177                "capability_md":   md,
178            });
179            println!("{}", serde_json::to_string_pretty(&value)?);
180        } else {
181            println!(
182                "{} {} {}",
183                "●".green(),
184                provider.id.bold(),
185                state_tag(provider.state)
186            );
187            for cap in &provider.capabilities {
188                let mismatch = if cap.namespace_mismatch {
189                    " [namespace mismatch]".yellow().to_string()
190                } else {
191                    String::new()
192                };
193                println!(
194                    "    {} {} ({}){}",
195                    "└─".dimmed(),
196                    cap.contract_id,
197                    transport_name(cap.transport),
198                    mismatch
199                );
200            }
201            if !md.is_empty() {
202                println!("\n{}", md);
203            }
204        }
205    }
206    Ok(())
207}
208
209pub async fn tools(endpoint: &str, json: bool) -> Result<()> {
210    // "Tools" here mirrors Pilot's model-facing MCP catalog. Contract
211    // metadata only filters this view; it does not alter Atlas discovery.
212    let mut atlas = connect(endpoint).await?;
213    let providers = atlas
214        .query_capabilities("", "", atlas_pb::Transport::Mcp)
215        .await?;
216    let contracts = atlas
217        .inner()
218        .list_contracts(atlas_pb::ListContractsRequest {
219            namespace_prefix: String::new(),
220        })
221        .await
222        .context("ListContracts RPC")?
223        .into_inner()
224        .contracts;
225    let hidden_contracts = contracts
226        .into_iter()
227        .filter(|contract| !contract_llm_callable(contract))
228        .map(|contract| contract.id)
229        .collect::<std::collections::HashSet<_>>();
230    let mut entries: Vec<(String, String, String, String)> = Vec::new();
231    for provider in &providers {
232        for cap in &provider.capabilities {
233            if cap.transport != atlas_pb::Transport::Mcp as i32
234                || hidden_contracts.contains(&cap.contract_id)
235            {
236                continue;
237            }
238            let schema = match cap.params.as_ref().and_then(|p| p.kind.as_ref()) {
239                Some(atlas_pb::transport_params::Kind::Mcp(m)) => m.input_schema_json.clone(),
240                _ => String::new(),
241            };
242            let description = cap.description.clone();
243            entries.push((
244                provider.id.clone(),
245                cap.contract_id.clone(),
246                description,
247                schema,
248            ));
249        }
250    }
251    if json {
252        let serialised: Vec<_> = entries
253            .iter()
254            .map(|(provider, c, d, s)| {
255                serde_json::json!({
256                    "provider_id":            provider,
257                    "contract_id":       c,
258                    "description":       d,
259                    "input_schema_json": s,
260                })
261            })
262            .collect();
263        println!("{}", serde_json::to_string_pretty(&serialised)?);
264        return Ok(());
265    }
266    if entries.is_empty() {
267        println!("{} no MCP tools registered", "[tools]".yellow().bold());
268        return Ok(());
269    }
270    for (provider, c, d, _s) in &entries {
271        let leaf = c.rsplit_once('/').map(|(_, leaf)| leaf).unwrap_or(c);
272        println!(
273            "{} {}  {}",
274            "●".green(),
275            leaf.bold(),
276            format!("[{}]", c).dimmed()
277        );
278        println!("    provider   : {}", provider.dimmed());
279        if !d.is_empty() {
280            println!("    desc  : {}", d);
281        }
282    }
283    Ok(())
284}
285
286pub async fn channels(endpoint: &str) -> Result<()> {
287    let atlas = connect(endpoint).await?;
288    let raw = atlas
289        .inner()
290        .inspect_atlas(atlas_pb::InspectAtlasRequest {})
291        .await
292        .context("InspectAtlas RPC")?
293        .into_inner()
294        .json;
295    let v: Value = serde_json::from_str(&raw).context("parse inspect json")?;
296    let channels = v
297        .get("channels")
298        .and_then(|c| c.as_object())
299        .cloned()
300        .unwrap_or_default();
301    if channels.is_empty() {
302        println!("{} no active channels", "[channels]".yellow().bold());
303        return Ok(());
304    }
305    for (id, ch) in channels.iter() {
306        let consumer = ch
307            .get("consumer_id")
308            .and_then(|x| x.as_str())
309            .unwrap_or("?");
310        let provider = ch
311            .get("provider_id")
312            .and_then(|x| x.as_str())
313            .unwrap_or("?");
314        let contract = ch
315            .get("contract_id")
316            .and_then(|x| x.as_str())
317            .unwrap_or("?");
318        let transport = ch.get("transport").and_then(|x| x.as_str()).unwrap_or("?");
319        let endpoint = ch.get("endpoint").and_then(|x| x.as_str()).unwrap_or("?");
320        println!("{} {}", "●".green(), id.bold());
321        println!("    consumer : {}", consumer);
322        println!(
323            "    provider : {} ({} via {})",
324            provider, contract, transport
325        );
326        println!("    endpoint : {}", endpoint.dimmed());
327    }
328    Ok(())
329}
330
331pub async fn contracts(
332    endpoint: &str,
333    prefix: Option<&str>,
334    json: bool,
335    verbose: bool,
336) -> Result<()> {
337    let atlas = connect(endpoint).await?;
338    let resp = atlas
339        .inner()
340        .list_contracts(atlas_pb::ListContractsRequest {
341            namespace_prefix: prefix.unwrap_or("").to_string(),
342        })
343        .await
344        .context("ListContracts RPC")?
345        .into_inner();
346    if json {
347        let arr: Vec<Value> = resp
348            .contracts
349            .iter()
350            .map(|c| {
351                serde_json::json!({
352                    "id": c.id,
353                    "version": c.version,
354                    "kind": c.kind,
355                    "mode": c.mode,
356                    "io_msg_type": c.io_msg_type,
357                    "io_srv_type": c.io_srv_type,
358                    "cross_namespace": c.cross_namespace,
359                    "llm_callable": contract_llm_callable(c),
360                    "source_toml_path": c.source_toml_path,
361                    "msg_fields": c.msg_fields.iter().map(|f| serde_json::json!({
362                        "name": f.name, "type_name": f.type_name,
363                        "is_primitive": f.is_primitive, "is_array": f.is_array,
364                        "array_size": f.array_size,
365                    })).collect::<Vec<_>>(),
366                    "srv_request_fields": c.srv_request_fields.iter().map(|f| serde_json::json!({
367                        "name": f.name, "type_name": f.type_name,
368                        "is_primitive": f.is_primitive, "is_array": f.is_array,
369                        "array_size": f.array_size,
370                    })).collect::<Vec<_>>(),
371                    "srv_response_fields": c.srv_response_fields.iter().map(|f| serde_json::json!({
372                        "name": f.name, "type_name": f.type_name,
373                        "is_primitive": f.is_primitive, "is_array": f.is_array,
374                        "array_size": f.array_size,
375                    })).collect::<Vec<_>>(),
376                })
377            })
378            .collect();
379        println!("{}", serde_json::to_string_pretty(&arr)?);
380        return Ok(());
381    }
382    if resp.contracts.is_empty() {
383        let label = match prefix {
384            Some(p) if !p.is_empty() => format!(" with prefix '{p}'"),
385            _ => String::new(),
386        };
387        println!(
388            "{} no contracts loaded{label}",
389            "[contracts]".yellow().bold()
390        );
391        return Ok(());
392    }
393    for c in &resp.contracts {
394        let io = if !c.io_msg_type.is_empty() {
395            c.io_msg_type.clone()
396        } else if !c.io_srv_type.is_empty() {
397            c.io_srv_type.clone()
398        } else {
399            "(none)".dimmed().to_string()
400        };
401        println!(
402            "● {}  {} {} {}{}{}",
403            c.id.bold(),
404            format!("[{}]", c.kind).dimmed(),
405            format!("mode={}", c.mode).cyan(),
406            format!("idl={io}").dimmed(),
407            if c.cross_namespace {
408                " cross-namespace".dimmed().to_string()
409            } else {
410                String::new()
411            },
412            if contract_llm_callable(c) {
413                String::new()
414            } else {
415                " pilot-hidden".dimmed().to_string()
416            },
417        );
418        if verbose {
419            if !c.msg_fields.is_empty() {
420                for f in &c.msg_fields {
421                    let arr = if f.is_array {
422                        if f.array_size == 0 {
423                            "[]".to_string()
424                        } else {
425                            format!("[{}]", f.array_size)
426                        }
427                    } else {
428                        String::new()
429                    };
430                    println!("    {} : {}{arr}", f.name, f.type_name);
431                }
432            }
433            if !c.srv_request_fields.is_empty() || !c.srv_response_fields.is_empty() {
434                println!("    {}", "request:".dimmed());
435                for f in &c.srv_request_fields {
436                    println!("      {} : {}", f.name, f.type_name);
437                }
438                println!("    {}", "response:".dimmed());
439                for f in &c.srv_response_fields {
440                    println!("      {} : {}", f.name, f.type_name);
441                }
442            }
443            if !c.source_toml_path.is_empty() {
444                println!("    {} {}", "src:".dimmed(), c.source_toml_path.dimmed());
445            }
446        }
447    }
448    if !verbose {
449        println!(
450            "\n{} {} contract(s); pass -v for field schemas + source paths",
451            "[contracts]".green().bold(),
452            resp.contracts.len()
453        );
454    }
455    Ok(())
456}
457
458/// Interpret absent metadata as visible for compatibility with older Atlas
459/// processes, whose wire descriptor predates `llm_callable`.
460fn contract_llm_callable(contract: &atlas_pb::ContractDescriptor) -> bool {
461    contract.llm_callable.unwrap_or(true)
462}
463
464pub async fn inspect(endpoint: &str) -> Result<()> {
465    let atlas = connect(endpoint).await?;
466    let raw = atlas
467        .inner()
468        .inspect_atlas(atlas_pb::InspectAtlasRequest {})
469        .await
470        .context("InspectAtlas RPC")?
471        .into_inner()
472        .json;
473    println!("{raw}");
474    Ok(())
475}
476
477#[cfg(test)]
478mod tests {
479    use super::contract_llm_callable;
480    use robonix_atlas::pb as atlas_pb;
481
482    #[test]
483    fn absent_contract_visibility_defaults_to_visible() {
484        assert!(contract_llm_callable(
485            &atlas_pb::ContractDescriptor::default()
486        ));
487        assert!(!contract_llm_callable(&atlas_pb::ContractDescriptor {
488            llm_callable: Some(false),
489            ..Default::default()
490        }));
491    }
492}