1use anyhow::{Context, bail};
17use clap::{Args, Subcommand};
18use rmcp::{
19 ServiceExt,
20 model::{CallToolRequestParams, ClientInfo},
21 transport::{
22 StreamableHttpClientTransport, streamable_http_client::StreamableHttpClientTransportConfig,
23 },
24};
25use serde_json::{Value, json};
26
27#[derive(Args)]
28pub struct ClientArgs {
29 #[arg(long, env = "BUS_URL", default_value = "http://localhost:8787/mcp")]
31 pub url: String,
32
33 #[arg(long, env = "BUS_TOKEN", hide_env_values = true)]
35 pub token: String,
36
37 #[arg(long, global = true)]
39 pub json: bool,
40
41 #[command(subcommand)]
42 pub command: ClientCmd,
43}
44
45#[derive(Subcommand)]
46pub enum ClientCmd {
47 Whoami,
49 Tools,
51 Send {
53 #[arg(long, conflicts_with = "to")]
54 channel: Option<String>,
55 #[arg(long)]
56 to: Option<String>,
57 #[arg(long)]
58 body: String,
59 #[arg(long)]
60 reply_to: Option<i64>,
61 },
62 Ask {
64 to: String,
65 question: Option<String>,
67 #[arg(long)]
68 timeout_seconds: Option<i64>,
69 #[arg(long)]
71 resume_id: Option<i64>,
72 },
73 Read {
75 #[arg(long, default_value = "all")]
76 scope: String,
77 #[arg(long)]
79 history: bool,
80 #[arg(long, default_value_t = 50)]
81 limit: i64,
82 },
83 Search {
85 query: String,
86 #[arg(long, default_value_t = 50)]
87 limit: i64,
88 },
89 Channels,
91 ChannelCreate {
93 name: String,
94 #[arg(long)]
95 topic: Option<String>,
96 },
97 Agents {
99 #[arg(long)]
100 online: bool,
101 },
102 Beat {
104 #[arg(long)]
105 status: Option<String>,
106 #[arg(long)]
107 repo: Option<String>,
108 #[arg(long)]
109 branch: Option<String>,
110 #[arg(long)]
111 activity: Option<String>,
112 #[arg(long)]
113 ttl_seconds: Option<i64>,
114 },
115 Tasks {
117 #[arg(long)]
118 status: Option<String>,
119 #[arg(long)]
120 mine: bool,
121 },
122 #[command(subcommand)]
124 Task(TaskCmd),
125 Notes {
127 #[arg(long)]
128 scope: Option<String>,
129 #[arg(long)]
130 tag: Option<String>,
131 },
132 #[command(subcommand)]
134 Note(NoteCmd),
135 Wait {
137 #[arg(long)]
138 timeout_seconds: Option<i64>,
139 #[arg(long, value_delimiter = ',')]
141 kinds: Vec<String>,
142 },
143 #[command(subcommand)]
145 Lock(LockCmd),
146 Digest {
148 #[arg(long, default_value_t = 24)]
149 hours: i64,
150 },
151 Call {
153 tool: String,
154 #[arg(long, default_value = "{}")]
156 args: String,
157 },
158}
159
160#[derive(Subcommand)]
161pub enum LockCmd {
162 Acquire {
163 name: String,
164 #[arg(long)]
165 ttl_seconds: Option<i64>,
166 #[arg(long)]
167 purpose: Option<String>,
168 },
169 Release {
170 name: String,
171 },
172 List,
173}
174
175#[derive(Subcommand)]
176pub enum TaskCmd {
177 Create {
178 key: String,
179 #[arg(long)]
180 title: String,
181 #[arg(long)]
182 description: Option<String>,
183 #[arg(long, value_delimiter = ',')]
185 depends_on: Vec<String>,
186 },
187 Show {
188 key: String,
189 },
190 Claim {
191 key: String,
192 #[arg(long)]
193 lease_seconds: Option<i64>,
194 },
195 Next {
197 #[arg(long)]
198 lease_seconds: Option<i64>,
199 },
200 Renew {
201 key: String,
202 #[arg(long)]
203 lease_seconds: Option<i64>,
204 },
205 Release {
206 key: String,
207 },
208 Done {
209 key: String,
210 #[arg(long)]
211 result: Option<String>,
212 },
213}
214
215#[derive(Subcommand)]
216pub enum NoteCmd {
217 Get {
218 key: String,
219 #[arg(long)]
220 scope: Option<String>,
221 },
222 Set {
223 key: String,
224 #[arg(long)]
225 value: String,
226 #[arg(long)]
227 scope: Option<String>,
228 #[arg(long, value_delimiter = ',')]
229 tags: Vec<String>,
230 },
231 Rm {
232 key: String,
233 #[arg(long)]
234 scope: Option<String>,
235 },
236 Search {
237 query: String,
238 #[arg(long)]
239 scope: Option<String>,
240 },
241}
242
243fn compact(value: Value) -> Value {
245 match value {
246 Value::Object(map) => Value::Object(
247 map.into_iter()
248 .filter(|(_, v)| !v.is_null())
249 .map(|(k, v)| (k, compact(v)))
250 .collect(),
251 ),
252 other => other,
253 }
254}
255
256fn to_call(cmd: &ClientCmd) -> Option<(&'static str, Value)> {
257 let (tool, args): (&str, Value) = match cmd {
258 ClientCmd::Whoami => ("whoami", json!({})),
259 ClientCmd::Tools => return None,
260 ClientCmd::Send {
261 channel,
262 to,
263 body,
264 reply_to,
265 } => (
266 "post_message",
267 json!({"channel": channel, "to": to, "body": body, "reply_to": reply_to}),
268 ),
269 ClientCmd::Ask {
270 to,
271 question,
272 timeout_seconds,
273 resume_id,
274 } => (
275 "ask_agent",
276 json!({
277 "to": to, "question": question,
278 "timeout_seconds": timeout_seconds, "resume_message_id": resume_id
279 }),
280 ),
281 ClientCmd::Read {
282 scope,
283 history,
284 limit,
285 } => (
286 "read_messages",
287 json!({"scope": scope, "only_new": !history, "limit": limit}),
288 ),
289 ClientCmd::Search { query, limit } => {
290 ("search_messages", json!({"query": query, "limit": limit}))
291 }
292 ClientCmd::Channels => ("list_channels", json!({})),
293 ClientCmd::ChannelCreate { name, topic } => {
294 ("create_channel", json!({"name": name, "topic": topic}))
295 }
296 ClientCmd::Agents { online } => ("list_agents", json!({"online_only": online})),
297 ClientCmd::Beat {
298 status,
299 repo,
300 branch,
301 activity,
302 ttl_seconds,
303 } => (
304 "heartbeat",
305 json!({
306 "status": status, "repo": repo, "branch": branch,
307 "activity": activity, "ttl_seconds": ttl_seconds
308 }),
309 ),
310 ClientCmd::Tasks { status, mine } => {
311 ("list_tasks", json!({"status": status, "mine_only": mine}))
312 }
313 ClientCmd::Wait {
314 timeout_seconds,
315 kinds,
316 } => (
317 "wait_for_updates",
318 json!({
319 "timeout_seconds": timeout_seconds,
320 "kinds": if kinds.is_empty() { Value::Null } else { json!(kinds) }
321 }),
322 ),
323 ClientCmd::Digest { hours } => ("team_digest", json!({"hours": hours})),
324 ClientCmd::Lock(lock) => match lock {
325 LockCmd::Acquire {
326 name,
327 ttl_seconds,
328 purpose,
329 } => (
330 "acquire_lock",
331 json!({"name": name, "ttl_seconds": ttl_seconds, "purpose": purpose}),
332 ),
333 LockCmd::Release { name } => ("release_lock", json!({"name": name})),
334 LockCmd::List => ("list_locks", json!({})),
335 },
336 ClientCmd::Task(task) => match task {
337 TaskCmd::Create {
338 key,
339 title,
340 description,
341 depends_on,
342 } => (
343 "create_task",
344 json!({
345 "key": key, "title": title, "description": description,
346 "depends_on": if depends_on.is_empty() { Value::Null } else { json!(depends_on) }
347 }),
348 ),
349 TaskCmd::Show { key } => ("get_task", json!({"key": key})),
350 TaskCmd::Claim { key, lease_seconds } => (
351 "claim_task",
352 json!({"key": key, "lease_seconds": lease_seconds}),
353 ),
354 TaskCmd::Next { lease_seconds } => {
355 ("claim_next_task", json!({"lease_seconds": lease_seconds}))
356 }
357 TaskCmd::Renew { key, lease_seconds } => (
358 "renew_task_lease",
359 json!({"key": key, "lease_seconds": lease_seconds}),
360 ),
361 TaskCmd::Release { key } => ("release_task", json!({"key": key})),
362 TaskCmd::Done { key, result } => {
363 ("complete_task", json!({"key": key, "result": result}))
364 }
365 },
366 ClientCmd::Notes { scope, tag } => ("list_notes", json!({"scope": scope, "tag": tag})),
367 ClientCmd::Note(note) => match note {
368 NoteCmd::Get { key, scope } => ("get_note", json!({"key": key, "scope": scope})),
369 NoteCmd::Set {
370 key,
371 value,
372 scope,
373 tags,
374 } => (
375 "set_note",
376 json!({
377 "key": key, "value": value, "scope": scope,
378 "tags": if tags.is_empty() { Value::Null } else { json!(tags) }
379 }),
380 ),
381 NoteCmd::Rm { key, scope } => ("delete_note", json!({"key": key, "scope": scope})),
382 NoteCmd::Search { query, scope } => {
383 ("search_notes", json!({"query": query, "scope": scope}))
384 }
385 },
386 ClientCmd::Call { .. } => unreachable!("handled by caller"),
387 };
388 Some((tool, compact(args)))
389}
390
391pub async fn run(args: ClientArgs) -> anyhow::Result<()> {
392 let mut config = StreamableHttpClientTransportConfig::with_uri(args.url.clone());
393 config.auth_header = Some(args.token.clone());
394 config.allow_stateless = true;
395 let transport = StreamableHttpClientTransport::from_config(config);
396
397 let client = ClientInfo::default()
398 .serve(transport)
399 .await
400 .context("could not connect to the bus (check --url and --token)")?;
401
402 let outcome = run_command(&client, &args).await;
403 let _ = client.cancel().await;
404 outcome
405}
406
407async fn run_command(
408 client: &rmcp::service::RunningService<rmcp::RoleClient, ClientInfo>,
409 args: &ClientArgs,
410) -> anyhow::Result<()> {
411 if matches!(args.command, ClientCmd::Tools) {
413 let tools = client.list_all_tools().await?;
414 if args.json {
415 println!("{}", serde_json::to_string_pretty(&tools)?);
416 } else {
417 for tool in tools {
418 println!(
419 "{:<20} {}",
420 tool.name,
421 tool.description.as_deref().unwrap_or_default().trim()
422 );
423 }
424 }
425 return Ok(());
426 }
427
428 let (tool, call_args) = match &args.command {
429 ClientCmd::Call { tool, args: raw } => {
430 let parsed: Value = serde_json::from_str(raw)
431 .with_context(|| format!("--args is not valid JSON: {raw}"))?;
432 if !parsed.is_object() {
433 bail!("--args must be a JSON object");
434 }
435 (tool.clone(), parsed)
436 }
437 other => {
438 let (tool, call_args) = to_call(other).expect("tools handled above");
439 (tool.to_string(), call_args)
440 }
441 };
442
443 let arguments: serde_json::Map<String, Value> =
444 serde_json::from_value(call_args).expect("compact() always yields an object");
445 let result = client
446 .call_tool(CallToolRequestParams::new(tool.clone()).with_arguments(arguments))
447 .await
448 .map_err(|e| anyhow::anyhow!("{tool} failed: {e}"))?;
449
450 if result.is_error == Some(true) {
451 bail!("{tool} returned an error: {:?}", result.content);
452 }
453 let value = result
454 .structured_content
455 .clone()
456 .unwrap_or_else(|| json!({ "ok": true }));
457
458 if args.json {
459 println!("{}", serde_json::to_string_pretty(&value)?);
460 } else {
461 render(&args.command, &value);
462 }
463 Ok(())
464}
465
466fn field<'v>(v: &'v Value, key: &str) -> &'v str {
469 v.get(key).and_then(Value::as_str).unwrap_or("")
470}
471
472fn render_messages(value: &Value) {
473 let messages = value["messages"].as_array().cloned().unwrap_or_default();
474 if messages.is_empty() {
475 println!("(no messages)");
476 return;
477 }
478 for m in &messages {
479 let target = m["channel"]
480 .as_str()
481 .map(|c| format!("#{c}"))
482 .or_else(|| m["to"].as_str().map(|t| format!("@{t}")))
483 .unwrap_or_default();
484 println!(
485 "[{}] {} {} → {}: {}",
486 m["id"],
487 field(m, "created_at"),
488 field(m, "from"),
489 target,
490 field(m, "body"),
491 );
492 }
493 if value["truncated"].as_bool() == Some(true) {
494 println!("(truncated at limit; raise --limit to see more)");
495 }
496}
497
498fn render_task_line(t: &Value) {
499 let holder = t["claimed_by"]
500 .as_str()
501 .map(|h| format!(" ({h})"))
502 .unwrap_or_default();
503 let expired = if t["lease_expired"].as_bool() == Some(true) {
504 " [lease expired]"
505 } else {
506 ""
507 };
508 println!(
509 "{:<24} {:<8}{}{} {}",
510 field(t, "key"),
511 field(t, "status"),
512 holder,
513 expired,
514 field(t, "title"),
515 );
516}
517
518fn render(cmd: &ClientCmd, value: &Value) {
519 match cmd {
520 ClientCmd::Whoami => {
521 println!(
522 "{} @ {} — {} unread DM(s), {} claimed task(s)",
523 field(value, "agent"),
524 field(value, "team"),
525 value["unread_direct_messages"],
526 value["open_claimed_tasks"],
527 );
528 }
529 ClientCmd::Read { .. } | ClientCmd::Search { .. } => render_messages(value),
530 ClientCmd::Send { .. } => {
531 let m = &value["message"];
532 println!("sent [{}] to {}", m["id"], value["delivered_to"]);
533 }
534 ClientCmd::Ask { .. } => {
535 if value["answered"].as_bool() == Some(true) {
536 let a = &value["answer"];
537 println!("{}: {}", field(a, "from"), field(a, "body"));
538 } else {
539 println!("(no answer yet)");
540 println!("{}", field(value, "suggestion"));
541 }
542 }
543 ClientCmd::ChannelCreate { .. } => {
544 println!("channel #{} ready", field(value, "name"));
545 }
546 ClientCmd::Channels => {
547 let channels = value["channels"].as_array().cloned().unwrap_or_default();
548 if channels.is_empty() {
549 println!("(no channels yet)");
550 }
551 for c in &channels {
552 println!(
553 "#{:<20} {:>5} msg(s) {}",
554 field(c, "name"),
555 c["message_count"],
556 field(c, "topic"),
557 );
558 }
559 }
560 ClientCmd::Agents { .. } => {
561 for a in value["agents"].as_array().cloned().unwrap_or_default() {
562 let place = match (a["repo"].as_str(), a["branch"].as_str()) {
563 (Some(r), Some(b)) => format!(" {r}@{b}"),
564 (Some(r), None) => format!(" {r}"),
565 _ => String::new(),
566 };
567 println!(
568 "{:<20} {:<8}{} {}",
569 field(&a, "name"),
570 field(&a, "status"),
571 place,
572 field(&a, "activity"),
573 );
574 }
575 println!("({} online)", value["online_count"]);
576 }
577 ClientCmd::Tasks { .. } => {
578 let tasks = value["tasks"].as_array().cloned().unwrap_or_default();
579 if tasks.is_empty() {
580 println!("(no tasks)");
581 }
582 for t in &tasks {
583 render_task_line(t);
584 }
585 println!("({} open, {} claimed)", value["open"], value["claimed"]);
586 }
587 ClientCmd::Task(TaskCmd::Show { .. }) => {
588 render_task_line(&value["task"]);
589 if let Some(desc) = value["task"]["description"].as_str() {
590 println!(" {desc}");
591 }
592 if let Some(result) = value["task"]["result"].as_str() {
593 println!(" result: {result}");
594 }
595 for e in value["history"].as_array().cloned().unwrap_or_default() {
596 println!(
597 " {} {} {} {}",
598 field(&e, "created_at"),
599 field(&e, "event"),
600 field(&e, "agent"),
601 field(&e, "detail"),
602 );
603 }
604 }
605 ClientCmd::Task(TaskCmd::Claim { .. } | TaskCmd::Next { .. }) => {
606 if value["claimed"].as_bool() == Some(true) {
607 print!("claimed: ");
608 render_task_line(&value["task"]);
609 } else {
610 println!(
611 "not claimed: {}",
612 value["reason"].as_str().unwrap_or("unknown reason")
613 );
614 }
615 }
616 ClientCmd::Task(_) => {
617 render_task_line(value);
618 }
619 ClientCmd::Notes { .. } | ClientCmd::Note(NoteCmd::Search { .. }) => {
620 let notes = value["notes"].as_array().cloned().unwrap_or_default();
621 if notes.is_empty() {
622 println!("(no notes)");
623 }
624 for n in ¬es {
625 let first_line = field(n, "value").lines().next().unwrap_or("").to_owned();
626 println!("{}/{}: {}", field(n, "scope"), field(n, "key"), first_line);
627 }
628 }
629 ClientCmd::Note(NoteCmd::Get { .. }) => {
630 if value["found"].as_bool() == Some(true) {
631 let n = &value["note"];
632 println!(
633 "{}/{} (by {}, {})",
634 field(n, "scope"),
635 field(n, "key"),
636 field(n, "updated_by"),
637 field(n, "updated_at"),
638 );
639 println!("{}", field(n, "value"));
640 } else {
641 println!("not found");
642 }
643 }
644 ClientCmd::Note(NoteCmd::Set { .. }) => {
645 println!("saved {}/{}", field(value, "scope"), field(value, "key"));
646 }
647 ClientCmd::Note(NoteCmd::Rm { .. }) => {
648 println!("{}", field(value, "detail"));
649 }
650 ClientCmd::Beat { .. } => {
651 println!(
652 "presence updated: {} {}",
653 field(value, "status"),
654 field(value, "activity"),
655 );
656 }
657 ClientCmd::Wait { .. } => {
658 if value["woke"].as_bool() == Some(true) {
659 for e in value["events"].as_array().cloned().unwrap_or_default() {
660 println!("[{}] {}", field(&e, "kind"), field(&e, "summary"));
661 }
662 } else {
663 println!("(nothing happened)");
664 }
665 println!("{}", field(value, "suggestion"));
666 }
667 ClientCmd::Lock(LockCmd::List) => {
668 let locks = value["locks"].as_array().cloned().unwrap_or_default();
669 if locks.is_empty() {
670 println!("(no locks held)");
671 }
672 for l in &locks {
673 println!(
674 "{:<28} {} until {} {}",
675 field(l, "name"),
676 field(l, "holder"),
677 field(l, "expires_at"),
678 field(l, "purpose"),
679 );
680 }
681 }
682 ClientCmd::Lock(LockCmd::Acquire { .. }) => {
683 if value["acquired"].as_bool() == Some(true) {
684 let l = &value["lock"];
685 println!(
686 "acquired {} until {}",
687 field(l, "name"),
688 field(l, "expires_at")
689 );
690 } else {
691 println!("not acquired: {}", field(value, "reason"));
692 }
693 }
694 ClientCmd::Lock(LockCmd::Release { .. }) => {
695 println!("{}", field(value, "detail"));
696 }
697 ClientCmd::Digest { .. } => {
698 println!("— last {}h —", value["hours"]);
699 for c in value["channels"].as_array().cloned().unwrap_or_default() {
700 println!("#{}: {} msg(s)", field(&c, "name"), c["message_count"]);
701 for m in c["last_messages"].as_array().cloned().unwrap_or_default() {
702 println!(" {}: {}", field(&m, "from"), field(&m, "body"));
703 }
704 }
705 let moved = value["tasks_moved"].as_array().cloned().unwrap_or_default();
706 if !moved.is_empty() {
707 println!("tasks:");
708 for t in &moved {
709 println!(
710 " {} → {} {}",
711 field(t, "key"),
712 field(t, "status"),
713 t["claimed_by"]
714 .as_str()
715 .map(|s| format!("({s})"))
716 .unwrap_or_default(),
717 );
718 }
719 }
720 for n in value["notes_updated"]
721 .as_array()
722 .cloned()
723 .unwrap_or_default()
724 {
725 println!(
726 "note {}/{} by {}",
727 field(&n, "scope"),
728 field(&n, "key"),
729 field(&n, "updated_by")
730 );
731 }
732 println!(
733 "({} open, {} claimed, {} lock(s))",
734 value["open_tasks"],
735 value["claimed_tasks"],
736 value["active_locks"].as_array().map(Vec::len).unwrap_or(0),
737 );
738 }
739 _ => {
740 println!("{}", serde_json::to_string_pretty(value).unwrap());
741 }
742 }
743}