Skip to main content

ai_crew_sync/
client.rs

1//! Console client: everything the MCP tools can do, from a human terminal.
2//!
3//! Connects to the bus over the same Streamable HTTP transport and the same
4//! bearer token a coding agent would use, so a human on the shell is
5//! just another agent on the bus:
6//!
7//! ```text
8//! export BUS_URL=https://bus.internal.example/mcp
9//! export BUS_TOKEN=acs_...
10//! ai-crew-sync client whoami
11//! ai-crew-sync client send --channel deploys --body "staging is on 1.4.2"
12//! ai-crew-sync client read --scope inbox
13//! ai-crew-sync client task claim --key refactor-auth
14//! ```
15
16use 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    /// URL of the bus MCP endpoint, e.g. https://bus.example.com/mcp
30    #[arg(long, env = "BUS_URL", default_value = "http://localhost:8787/mcp")]
31    pub url: String,
32
33    /// Your agent token (issued with `ai-crew-sync token issue`).
34    #[arg(long, env = "BUS_TOKEN", hide_env_values = true)]
35    pub token: String,
36
37    /// Print raw JSON instead of the human-readable rendering.
38    #[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    /// Who am I on the bus, and is anything waiting for me?
48    Whoami,
49    /// List the tools the server exposes (sanity check).
50    Tools,
51    /// Send a message: --channel to broadcast, --to for a direct message.
52    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 a teammate's agent and wait for the answer.
63    Ask {
64        to: String,
65        /// The question. Omit when resuming with --resume-id.
66        question: Option<String>,
67        #[arg(long)]
68        timeout_seconds: Option<i64>,
69        /// Keep waiting on an earlier question (its question_message_id).
70        #[arg(long)]
71        resume_id: Option<i64>,
72    },
73    /// Read messages ("all", "inbox", or a channel name).
74    Read {
75        #[arg(long, default_value = "all")]
76        scope: String,
77        /// Re-read history instead of only unread messages.
78        #[arg(long)]
79        history: bool,
80        #[arg(long, default_value_t = 50)]
81        limit: i64,
82    },
83    /// Full-text search messages.
84    Search {
85        query: String,
86        #[arg(long, default_value_t = 50)]
87        limit: i64,
88    },
89    /// List channels.
90    Channels,
91    /// Create a channel.
92    ChannelCreate {
93        name: String,
94        #[arg(long)]
95        topic: Option<String>,
96    },
97    /// Who is on the bus and what are they doing?
98    Agents {
99        #[arg(long)]
100        online: bool,
101    },
102    /// Publish your own presence.
103    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    /// List tasks.
116    Tasks {
117        #[arg(long)]
118        status: Option<String>,
119        #[arg(long)]
120        mine: bool,
121    },
122    /// Operate on a single task.
123    #[command(subcommand)]
124    Task(TaskCmd),
125    /// List notes.
126    Notes {
127        #[arg(long)]
128        scope: Option<String>,
129        #[arg(long)]
130        tag: Option<String>,
131    },
132    /// Operate on a single note.
133    #[command(subcommand)]
134    Note(NoteCmd),
135    /// Block until something happens on the bus (or the timeout passes).
136    Wait {
137        #[arg(long)]
138        timeout_seconds: Option<i64>,
139        /// Restrict to kinds: message, task, lock, note.
140        #[arg(long, value_delimiter = ',')]
141        kinds: Vec<String>,
142    },
143    /// Advisory locks on shared resources.
144    #[command(subcommand)]
145    Lock(LockCmd),
146    /// Summary of the team's recent activity.
147    Digest {
148        #[arg(long, default_value_t = 24)]
149        hours: i64,
150    },
151    /// Escape hatch: call any tool with raw JSON arguments.
152    Call {
153        tool: String,
154        /// JSON object with the arguments, e.g. '{"key": "x"}'.
155        #[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        /// Comma-separated keys of tasks this one depends on.
184        #[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    /// Claim the oldest available task, whatever it is.
196    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
243/// Strip nulls so optional flags the user did not pass are simply absent.
244fn 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    // `tools` is the one command that is not a tool call.
412    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
466// ------------------------------------------------------------- rendering --
467
468fn 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 &notes {
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}