Skip to main content

ai_crew_sync/client/
mod.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.
263pub mod mapping;
264pub mod render;
265
266use mapping::to_call;
267use render::render;
268
269pub async fn run(args: ClientArgs) -> anyhow::Result<()> {
270    let mut config = StreamableHttpClientTransportConfig::with_uri(args.url.clone());
271    config.auth_header = Some(args.token.clone());
272    config.allow_stateless = true;
273    let transport = StreamableHttpClientTransport::from_config(config);
274
275    let client = ClientInfo::default()
276        .serve(transport)
277        .await
278        .context("could not connect to the bus (check --url and --token)")?;
279
280    let outcome = run_command(&client, &args).await;
281    let _ = client.cancel().await;
282    outcome
283}
284
285async fn run_command(
286    client: &rmcp::service::RunningService<rmcp::RoleClient, ClientInfo>,
287    args: &ClientArgs,
288) -> anyhow::Result<()> {
289    // `tools` is the one command that is not a tool call.
290    if matches!(args.command, ClientCmd::Tools) {
291        let tools = client.list_all_tools().await?;
292        if args.json {
293            println!("{}", serde_json::to_string_pretty(&tools)?);
294        } else {
295            for tool in tools {
296                println!(
297                    "{:<20} {}",
298                    tool.name,
299                    tool.description.as_deref().unwrap_or_default().trim()
300                );
301            }
302        }
303        return Ok(());
304    }
305
306    let (tool, call_args) = match &args.command {
307        ClientCmd::Call { tool, args: raw } => {
308            let parsed: Value = serde_json::from_str(raw)
309                .with_context(|| format!("--args is not valid JSON: {raw}"))?;
310            if !parsed.is_object() {
311                bail!("--args must be a JSON object");
312            }
313            (tool.clone(), parsed)
314        }
315        other => {
316            // `tools` is handled above and is the only command that maps to
317            // nothing; anything else reaching here without a mapping is a
318            // missing match arm, and says so instead of panicking.
319            let Some((tool, call_args)) = to_call(other)? else {
320                bail!("this subcommand has no MCP tool mapping yet");
321            };
322            (tool.to_string(), call_args)
323        }
324    };
325
326    let arguments: serde_json::Map<String, Value> =
327        serde_json::from_value(call_args).context("arguments did not form a JSON object")?;
328    let result = client
329        .call_tool(CallToolRequestParams::new(tool.clone()).with_arguments(arguments))
330        .await
331        .map_err(|e| anyhow::anyhow!("{tool} failed: {e}"))?;
332
333    if result.is_error == Some(true) {
334        bail!("{tool} returned an error: {:?}", result.content);
335    }
336    let value = result
337        .structured_content
338        .clone()
339        .unwrap_or_else(|| json!({ "ok": true }));
340
341    if args.json {
342        println!("{}", serde_json::to_string_pretty(&value)?);
343    } else {
344        render(&args.command, &value)?;
345    }
346    Ok(())
347}