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                    })).collect::<Vec<_>>(),
76                })
77            })
78            .collect();
79        println!("{}", serde_json::to_string_pretty(&serialised)?);
80        return Ok(());
81    }
82
83    if providers.is_empty() {
84        println!("{} no providers registered", "[providers]".yellow().bold());
85        return Ok(());
86    }
87    // Default: one row per provider, no capabilities. -v expands the
88    // capability list (lspci -tv style — quick scan vs full dump).
89    for provider in &providers {
90        let detail = if provider.state_detail.is_empty() {
91            String::new()
92        } else {
93            format!(" — {}", provider.state_detail)
94        };
95        let cap_count_hint = if verbose {
96            String::new()
97        } else {
98            format!(" ({} caps)", provider.capabilities.len())
99                .dimmed()
100                .to_string()
101        };
102        println!(
103            "{} {} {} {}{}{}",
104            "●".green(),
105            provider.id.bold(),
106            state_tag(provider.state),
107            provider.namespace.dimmed(),
108            cap_count_hint,
109            detail.dimmed()
110        );
111        if verbose {
112            for cap in &provider.capabilities {
113                println!(
114                    "    {} {} {}",
115                    "└─".dimmed(),
116                    cap.contract_id,
117                    format!("({})", transport_name(cap.transport)).dimmed()
118                );
119            }
120        }
121    }
122    if !verbose {
123        println!(
124            "\n{} pass {} for the per-provider capability list",
125            "tip:".dimmed(),
126            "-v".bold()
127        );
128    }
129    Ok(())
130}
131
132pub async fn describe(endpoint: &str, provider_id: Option<&str>, json: bool) -> Result<()> {
133    let mut atlas = connect(endpoint).await?;
134    let cap_filter = provider_id.unwrap_or("");
135    let providers = atlas
136        .query_capabilities(cap_filter, "", atlas_pb::Transport::Unspecified)
137        .await?;
138    if providers.is_empty() {
139        println!("{} no matching providers", "[describe]".yellow().bold());
140        return Ok(());
141    }
142    for provider in &providers {
143        // Atlas only stores the CAPABILITY.md path; consumers read the
144        // file off the local filesystem themselves.
145        let md = if provider.capability_md_path.is_empty() {
146            String::new()
147        } else {
148            std::fs::read_to_string(&provider.capability_md_path).unwrap_or_default()
149        };
150        if json {
151            let value = serde_json::json!({
152                "provider_id":   provider.id,
153                "namespace":       provider.namespace,
154                "state":           state_name(provider.state),
155                "capabilities":      provider.capabilities.iter().map(|i| serde_json::json!({
156                    "contract_id": i.contract_id,
157                    "transport":   transport_name(i.transport),
158                })).collect::<Vec<_>>(),
159                "capability_md":   md,
160            });
161            println!("{}", serde_json::to_string_pretty(&value)?);
162        } else {
163            println!(
164                "{} {} {}",
165                "●".green(),
166                provider.id.bold(),
167                state_tag(provider.state)
168            );
169            for cap in &provider.capabilities {
170                println!(
171                    "    {} {} ({})",
172                    "└─".dimmed(),
173                    cap.contract_id,
174                    transport_name(cap.transport)
175                );
176            }
177            if !md.is_empty() {
178                println!("\n{}", md);
179            }
180        }
181    }
182    Ok(())
183}
184
185pub async fn tools(endpoint: &str, json: bool) -> Result<()> {
186    // "Tools" in Robonix-speak = MCP-transport capabilities (LLM-callable providers).
187    let mut atlas = connect(endpoint).await?;
188    let providers = atlas
189        .query_capabilities("", "", atlas_pb::Transport::Mcp)
190        .await?;
191    let mut entries: Vec<(String, String, String, String)> = Vec::new();
192    for provider in &providers {
193        for cap in &provider.capabilities {
194            if cap.transport != atlas_pb::Transport::Mcp as i32 {
195                continue;
196            }
197            let schema = match cap.params.as_ref().and_then(|p| p.kind.as_ref()) {
198                Some(atlas_pb::transport_params::Kind::Mcp(m)) => m.input_schema_json.clone(),
199                _ => String::new(),
200            };
201            let description = cap.description.clone();
202            entries.push((
203                provider.id.clone(),
204                cap.contract_id.clone(),
205                description,
206                schema,
207            ));
208        }
209    }
210    if json {
211        let serialised: Vec<_> = entries
212            .iter()
213            .map(|(provider, c, d, s)| {
214                serde_json::json!({
215                    "provider_id":            provider,
216                    "contract_id":       c,
217                    "description":       d,
218                    "input_schema_json": s,
219                })
220            })
221            .collect();
222        println!("{}", serde_json::to_string_pretty(&serialised)?);
223        return Ok(());
224    }
225    if entries.is_empty() {
226        println!("{} no MCP tools registered", "[tools]".yellow().bold());
227        return Ok(());
228    }
229    for (provider, c, d, _s) in &entries {
230        let leaf = c.rsplit_once('/').map(|(_, leaf)| leaf).unwrap_or(c);
231        println!(
232            "{} {}  {}",
233            "●".green(),
234            leaf.bold(),
235            format!("[{}]", c).dimmed()
236        );
237        println!("    provider   : {}", provider.dimmed());
238        if !d.is_empty() {
239            println!("    desc  : {}", d);
240        }
241    }
242    Ok(())
243}
244
245pub async fn channels(endpoint: &str) -> Result<()> {
246    let atlas = connect(endpoint).await?;
247    let raw = atlas
248        .inner()
249        .inspect_atlas(atlas_pb::InspectAtlasRequest {})
250        .await
251        .context("InspectAtlas RPC")?
252        .into_inner()
253        .json;
254    let v: Value = serde_json::from_str(&raw).context("parse inspect json")?;
255    let channels = v
256        .get("channels")
257        .and_then(|c| c.as_object())
258        .cloned()
259        .unwrap_or_default();
260    if channels.is_empty() {
261        println!("{} no active channels", "[channels]".yellow().bold());
262        return Ok(());
263    }
264    for (id, ch) in channels.iter() {
265        let consumer = ch
266            .get("consumer_id")
267            .and_then(|x| x.as_str())
268            .unwrap_or("?");
269        let provider = ch
270            .get("provider_id")
271            .and_then(|x| x.as_str())
272            .unwrap_or("?");
273        let contract = ch
274            .get("contract_id")
275            .and_then(|x| x.as_str())
276            .unwrap_or("?");
277        let transport = ch.get("transport").and_then(|x| x.as_str()).unwrap_or("?");
278        let endpoint = ch.get("endpoint").and_then(|x| x.as_str()).unwrap_or("?");
279        println!("{} {}", "●".green(), id.bold());
280        println!("    consumer : {}", consumer);
281        println!(
282            "    provider : {} ({} via {})",
283            provider, contract, transport
284        );
285        println!("    endpoint : {}", endpoint.dimmed());
286    }
287    Ok(())
288}
289
290pub async fn contracts(
291    endpoint: &str,
292    prefix: Option<&str>,
293    json: bool,
294    verbose: bool,
295) -> Result<()> {
296    let atlas = connect(endpoint).await?;
297    let resp = atlas
298        .inner()
299        .list_contracts(atlas_pb::ListContractsRequest {
300            namespace_prefix: prefix.unwrap_or("").to_string(),
301        })
302        .await
303        .context("ListContracts RPC")?
304        .into_inner();
305    if json {
306        let arr: Vec<Value> = resp
307            .contracts
308            .iter()
309            .map(|c| {
310                serde_json::json!({
311                    "id": c.id,
312                    "version": c.version,
313                    "kind": c.kind,
314                    "mode": c.mode,
315                    "io_msg_type": c.io_msg_type,
316                    "io_srv_type": c.io_srv_type,
317                    "source_toml_path": c.source_toml_path,
318                    "msg_fields": c.msg_fields.iter().map(|f| serde_json::json!({
319                        "name": f.name, "type_name": f.type_name,
320                        "is_primitive": f.is_primitive, "is_array": f.is_array,
321                        "array_size": f.array_size,
322                    })).collect::<Vec<_>>(),
323                    "srv_request_fields": c.srv_request_fields.iter().map(|f| serde_json::json!({
324                        "name": f.name, "type_name": f.type_name,
325                        "is_primitive": f.is_primitive, "is_array": f.is_array,
326                        "array_size": f.array_size,
327                    })).collect::<Vec<_>>(),
328                    "srv_response_fields": c.srv_response_fields.iter().map(|f| serde_json::json!({
329                        "name": f.name, "type_name": f.type_name,
330                        "is_primitive": f.is_primitive, "is_array": f.is_array,
331                        "array_size": f.array_size,
332                    })).collect::<Vec<_>>(),
333                })
334            })
335            .collect();
336        println!("{}", serde_json::to_string_pretty(&arr)?);
337        return Ok(());
338    }
339    if resp.contracts.is_empty() {
340        let label = match prefix {
341            Some(p) if !p.is_empty() => format!(" with prefix '{p}'"),
342            _ => String::new(),
343        };
344        println!(
345            "{} no contracts loaded{label}",
346            "[contracts]".yellow().bold()
347        );
348        return Ok(());
349    }
350    for c in &resp.contracts {
351        let io = if !c.io_msg_type.is_empty() {
352            c.io_msg_type.clone()
353        } else if !c.io_srv_type.is_empty() {
354            c.io_srv_type.clone()
355        } else {
356            "(none)".dimmed().to_string()
357        };
358        println!(
359            "● {}  {} {} {}",
360            c.id.bold(),
361            format!("[{}]", c.kind).dimmed(),
362            format!("mode={}", c.mode).cyan(),
363            format!("idl={io}").dimmed(),
364        );
365        if verbose {
366            if !c.msg_fields.is_empty() {
367                for f in &c.msg_fields {
368                    let arr = if f.is_array {
369                        if f.array_size == 0 {
370                            "[]".to_string()
371                        } else {
372                            format!("[{}]", f.array_size)
373                        }
374                    } else {
375                        String::new()
376                    };
377                    println!("    {} : {}{arr}", f.name, f.type_name);
378                }
379            }
380            if !c.srv_request_fields.is_empty() || !c.srv_response_fields.is_empty() {
381                println!("    {}", "request:".dimmed());
382                for f in &c.srv_request_fields {
383                    println!("      {} : {}", f.name, f.type_name);
384                }
385                println!("    {}", "response:".dimmed());
386                for f in &c.srv_response_fields {
387                    println!("      {} : {}", f.name, f.type_name);
388                }
389            }
390            if !c.source_toml_path.is_empty() {
391                println!("    {} {}", "src:".dimmed(), c.source_toml_path.dimmed());
392            }
393        }
394    }
395    if !verbose {
396        println!(
397            "\n{} {} contract(s); pass -v for field schemas + source paths",
398            "[contracts]".green().bold(),
399            resp.contracts.len()
400        );
401    }
402    Ok(())
403}
404
405pub async fn inspect(endpoint: &str) -> Result<()> {
406    let atlas = connect(endpoint).await?;
407    let raw = atlas
408        .inner()
409        .inspect_atlas(atlas_pb::InspectAtlasRequest {})
410        .await
411        .context("InspectAtlas RPC")?
412        .into_inner()
413        .json;
414    println!("{raw}");
415    Ok(())
416}