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, ClientConfig},
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. Omit
30    /// it to take the endpoint from the selected profile.
31    #[arg(long, env = "BUS_URL")]
32    pub url: Option<String>,
33
34    /// Your agent token (issued with `ai-crew-sync token issue`). Omit it to
35    /// use a local profile: `--profile`, the project's .acs.toml, or the
36    /// user default (see `ai-crew-sync context show`).
37    #[arg(long, env = "BUS_TOKEN", hide_env_values = true)]
38    pub token: Option<String>,
39
40    /// Connect with this local profile (from `context profile add`).
41    /// Passing it together with --token is a contradiction and is refused,
42    /// so it is always clear which identity a window uses.
43    #[arg(long, env = "BUS_PROFILE")]
44    pub profile: Option<String>,
45
46    /// Directory whose project defaults (.acs.toml) apply; the current
47    /// directory by default.
48    #[arg(long, env = "BUS_PROJECT_DIR")]
49    pub project_dir: Option<std::path::PathBuf>,
50
51    /// Id of the host conversation this call belongs to. It derives the same
52    /// bus session the proxy of that conversation uses, so a lifecycle hook
53    /// reads and writes that window's context and nobody else's.
54    #[arg(long, env = "BUS_HOST_SESSION")]
55    pub host_session: Option<String>,
56
57    /// Which working context this is — usually the repository name. Separates
58    /// your presence, task claims and locks from your other sessions. Omit it
59    /// to share one context with them.
60    #[arg(long, env = "BUS_SESSION")]
61    pub session: Option<String>,
62
63    /// Print raw JSON instead of the human-readable rendering.
64    #[arg(long, global = true)]
65    pub json: bool,
66
67    #[command(subcommand)]
68    pub command: ClientCmd,
69}
70
71#[derive(Subcommand)]
72pub enum ClientCmd {
73    /// Who am I on the bus, and is anything waiting for me?
74    Whoami,
75    /// List the tools the server exposes (sanity check).
76    Tools,
77    /// Send a message: --channel to broadcast, --to for a direct message.
78    Send {
79        #[arg(long, conflicts_with = "to")]
80        channel: Option<String>,
81        #[arg(long)]
82        to: Option<String>,
83        #[arg(long)]
84        body: String,
85        /// Interrupt every session in the team, not just those watching this
86        /// channel. For deploys, migrations and breaking changes.
87        #[arg(long)]
88        announce: bool,
89        #[arg(long)]
90        reply_to: Option<i64>,
91        /// Attach a file (repeatable). Max 8 files, 256 KiB each.
92        #[arg(long)]
93        file: Vec<std::path::PathBuf>,
94    },
95    /// Attach a file to a task.
96    Attach {
97        /// Task key.
98        task: String,
99        #[arg(long)]
100        file: std::path::PathBuf,
101        #[arg(long)]
102        content_type: Option<String>,
103    },
104    /// Download an attachment by id.
105    Download {
106        id: i64,
107        /// Output path; defaults to the attachment's filename.
108        #[arg(long)]
109        out: Option<std::path::PathBuf>,
110    },
111    /// Ask a teammate's agent and wait for the answer.
112    Ask {
113        to: String,
114        /// The question. Omit when resuming with --resume-id.
115        question: Option<String>,
116        #[arg(long)]
117        timeout_seconds: Option<i64>,
118        /// Keep waiting on an earlier question (its question_message_id).
119        #[arg(long)]
120        resume_id: Option<i64>,
121    },
122    /// Read messages ("all", "inbox", or a channel name).
123    Read {
124        #[arg(long, default_value = "all")]
125        scope: String,
126        /// Re-read history instead of only unread messages.
127        #[arg(long)]
128        history: bool,
129        #[arg(long, default_value_t = 50)]
130        limit: i64,
131        /// Include direct messages addressed to your other sessions.
132        #[arg(long)]
133        all_sessions: bool,
134    },
135    /// Full-text search messages.
136    Search {
137        query: String,
138        #[arg(long, default_value_t = 50)]
139        limit: i64,
140    },
141    /// List channels.
142    Channels,
143    /// Create a channel.
144    ChannelCreate {
145        name: String,
146        #[arg(long)]
147        topic: Option<String>,
148    },
149    /// Who is on the bus and what are they doing?
150    Agents {
151        #[arg(long)]
152        online: bool,
153    },
154    /// Every session in the team with its exact address, project and role.
155    Sessions {
156        #[arg(long)]
157        project: Option<String>,
158        #[arg(long)]
159        role: Option<String>,
160        #[arg(long)]
161        online: bool,
162        #[arg(long)]
163        limit: Option<i64>,
164    },
165    /// Publish your own presence.
166    Beat {
167        #[arg(long)]
168        status: Option<String>,
169        #[arg(long)]
170        repo: Option<String>,
171        #[arg(long)]
172        branch: Option<String>,
173        #[arg(long)]
174        activity: Option<String>,
175        /// Discovery labels (see `sessions`). Pass "" to clear.
176        #[arg(long)]
177        project: Option<String>,
178        #[arg(long)]
179        role: Option<String>,
180        #[arg(long)]
181        ttl_seconds: Option<i64>,
182    },
183    /// List tasks.
184    Tasks {
185        #[arg(long)]
186        status: Option<String>,
187        #[arg(long)]
188        mine: bool,
189    },
190    /// Operate on a single task.
191    #[command(subcommand)]
192    Task(TaskCmd),
193    /// List notes.
194    Notes {
195        #[arg(long)]
196        scope: Option<String>,
197        #[arg(long)]
198        tag: Option<String>,
199    },
200    /// Operate on a single note.
201    #[command(subcommand)]
202    Note(NoteCmd),
203    /// Block until something happens on the bus (or the timeout passes).
204    Wait {
205        #[arg(long)]
206        timeout_seconds: Option<i64>,
207        /// Restrict to kinds: message, task, lock, note.
208        #[arg(long, value_delimiter = ',')]
209        kinds: Vec<String>,
210        /// Wake on every channel, not only the one this session works in.
211        #[arg(long)]
212        all_channels: bool,
213    },
214    /// Advisory locks on shared resources.
215    #[command(subcommand)]
216    Lock(LockCmd),
217    /// Summary of the team's recent activity.
218    Digest {
219        #[arg(long, default_value_t = 24)]
220        hours: i64,
221        /// Cover every channel, not only the one this session works in.
222        #[arg(long)]
223        all_channels: bool,
224    },
225    /// Escape hatch: call any tool with raw JSON arguments.
226    Call {
227        tool: String,
228        /// JSON object with the arguments, e.g. '{"key": "x"}'.
229        #[arg(long, default_value = "{}")]
230        args: String,
231    },
232}
233
234#[derive(Subcommand)]
235pub enum LockCmd {
236    Acquire {
237        name: String,
238        #[arg(long)]
239        ttl_seconds: Option<i64>,
240        #[arg(long)]
241        purpose: Option<String>,
242    },
243    Release {
244        name: String,
245    },
246    List,
247}
248
249#[derive(Subcommand)]
250pub enum TaskCmd {
251    Create {
252        key: String,
253        #[arg(long)]
254        title: String,
255        #[arg(long)]
256        description: Option<String>,
257        /// Comma-separated keys of tasks this one depends on.
258        #[arg(long, value_delimiter = ',')]
259        depends_on: Vec<String>,
260    },
261    Show {
262        key: String,
263    },
264    Claim {
265        key: String,
266        #[arg(long)]
267        lease_seconds: Option<i64>,
268    },
269    /// Claim the oldest available task, whatever it is.
270    Next {
271        #[arg(long)]
272        lease_seconds: Option<i64>,
273    },
274    Renew {
275        key: String,
276        #[arg(long)]
277        lease_seconds: Option<i64>,
278    },
279    Release {
280        key: String,
281    },
282    Done {
283        key: String,
284        #[arg(long)]
285        result: Option<String>,
286    },
287}
288
289#[derive(Subcommand)]
290pub enum NoteCmd {
291    Get {
292        key: String,
293        #[arg(long)]
294        scope: Option<String>,
295    },
296    Set {
297        key: String,
298        #[arg(long)]
299        value: String,
300        #[arg(long)]
301        scope: Option<String>,
302        #[arg(long, value_delimiter = ',')]
303        tags: Vec<String>,
304    },
305    Rm {
306        key: String,
307        #[arg(long)]
308        scope: Option<String>,
309    },
310    Search {
311        query: String,
312        #[arg(long)]
313        scope: Option<String>,
314    },
315}
316
317/// Strip nulls so optional flags the user did not pass are simply absent.
318pub mod mapping;
319pub mod render;
320
321use mapping::{Defaults, to_call_with};
322use render::render;
323
324pub async fn run(args: ClientArgs) -> anyhow::Result<()> {
325    // Where to connect and as whom: explicit flags first, then the local
326    // profiles and the project's defaults. `context` owns the order.
327    let resolved = crate::context::resolve(&crate::context::Inputs {
328        config_dir: crate::context::config_dir()?,
329        explicit_url: args.url.clone(),
330        url_origin: args
331            .url
332            .as_deref()
333            .map(|v| crate::context::Origin::of("BUS_URL", v)),
334        explicit_token: args.token.clone(),
335        token_origin: args
336            .token
337            .as_deref()
338            .map(|v| crate::context::Origin::of("BUS_TOKEN", v)),
339        explicit_session: args.session.clone(),
340        profile: args.profile.clone(),
341        project_dir: args.project_dir.clone(),
342        host_session: args.host_session.clone(),
343    })?;
344    // Shadow warnings go to stderr: stdout is the command's parseable answer.
345    for w in &resolved.warnings {
346        eprintln!("warning: {w}");
347    }
348    let mut config = StreamableHttpClientTransportConfig::with_uri(resolved.mcp_url.clone());
349    config.auth_header = Some(resolved.token.clone());
350    config.allow_stateless = true;
351    if let Some(session) = resolved
352        .session
353        .as_deref()
354        .map(str::trim)
355        .filter(|s| !s.is_empty())
356    {
357        let value = session
358            .parse()
359            .with_context(|| format!("--session '{session}' is not a valid HTTP header value"))?;
360        config
361            .custom_headers
362            .insert(crate::auth::SESSION_HEADER.parse()?, value);
363    }
364    let transport = StreamableHttpClientTransport::from_config(config);
365
366    let client = ClientConfig::default()
367        .serve(transport)
368        .await
369        .context("could not connect to the bus (check --url and --token)")?;
370
371    let defaults = Defaults {
372        channel: resolved.channel.clone(),
373    };
374    let outcome = run_command(&client, &args, &defaults).await;
375    let _ = client.cancel().await;
376    outcome
377}
378
379async fn run_command(
380    client: &rmcp::service::RunningService<rmcp::RoleClient, ClientConfig>,
381    args: &ClientArgs,
382    defaults: &Defaults,
383) -> anyhow::Result<()> {
384    // `tools` is the one command that is not a tool call.
385    if matches!(args.command, ClientCmd::Tools) {
386        let tools = client.list_all_tools().await?;
387        if args.json {
388            println!("{}", serde_json::to_string_pretty(&tools)?);
389        } else {
390            for tool in tools {
391                println!(
392                    "{:<20} {}",
393                    tool.name,
394                    tool.description.as_deref().unwrap_or_default().trim()
395                );
396            }
397        }
398        return Ok(());
399    }
400
401    let (tool, call_args) = match &args.command {
402        ClientCmd::Call { tool, args: raw } => {
403            let parsed: Value = serde_json::from_str(raw)
404                .with_context(|| format!("--args is not valid JSON: {raw}"))?;
405            if !parsed.is_object() {
406                bail!("--args must be a JSON object");
407            }
408            (tool.clone(), parsed)
409        }
410        other => {
411            // `tools` is handled above and is the only command that maps to
412            // nothing; anything else reaching here without a mapping is a
413            // missing match arm, and says so instead of panicking.
414            let Some((tool, call_args)) = to_call_with(other, defaults)? else {
415                bail!("this subcommand has no MCP tool mapping yet");
416            };
417            (tool.to_string(), call_args)
418        }
419    };
420
421    let arguments: serde_json::Map<String, Value> =
422        serde_json::from_value(call_args).context("arguments did not form a JSON object")?;
423    let result = client
424        .call_tool(CallToolRequestParams::new(tool.clone()).with_arguments(arguments))
425        .await
426        .map_err(|e| anyhow::anyhow!("{tool} failed: {e}"))?;
427
428    if result.is_error == Some(true) {
429        bail!("{tool} returned an error: {:?}", result.content);
430    }
431    let value = result
432        .structured_content
433        .clone()
434        .unwrap_or_else(|| json!({ "ok": true }));
435
436    if args.json {
437        println!("{}", serde_json::to_string_pretty(&value)?);
438    } else {
439        render(&args.command, &value)?;
440    }
441    Ok(())
442}