1use 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 #[arg(long, env = "BUS_URL")]
32 pub url: Option<String>,
33
34 #[arg(long, env = "BUS_TOKEN", hide_env_values = true)]
38 pub token: Option<String>,
39
40 #[arg(long, env = "BUS_PROFILE")]
44 pub profile: Option<String>,
45
46 #[arg(long, env = "BUS_PROJECT_DIR")]
49 pub project_dir: Option<std::path::PathBuf>,
50
51 #[arg(long, env = "BUS_HOST_SESSION")]
55 pub host_session: Option<String>,
56
57 #[arg(long, env = "BUS_SESSION")]
61 pub session: Option<String>,
62
63 #[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 Whoami,
75 Tools,
77 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 #[arg(long)]
88 announce: bool,
89 #[arg(long)]
90 reply_to: Option<i64>,
91 #[arg(long)]
93 file: Vec<std::path::PathBuf>,
94 },
95 Attach {
97 task: String,
99 #[arg(long)]
100 file: std::path::PathBuf,
101 #[arg(long)]
102 content_type: Option<String>,
103 },
104 Download {
106 id: i64,
107 #[arg(long)]
109 out: Option<std::path::PathBuf>,
110 },
111 Ask {
113 to: String,
114 question: Option<String>,
116 #[arg(long)]
117 timeout_seconds: Option<i64>,
118 #[arg(long)]
120 resume_id: Option<i64>,
121 },
122 Read {
124 #[arg(long, default_value = "all")]
125 scope: String,
126 #[arg(long)]
128 history: bool,
129 #[arg(long, default_value_t = 50)]
130 limit: i64,
131 #[arg(long)]
133 all_sessions: bool,
134 },
135 Search {
137 query: String,
138 #[arg(long, default_value_t = 50)]
139 limit: i64,
140 },
141 Channels,
143 ChannelCreate {
145 name: String,
146 #[arg(long)]
147 topic: Option<String>,
148 },
149 Agents {
151 #[arg(long)]
152 online: bool,
153 },
154 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 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 #[arg(long)]
177 project: Option<String>,
178 #[arg(long)]
179 role: Option<String>,
180 #[arg(long)]
181 ttl_seconds: Option<i64>,
182 },
183 Tasks {
185 #[arg(long)]
186 status: Option<String>,
187 #[arg(long)]
188 mine: bool,
189 },
190 #[command(subcommand)]
192 Task(TaskCmd),
193 Notes {
195 #[arg(long)]
196 scope: Option<String>,
197 #[arg(long)]
198 tag: Option<String>,
199 },
200 #[command(subcommand)]
202 Note(NoteCmd),
203 Wait {
205 #[arg(long)]
206 timeout_seconds: Option<i64>,
207 #[arg(long, value_delimiter = ',')]
209 kinds: Vec<String>,
210 #[arg(long)]
212 all_channels: bool,
213 },
214 #[command(subcommand)]
216 Lock(LockCmd),
217 Digest {
219 #[arg(long, default_value_t = 24)]
220 hours: i64,
221 #[arg(long)]
223 all_channels: bool,
224 },
225 Call {
227 tool: String,
228 #[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 #[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 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
317pub 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 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 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 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 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}