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 #[arg(long)]
63 file: Vec<std::path::PathBuf>,
64 },
65 Attach {
67 task: String,
69 #[arg(long)]
70 file: std::path::PathBuf,
71 #[arg(long)]
72 content_type: Option<String>,
73 },
74 Download {
76 id: i64,
77 #[arg(long)]
79 out: Option<std::path::PathBuf>,
80 },
81 Ask {
83 to: String,
84 question: Option<String>,
86 #[arg(long)]
87 timeout_seconds: Option<i64>,
88 #[arg(long)]
90 resume_id: Option<i64>,
91 },
92 Read {
94 #[arg(long, default_value = "all")]
95 scope: String,
96 #[arg(long)]
98 history: bool,
99 #[arg(long, default_value_t = 50)]
100 limit: i64,
101 },
102 Search {
104 query: String,
105 #[arg(long, default_value_t = 50)]
106 limit: i64,
107 },
108 Channels,
110 ChannelCreate {
112 name: String,
113 #[arg(long)]
114 topic: Option<String>,
115 },
116 Agents {
118 #[arg(long)]
119 online: bool,
120 },
121 Beat {
123 #[arg(long)]
124 status: Option<String>,
125 #[arg(long)]
126 repo: Option<String>,
127 #[arg(long)]
128 branch: Option<String>,
129 #[arg(long)]
130 activity: Option<String>,
131 #[arg(long)]
132 ttl_seconds: Option<i64>,
133 },
134 Tasks {
136 #[arg(long)]
137 status: Option<String>,
138 #[arg(long)]
139 mine: bool,
140 },
141 #[command(subcommand)]
143 Task(TaskCmd),
144 Notes {
146 #[arg(long)]
147 scope: Option<String>,
148 #[arg(long)]
149 tag: Option<String>,
150 },
151 #[command(subcommand)]
153 Note(NoteCmd),
154 Wait {
156 #[arg(long)]
157 timeout_seconds: Option<i64>,
158 #[arg(long, value_delimiter = ',')]
160 kinds: Vec<String>,
161 },
162 #[command(subcommand)]
164 Lock(LockCmd),
165 Digest {
167 #[arg(long, default_value_t = 24)]
168 hours: i64,
169 },
170 Call {
172 tool: String,
173 #[arg(long, default_value = "{}")]
175 args: String,
176 },
177}
178
179#[derive(Subcommand)]
180pub enum LockCmd {
181 Acquire {
182 name: String,
183 #[arg(long)]
184 ttl_seconds: Option<i64>,
185 #[arg(long)]
186 purpose: Option<String>,
187 },
188 Release {
189 name: String,
190 },
191 List,
192}
193
194#[derive(Subcommand)]
195pub enum TaskCmd {
196 Create {
197 key: String,
198 #[arg(long)]
199 title: String,
200 #[arg(long)]
201 description: Option<String>,
202 #[arg(long, value_delimiter = ',')]
204 depends_on: Vec<String>,
205 },
206 Show {
207 key: String,
208 },
209 Claim {
210 key: String,
211 #[arg(long)]
212 lease_seconds: Option<i64>,
213 },
214 Next {
216 #[arg(long)]
217 lease_seconds: Option<i64>,
218 },
219 Renew {
220 key: String,
221 #[arg(long)]
222 lease_seconds: Option<i64>,
223 },
224 Release {
225 key: String,
226 },
227 Done {
228 key: String,
229 #[arg(long)]
230 result: Option<String>,
231 },
232}
233
234#[derive(Subcommand)]
235pub enum NoteCmd {
236 Get {
237 key: String,
238 #[arg(long)]
239 scope: Option<String>,
240 },
241 Set {
242 key: String,
243 #[arg(long)]
244 value: String,
245 #[arg(long)]
246 scope: Option<String>,
247 #[arg(long, value_delimiter = ',')]
248 tags: Vec<String>,
249 },
250 Rm {
251 key: String,
252 #[arg(long)]
253 scope: Option<String>,
254 },
255 Search {
256 query: String,
257 #[arg(long)]
258 scope: Option<String>,
259 },
260}
261
262fn compact(value: Value) -> Value {
264 match value {
265 Value::Object(map) => Value::Object(
266 map.into_iter()
267 .filter(|(_, v)| !v.is_null())
268 .map(|(k, v)| (k, compact(v)))
269 .collect(),
270 ),
271 other => other,
272 }
273}
274
275fn guess_content_type(path: &std::path::Path) -> &'static str {
276 match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
277 "txt" | "md" | "log" | "diff" | "patch" | "rs" | "toml" | "yml" | "yaml" | "sh" => {
278 "text/plain"
279 }
280 "json" => "application/json",
281 _ => "application/octet-stream",
282 }
283}
284
285fn attachment_json(path: &std::path::Path, content_type: Option<&str>) -> anyhow::Result<Value> {
286 use base64::Engine;
287 let data = std::fs::read(path).with_context(|| format!("cannot read {}", path.display()))?;
288 let filename = path
289 .file_name()
290 .and_then(|n| n.to_str())
291 .unwrap_or("file")
292 .to_owned();
293 Ok(json!({
294 "filename": filename,
295 "content_type": content_type.unwrap_or_else(|| guess_content_type(path)),
296 "data_base64": base64::engine::general_purpose::STANDARD.encode(&data),
297 }))
298}
299
300fn to_call(cmd: &ClientCmd) -> anyhow::Result<Option<(&'static str, Value)>> {
301 let (tool, args): (&str, Value) = match cmd {
302 ClientCmd::Whoami => ("whoami", json!({})),
303 ClientCmd::Tools => return Ok(None),
304 ClientCmd::Send {
305 channel,
306 to,
307 body,
308 reply_to,
309 file,
310 } => {
311 let attachments = file
312 .iter()
313 .map(|p| attachment_json(p, None))
314 .collect::<anyhow::Result<Vec<_>>>()?;
315 (
316 "post_message",
317 json!({
318 "channel": channel, "to": to, "body": body, "reply_to": reply_to,
319 "attachments": if attachments.is_empty() { Value::Null } else { json!(attachments) }
320 }),
321 )
322 }
323 ClientCmd::Attach {
324 task,
325 file,
326 content_type,
327 } => {
328 let mut att = attachment_json(file, content_type.as_deref())?;
329 att["task"] = json!(task);
330 ("attach_file", att)
331 }
332 ClientCmd::Download { id, .. } => ("get_attachment", json!({"id": id})),
333 ClientCmd::Ask {
334 to,
335 question,
336 timeout_seconds,
337 resume_id,
338 } => (
339 "ask_agent",
340 json!({
341 "to": to, "question": question,
342 "timeout_seconds": timeout_seconds, "resume_message_id": resume_id
343 }),
344 ),
345 ClientCmd::Read {
346 scope,
347 history,
348 limit,
349 } => (
350 "read_messages",
351 json!({"scope": scope, "only_new": !history, "limit": limit}),
352 ),
353 ClientCmd::Search { query, limit } => {
354 ("search_messages", json!({"query": query, "limit": limit}))
355 }
356 ClientCmd::Channels => ("list_channels", json!({})),
357 ClientCmd::ChannelCreate { name, topic } => {
358 ("create_channel", json!({"name": name, "topic": topic}))
359 }
360 ClientCmd::Agents { online } => ("list_agents", json!({"online_only": online})),
361 ClientCmd::Beat {
362 status,
363 repo,
364 branch,
365 activity,
366 ttl_seconds,
367 } => (
368 "heartbeat",
369 json!({
370 "status": status, "repo": repo, "branch": branch,
371 "activity": activity, "ttl_seconds": ttl_seconds
372 }),
373 ),
374 ClientCmd::Tasks { status, mine } => {
375 ("list_tasks", json!({"status": status, "mine_only": mine}))
376 }
377 ClientCmd::Wait {
378 timeout_seconds,
379 kinds,
380 } => (
381 "wait_for_updates",
382 json!({
383 "timeout_seconds": timeout_seconds,
384 "kinds": if kinds.is_empty() { Value::Null } else { json!(kinds) }
385 }),
386 ),
387 ClientCmd::Digest { hours } => ("team_digest", json!({"hours": hours})),
388 ClientCmd::Lock(lock) => match lock {
389 LockCmd::Acquire {
390 name,
391 ttl_seconds,
392 purpose,
393 } => (
394 "acquire_lock",
395 json!({"name": name, "ttl_seconds": ttl_seconds, "purpose": purpose}),
396 ),
397 LockCmd::Release { name } => ("release_lock", json!({"name": name})),
398 LockCmd::List => ("list_locks", json!({})),
399 },
400 ClientCmd::Task(task) => match task {
401 TaskCmd::Create {
402 key,
403 title,
404 description,
405 depends_on,
406 } => (
407 "create_task",
408 json!({
409 "key": key, "title": title, "description": description,
410 "depends_on": if depends_on.is_empty() { Value::Null } else { json!(depends_on) }
411 }),
412 ),
413 TaskCmd::Show { key } => ("get_task", json!({"key": key})),
414 TaskCmd::Claim { key, lease_seconds } => (
415 "claim_task",
416 json!({"key": key, "lease_seconds": lease_seconds}),
417 ),
418 TaskCmd::Next { lease_seconds } => {
419 ("claim_next_task", json!({"lease_seconds": lease_seconds}))
420 }
421 TaskCmd::Renew { key, lease_seconds } => (
422 "renew_task_lease",
423 json!({"key": key, "lease_seconds": lease_seconds}),
424 ),
425 TaskCmd::Release { key } => ("release_task", json!({"key": key})),
426 TaskCmd::Done { key, result } => {
427 ("complete_task", json!({"key": key, "result": result}))
428 }
429 },
430 ClientCmd::Notes { scope, tag } => ("list_notes", json!({"scope": scope, "tag": tag})),
431 ClientCmd::Note(note) => match note {
432 NoteCmd::Get { key, scope } => ("get_note", json!({"key": key, "scope": scope})),
433 NoteCmd::Set {
434 key,
435 value,
436 scope,
437 tags,
438 } => (
439 "set_note",
440 json!({
441 "key": key, "value": value, "scope": scope,
442 "tags": if tags.is_empty() { Value::Null } else { json!(tags) }
443 }),
444 ),
445 NoteCmd::Rm { key, scope } => ("delete_note", json!({"key": key, "scope": scope})),
446 NoteCmd::Search { query, scope } => {
447 ("search_notes", json!({"query": query, "scope": scope}))
448 }
449 },
450 ClientCmd::Call { .. } => unreachable!("handled by caller"),
451 };
452 Ok(Some((tool, compact(args))))
453}
454
455pub async fn run(args: ClientArgs) -> anyhow::Result<()> {
456 let mut config = StreamableHttpClientTransportConfig::with_uri(args.url.clone());
457 config.auth_header = Some(args.token.clone());
458 config.allow_stateless = true;
459 let transport = StreamableHttpClientTransport::from_config(config);
460
461 let client = ClientInfo::default()
462 .serve(transport)
463 .await
464 .context("could not connect to the bus (check --url and --token)")?;
465
466 let outcome = run_command(&client, &args).await;
467 let _ = client.cancel().await;
468 outcome
469}
470
471async fn run_command(
472 client: &rmcp::service::RunningService<rmcp::RoleClient, ClientInfo>,
473 args: &ClientArgs,
474) -> anyhow::Result<()> {
475 if matches!(args.command, ClientCmd::Tools) {
477 let tools = client.list_all_tools().await?;
478 if args.json {
479 println!("{}", serde_json::to_string_pretty(&tools)?);
480 } else {
481 for tool in tools {
482 println!(
483 "{:<20} {}",
484 tool.name,
485 tool.description.as_deref().unwrap_or_default().trim()
486 );
487 }
488 }
489 return Ok(());
490 }
491
492 let (tool, call_args) = match &args.command {
493 ClientCmd::Call { tool, args: raw } => {
494 let parsed: Value = serde_json::from_str(raw)
495 .with_context(|| format!("--args is not valid JSON: {raw}"))?;
496 if !parsed.is_object() {
497 bail!("--args must be a JSON object");
498 }
499 (tool.clone(), parsed)
500 }
501 other => {
502 let (tool, call_args) = to_call(other)?.expect("tools handled above");
503 (tool.to_string(), call_args)
504 }
505 };
506
507 let arguments: serde_json::Map<String, Value> =
508 serde_json::from_value(call_args).expect("compact() always yields an object");
509 let result = client
510 .call_tool(CallToolRequestParams::new(tool.clone()).with_arguments(arguments))
511 .await
512 .map_err(|e| anyhow::anyhow!("{tool} failed: {e}"))?;
513
514 if result.is_error == Some(true) {
515 bail!("{tool} returned an error: {:?}", result.content);
516 }
517 let value = result
518 .structured_content
519 .clone()
520 .unwrap_or_else(|| json!({ "ok": true }));
521
522 if args.json {
523 println!("{}", serde_json::to_string_pretty(&value)?);
524 } else {
525 render(&args.command, &value)?;
526 }
527 Ok(())
528}
529
530fn field<'v>(v: &'v Value, key: &str) -> &'v str {
533 v.get(key).and_then(Value::as_str).unwrap_or("")
534}
535
536fn render_messages(value: &Value) {
537 let messages = value["messages"].as_array().cloned().unwrap_or_default();
538 if messages.is_empty() {
539 println!("(no messages)");
540 return;
541 }
542 for m in &messages {
543 let target = m["channel"]
544 .as_str()
545 .map(|c| format!("#{c}"))
546 .or_else(|| m["to"].as_str().map(|t| format!("@{t}")))
547 .unwrap_or_default();
548 println!(
549 "[{}] {} {} → {}: {}",
550 m["id"],
551 field(m, "created_at"),
552 field(m, "from"),
553 target,
554 field(m, "body"),
555 );
556 }
557 if value["truncated"].as_bool() == Some(true) {
558 println!("(truncated at limit; raise --limit to see more)");
559 }
560}
561
562fn render_task_line(t: &Value) {
563 let holder = t["claimed_by"]
564 .as_str()
565 .map(|h| format!(" ({h})"))
566 .unwrap_or_default();
567 let expired = if t["lease_expired"].as_bool() == Some(true) {
568 " [lease expired]"
569 } else {
570 ""
571 };
572 println!(
573 "{:<24} {:<8}{}{} {}",
574 field(t, "key"),
575 field(t, "status"),
576 holder,
577 expired,
578 field(t, "title"),
579 );
580}
581
582fn render(cmd: &ClientCmd, value: &Value) -> anyhow::Result<()> {
583 match cmd {
584 ClientCmd::Whoami => {
585 println!(
586 "{} @ {} — {} unread DM(s), {} claimed task(s)",
587 field(value, "agent"),
588 field(value, "team"),
589 value["unread_direct_messages"],
590 value["open_claimed_tasks"],
591 );
592 }
593 ClientCmd::Read { .. } | ClientCmd::Search { .. } => render_messages(value),
594 ClientCmd::Send { .. } => {
595 let m = &value["message"];
596 println!("sent [{}] to {}", m["id"], value["delivered_to"]);
597 }
598 ClientCmd::Ask { .. } => {
599 if value["answered"].as_bool() == Some(true) {
600 let a = &value["answer"];
601 println!("{}: {}", field(a, "from"), field(a, "body"));
602 } else {
603 println!("(no answer yet)");
604 println!("{}", field(value, "suggestion"));
605 }
606 }
607 ClientCmd::Attach { .. } => {
608 println!(
609 "attached [{}] {} ({} bytes)",
610 value["id"],
611 field(value, "filename"),
612 value["size_bytes"]
613 );
614 }
615 ClientCmd::Download { out, .. } => {
616 use base64::Engine;
617 let data = base64::engine::general_purpose::STANDARD
618 .decode(field(value, "data_base64"))
619 .context("server returned invalid base64")?;
620 let path = out
621 .clone()
622 .unwrap_or_else(|| std::path::PathBuf::from(field(value, "filename")));
623 std::fs::write(&path, data)
624 .with_context(|| format!("cannot write {}", path.display()))?;
625 println!(
626 "wrote {} ({} bytes, {})",
627 path.display(),
628 value["size_bytes"],
629 field(value, "content_type")
630 );
631 }
632 ClientCmd::ChannelCreate { .. } => {
633 println!("channel #{} ready", field(value, "name"));
634 }
635 ClientCmd::Channels => {
636 let channels = value["channels"].as_array().cloned().unwrap_or_default();
637 if channels.is_empty() {
638 println!("(no channels yet)");
639 }
640 for c in &channels {
641 println!(
642 "#{:<20} {:>5} msg(s) {}",
643 field(c, "name"),
644 c["message_count"],
645 field(c, "topic"),
646 );
647 }
648 }
649 ClientCmd::Agents { .. } => {
650 for a in value["agents"].as_array().cloned().unwrap_or_default() {
651 let place = match (a["repo"].as_str(), a["branch"].as_str()) {
652 (Some(r), Some(b)) => format!(" {r}@{b}"),
653 (Some(r), None) => format!(" {r}"),
654 _ => String::new(),
655 };
656 println!(
657 "{:<20} {:<8}{} {}",
658 field(&a, "name"),
659 field(&a, "status"),
660 place,
661 field(&a, "activity"),
662 );
663 }
664 println!("({} online)", value["online_count"]);
665 }
666 ClientCmd::Tasks { .. } => {
667 let tasks = value["tasks"].as_array().cloned().unwrap_or_default();
668 if tasks.is_empty() {
669 println!("(no tasks)");
670 }
671 for t in &tasks {
672 render_task_line(t);
673 }
674 println!("({} open, {} claimed)", value["open"], value["claimed"]);
675 }
676 ClientCmd::Task(TaskCmd::Show { .. }) => {
677 render_task_line(&value["task"]);
678 if let Some(desc) = value["task"]["description"].as_str() {
679 println!(" {desc}");
680 }
681 if let Some(result) = value["task"]["result"].as_str() {
682 println!(" result: {result}");
683 }
684 for e in value["history"].as_array().cloned().unwrap_or_default() {
685 println!(
686 " {} {} {} {}",
687 field(&e, "created_at"),
688 field(&e, "event"),
689 field(&e, "agent"),
690 field(&e, "detail"),
691 );
692 }
693 }
694 ClientCmd::Task(TaskCmd::Claim { .. } | TaskCmd::Next { .. }) => {
695 if value["claimed"].as_bool() == Some(true) {
696 print!("claimed: ");
697 render_task_line(&value["task"]);
698 } else {
699 println!(
700 "not claimed: {}",
701 value["reason"].as_str().unwrap_or("unknown reason")
702 );
703 }
704 }
705 ClientCmd::Task(_) => {
706 render_task_line(value);
707 }
708 ClientCmd::Notes { .. } | ClientCmd::Note(NoteCmd::Search { .. }) => {
709 let notes = value["notes"].as_array().cloned().unwrap_or_default();
710 if notes.is_empty() {
711 println!("(no notes)");
712 }
713 for n in ¬es {
714 let first_line = field(n, "value").lines().next().unwrap_or("").to_owned();
715 println!("{}/{}: {}", field(n, "scope"), field(n, "key"), first_line);
716 }
717 }
718 ClientCmd::Note(NoteCmd::Get { .. }) => {
719 if value["found"].as_bool() == Some(true) {
720 let n = &value["note"];
721 println!(
722 "{}/{} (by {}, {})",
723 field(n, "scope"),
724 field(n, "key"),
725 field(n, "updated_by"),
726 field(n, "updated_at"),
727 );
728 println!("{}", field(n, "value"));
729 } else {
730 println!("not found");
731 }
732 }
733 ClientCmd::Note(NoteCmd::Set { .. }) => {
734 println!("saved {}/{}", field(value, "scope"), field(value, "key"));
735 }
736 ClientCmd::Note(NoteCmd::Rm { .. }) => {
737 println!("{}", field(value, "detail"));
738 }
739 ClientCmd::Beat { .. } => {
740 println!(
741 "presence updated: {} {}",
742 field(value, "status"),
743 field(value, "activity"),
744 );
745 }
746 ClientCmd::Wait { .. } => {
747 if value["woke"].as_bool() == Some(true) {
748 for e in value["events"].as_array().cloned().unwrap_or_default() {
749 println!("[{}] {}", field(&e, "kind"), field(&e, "summary"));
750 }
751 } else {
752 println!("(nothing happened)");
753 }
754 println!("{}", field(value, "suggestion"));
755 }
756 ClientCmd::Lock(LockCmd::List) => {
757 let locks = value["locks"].as_array().cloned().unwrap_or_default();
758 if locks.is_empty() {
759 println!("(no locks held)");
760 }
761 for l in &locks {
762 println!(
763 "{:<28} {} until {} {}",
764 field(l, "name"),
765 field(l, "holder"),
766 field(l, "expires_at"),
767 field(l, "purpose"),
768 );
769 }
770 }
771 ClientCmd::Lock(LockCmd::Acquire { .. }) => {
772 if value["acquired"].as_bool() == Some(true) {
773 let l = &value["lock"];
774 println!(
775 "acquired {} until {}",
776 field(l, "name"),
777 field(l, "expires_at")
778 );
779 } else {
780 println!("not acquired: {}", field(value, "reason"));
781 }
782 }
783 ClientCmd::Lock(LockCmd::Release { .. }) => {
784 println!("{}", field(value, "detail"));
785 }
786 ClientCmd::Digest { .. } => {
787 println!("— last {}h —", value["hours"]);
788 for c in value["channels"].as_array().cloned().unwrap_or_default() {
789 println!("#{}: {} msg(s)", field(&c, "name"), c["message_count"]);
790 for m in c["last_messages"].as_array().cloned().unwrap_or_default() {
791 println!(" {}: {}", field(&m, "from"), field(&m, "body"));
792 }
793 }
794 let moved = value["tasks_moved"].as_array().cloned().unwrap_or_default();
795 if !moved.is_empty() {
796 println!("tasks:");
797 for t in &moved {
798 println!(
799 " {} → {} {}",
800 field(t, "key"),
801 field(t, "status"),
802 t["claimed_by"]
803 .as_str()
804 .map(|s| format!("({s})"))
805 .unwrap_or_default(),
806 );
807 }
808 }
809 for n in value["notes_updated"]
810 .as_array()
811 .cloned()
812 .unwrap_or_default()
813 {
814 println!(
815 "note {}/{} by {}",
816 field(&n, "scope"),
817 field(&n, "key"),
818 field(&n, "updated_by")
819 );
820 }
821 println!(
822 "({} open, {} claimed, {} lock(s))",
823 value["open_tasks"],
824 value["claimed_tasks"],
825 value["active_locks"].as_array().map(Vec::len).unwrap_or(0),
826 );
827 }
828 _ => {
829 println!("{}", serde_json::to_string_pretty(value).unwrap());
830 }
831 }
832 Ok(())
833}