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?;
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
458fn 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}