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" in Robonix-speak = MCP-transport capabilities (LLM-callable providers).
211    let mut atlas = connect(endpoint).await?;
212    let providers = atlas
213        .query_capabilities("", "", atlas_pb::Transport::Mcp)
214        .await?;
215    let mut entries: Vec<(String, String, String, String)> = Vec::new();
216    for provider in &providers {
217        for cap in &provider.capabilities {
218            if cap.transport != atlas_pb::Transport::Mcp as i32 {
219                continue;
220            }
221            let schema = match cap.params.as_ref().and_then(|p| p.kind.as_ref()) {
222                Some(atlas_pb::transport_params::Kind::Mcp(m)) => m.input_schema_json.clone(),
223                _ => String::new(),
224            };
225            let description = cap.description.clone();
226            entries.push((
227                provider.id.clone(),
228                cap.contract_id.clone(),
229                description,
230                schema,
231            ));
232        }
233    }
234    if json {
235        let serialised: Vec<_> = entries
236            .iter()
237            .map(|(provider, c, d, s)| {
238                serde_json::json!({
239                    "provider_id":            provider,
240                    "contract_id":       c,
241                    "description":       d,
242                    "input_schema_json": s,
243                })
244            })
245            .collect();
246        println!("{}", serde_json::to_string_pretty(&serialised)?);
247        return Ok(());
248    }
249    if entries.is_empty() {
250        println!("{} no MCP tools registered", "[tools]".yellow().bold());
251        return Ok(());
252    }
253    for (provider, c, d, _s) in &entries {
254        let leaf = c.rsplit_once('/').map(|(_, leaf)| leaf).unwrap_or(c);
255        println!(
256            "{} {}  {}",
257            "●".green(),
258            leaf.bold(),
259            format!("[{}]", c).dimmed()
260        );
261        println!("    provider   : {}", provider.dimmed());
262        if !d.is_empty() {
263            println!("    desc  : {}", d);
264        }
265    }
266    Ok(())
267}
268
269pub async fn channels(endpoint: &str) -> Result<()> {
270    let atlas = connect(endpoint).await?;
271    let raw = atlas
272        .inner()
273        .inspect_atlas(atlas_pb::InspectAtlasRequest {})
274        .await
275        .context("InspectAtlas RPC")?
276        .into_inner()
277        .json;
278    let v: Value = serde_json::from_str(&raw).context("parse inspect json")?;
279    let channels = v
280        .get("channels")
281        .and_then(|c| c.as_object())
282        .cloned()
283        .unwrap_or_default();
284    if channels.is_empty() {
285        println!("{} no active channels", "[channels]".yellow().bold());
286        return Ok(());
287    }
288    for (id, ch) in channels.iter() {
289        let consumer = ch
290            .get("consumer_id")
291            .and_then(|x| x.as_str())
292            .unwrap_or("?");
293        let provider = ch
294            .get("provider_id")
295            .and_then(|x| x.as_str())
296            .unwrap_or("?");
297        let contract = ch
298            .get("contract_id")
299            .and_then(|x| x.as_str())
300            .unwrap_or("?");
301        let transport = ch.get("transport").and_then(|x| x.as_str()).unwrap_or("?");
302        let endpoint = ch.get("endpoint").and_then(|x| x.as_str()).unwrap_or("?");
303        println!("{} {}", "●".green(), id.bold());
304        println!("    consumer : {}", consumer);
305        println!(
306            "    provider : {} ({} via {})",
307            provider, contract, transport
308        );
309        println!("    endpoint : {}", endpoint.dimmed());
310    }
311    Ok(())
312}
313
314pub async fn contracts(
315    endpoint: &str,
316    prefix: Option<&str>,
317    json: bool,
318    verbose: bool,
319) -> Result<()> {
320    let atlas = connect(endpoint).await?;
321    let resp = atlas
322        .inner()
323        .list_contracts(atlas_pb::ListContractsRequest {
324            namespace_prefix: prefix.unwrap_or("").to_string(),
325        })
326        .await
327        .context("ListContracts RPC")?
328        .into_inner();
329    if json {
330        let arr: Vec<Value> = resp
331            .contracts
332            .iter()
333            .map(|c| {
334                serde_json::json!({
335                    "id": c.id,
336                    "version": c.version,
337                    "kind": c.kind,
338                    "mode": c.mode,
339                    "io_msg_type": c.io_msg_type,
340                    "io_srv_type": c.io_srv_type,
341                    "cross_namespace": c.cross_namespace,
342                    "source_toml_path": c.source_toml_path,
343                    "msg_fields": c.msg_fields.iter().map(|f| serde_json::json!({
344                        "name": f.name, "type_name": f.type_name,
345                        "is_primitive": f.is_primitive, "is_array": f.is_array,
346                        "array_size": f.array_size,
347                    })).collect::<Vec<_>>(),
348                    "srv_request_fields": c.srv_request_fields.iter().map(|f| serde_json::json!({
349                        "name": f.name, "type_name": f.type_name,
350                        "is_primitive": f.is_primitive, "is_array": f.is_array,
351                        "array_size": f.array_size,
352                    })).collect::<Vec<_>>(),
353                    "srv_response_fields": c.srv_response_fields.iter().map(|f| serde_json::json!({
354                        "name": f.name, "type_name": f.type_name,
355                        "is_primitive": f.is_primitive, "is_array": f.is_array,
356                        "array_size": f.array_size,
357                    })).collect::<Vec<_>>(),
358                })
359            })
360            .collect();
361        println!("{}", serde_json::to_string_pretty(&arr)?);
362        return Ok(());
363    }
364    if resp.contracts.is_empty() {
365        let label = match prefix {
366            Some(p) if !p.is_empty() => format!(" with prefix '{p}'"),
367            _ => String::new(),
368        };
369        println!(
370            "{} no contracts loaded{label}",
371            "[contracts]".yellow().bold()
372        );
373        return Ok(());
374    }
375    for c in &resp.contracts {
376        let io = if !c.io_msg_type.is_empty() {
377            c.io_msg_type.clone()
378        } else if !c.io_srv_type.is_empty() {
379            c.io_srv_type.clone()
380        } else {
381            "(none)".dimmed().to_string()
382        };
383        println!(
384            "● {}  {} {} {}{}",
385            c.id.bold(),
386            format!("[{}]", c.kind).dimmed(),
387            format!("mode={}", c.mode).cyan(),
388            format!("idl={io}").dimmed(),
389            if c.cross_namespace {
390                " cross-namespace".dimmed().to_string()
391            } else {
392                String::new()
393            },
394        );
395        if verbose {
396            if !c.msg_fields.is_empty() {
397                for f in &c.msg_fields {
398                    let arr = if f.is_array {
399                        if f.array_size == 0 {
400                            "[]".to_string()
401                        } else {
402                            format!("[{}]", f.array_size)
403                        }
404                    } else {
405                        String::new()
406                    };
407                    println!("    {} : {}{arr}", f.name, f.type_name);
408                }
409            }
410            if !c.srv_request_fields.is_empty() || !c.srv_response_fields.is_empty() {
411                println!("    {}", "request:".dimmed());
412                for f in &c.srv_request_fields {
413                    println!("      {} : {}", f.name, f.type_name);
414                }
415                println!("    {}", "response:".dimmed());
416                for f in &c.srv_response_fields {
417                    println!("      {} : {}", f.name, f.type_name);
418                }
419            }
420            if !c.source_toml_path.is_empty() {
421                println!("    {} {}", "src:".dimmed(), c.source_toml_path.dimmed());
422            }
423        }
424    }
425    if !verbose {
426        println!(
427            "\n{} {} contract(s); pass -v for field schemas + source paths",
428            "[contracts]".green().bold(),
429            resp.contracts.len()
430        );
431    }
432    Ok(())
433}
434
435pub async fn inspect(endpoint: &str) -> Result<()> {
436    let atlas = connect(endpoint).await?;
437    let raw = atlas
438        .inner()
439        .inspect_atlas(atlas_pb::InspectAtlasRequest {})
440        .await
441        .context("InspectAtlas RPC")?
442        .into_inner()
443        .json;
444    println!("{raw}");
445    Ok(())
446}