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        /// Attach a file (repeatable). Max 8 files, 256 KiB each.
62        #[arg(long)]
63        file: Vec<std::path::PathBuf>,
64    },
65    /// Attach a file to a task.
66    Attach {
67        /// Task key.
68        task: String,
69        #[arg(long)]
70        file: std::path::PathBuf,
71        #[arg(long)]
72        content_type: Option<String>,
73    },
74    /// Download an attachment by id.
75    Download {
76        id: i64,
77        /// Output path; defaults to the attachment's filename.
78        #[arg(long)]
79        out: Option<std::path::PathBuf>,
80    },
81    /// Ask a teammate's agent and wait for the answer.
82    Ask {
83        to: String,
84        /// The question. Omit when resuming with --resume-id.
85        question: Option<String>,
86        #[arg(long)]
87        timeout_seconds: Option<i64>,
88        /// Keep waiting on an earlier question (its question_message_id).
89        #[arg(long)]
90        resume_id: Option<i64>,
91    },
92    /// Read messages ("all", "inbox", or a channel name).
93    Read {
94        #[arg(long, default_value = "all")]
95        scope: String,
96        /// Re-read history instead of only unread messages.
97        #[arg(long)]
98        history: bool,
99        #[arg(long, default_value_t = 50)]
100        limit: i64,
101    },
102    /// Full-text search messages.
103    Search {
104        query: String,
105        #[arg(long, default_value_t = 50)]
106        limit: i64,
107    },
108    /// List channels.
109    Channels,
110    /// Create a channel.
111    ChannelCreate {
112        name: String,
113        #[arg(long)]
114        topic: Option<String>,
115    },
116    /// Who is on the bus and what are they doing?
117    Agents {
118        #[arg(long)]
119        online: bool,
120    },
121    /// Publish your own presence.
122    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    /// List tasks.
135    Tasks {
136        #[arg(long)]
137        status: Option<String>,
138        #[arg(long)]
139        mine: bool,
140    },
141    /// Operate on a single task.
142    #[command(subcommand)]
143    Task(TaskCmd),
144    /// List notes.
145    Notes {
146        #[arg(long)]
147        scope: Option<String>,
148        #[arg(long)]
149        tag: Option<String>,
150    },
151    /// Operate on a single note.
152    #[command(subcommand)]
153    Note(NoteCmd),
154    /// Block until something happens on the bus (or the timeout passes).
155    Wait {
156        #[arg(long)]
157        timeout_seconds: Option<i64>,
158        /// Restrict to kinds: message, task, lock, note.
159        #[arg(long, value_delimiter = ',')]
160        kinds: Vec<String>,
161    },
162    /// Advisory locks on shared resources.
163    #[command(subcommand)]
164    Lock(LockCmd),
165    /// Summary of the team's recent activity.
166    Digest {
167        #[arg(long, default_value_t = 24)]
168        hours: i64,
169    },
170    /// Escape hatch: call any tool with raw JSON arguments.
171    Call {
172        tool: String,
173        /// JSON object with the arguments, e.g. '{"key": "x"}'.
174        #[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        /// Comma-separated keys of tasks this one depends on.
203        #[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    /// Claim the oldest available task, whatever it is.
215    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
262/// Strip nulls so optional flags the user did not pass are simply absent.
263fn 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    // `tools` is the one command that is not a tool call.
476    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
530// ------------------------------------------------------------- rendering --
531
532fn 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 &notes {
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}