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
262pub 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 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 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}