1use 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
35fn 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 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 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 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}