1use super::*;
7
8pub(super) async fn call(name: &str, arguments: &Value, cx: &ToolCtx<'_>) -> Result<String> {
9 let ToolCtx { local_url, auth_key, block_id, client, .. } = *cx;
10 match name {
11 "OpenEditor" => {
12 let connection = connection_arg(arguments);
13 let file = arguments
14 .get("file")
15 .and_then(|v| v.as_str())
16 .filter(|s| !s.is_empty())
17 .ok_or_else(|| anyhow::anyhow!("missing required parameter: file"))?;
18
19 if local_url.is_empty() || auth_key.is_empty() {
20 anyhow::bail!(
21 "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
22 Is this agent pane opened via AgentMux?"
23 );
24 }
25
26 let split = arguments
27 .get("split")
28 .and_then(|v| v.as_str())
29 .filter(|s| matches!(*s, "right" | "left" | "down" | "up"))
30 .unwrap_or("right");
31
32 let url = format!("{}/api/v1/pane/open", local_url.trim_end_matches('/'));
33 let (split_direction, split_reference_block_id) = if block_id.is_empty() {
37 (None, None)
38 } else {
39 (Some(split.to_string()), Some(block_id.to_string()))
40 };
41 let req = PaneOpenRequest {
42 view: "editor".to_string(),
43 file: Some(file.to_string()),
44 focus: Some(true),
45 split_direction,
46 split_reference_block_id,
47 title: arguments.get("title").and_then(|v| v.as_str()).map(str::to_string),
48 tree_expanded: if arguments.get("collapse_tree").and_then(|v| v.as_bool()) == Some(true) {
51 Some(false)
52 } else {
53 None
54 },
55 floating: if arguments.get("floating").and_then(|v| v.as_bool()) == Some(true) {
57 Some(true)
58 } else {
59 None
60 },
61 url: None,
62 cwd: None,
63 tab_id: None,
64 reuse_editor_pane: Some(true),
70 select: None,
71 auth: if connection.is_some() { Some(sign_ui_automation_auth()?) } else { None },
74 connection,
75 };
76
77 let resp = client
78 .post(&url)
79 .header(AUTH_KEY_HEADER, auth_key)
80 .json(&req)
81 .send()
82 .await
83 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
84
85 if !resp.status().is_success() {
86 let status = resp.status();
87 let text = resp.text().await.unwrap_or_default();
88 anyhow::bail!("pane.open failed: HTTP {status} — {text}");
89 }
90
91 let result: PaneOpenResponse = resp
92 .json()
93 .await
94 .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
95
96 Ok(format!("Opened {file} in editor pane (block {})", result.block_id))
97 }
98 "OpenMedia" => {
99 let file = arguments
100 .get("file")
101 .and_then(|v| v.as_str())
102 .filter(|s| !s.is_empty())
103 .ok_or_else(|| anyhow::anyhow!("missing required parameter: file"))?;
104
105 if local_url.is_empty() || auth_key.is_empty() {
106 anyhow::bail!(
107 "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
108 Is this agent pane opened via AgentMux?"
109 );
110 }
111
112 let split = arguments
113 .get("split")
114 .and_then(|v| v.as_str())
115 .filter(|s| matches!(*s, "right" | "left" | "down" | "up"))
116 .unwrap_or("right");
117
118 let url = format!("{}/api/v1/pane/open", local_url.trim_end_matches('/'));
119 let (split_direction, split_reference_block_id) = if block_id.is_empty() {
123 (None, None)
124 } else {
125 (Some(split.to_string()), Some(block_id.to_string()))
126 };
127 let req = PaneOpenRequest {
128 view: "media".to_string(),
129 file: Some(file.to_string()),
130 focus: Some(true),
131 split_direction,
132 split_reference_block_id,
133 title: arguments.get("title").and_then(|v| v.as_str()).map(str::to_string),
134 tree_expanded: None,
136 floating: if arguments.get("floating").and_then(|v| v.as_bool()) == Some(true) {
138 Some(true)
139 } else {
140 None
141 },
142 url: None,
143 cwd: None,
144 tab_id: None,
145 reuse_editor_pane: None, select: None,
147 connection: None,
148 auth: None,
149 };
150
151 let resp = client
152 .post(&url)
153 .header(AUTH_KEY_HEADER, auth_key)
154 .json(&req)
155 .send()
156 .await
157 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
158
159 if !resp.status().is_success() {
160 let status = resp.status();
161 let text = resp.text().await.unwrap_or_default();
162 anyhow::bail!("pane.open failed: HTTP {status} — {text}");
163 }
164
165 let result: PaneOpenResponse = resp
166 .json()
167 .await
168 .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
169
170 Ok(format!("Opened {file} in media pane (block {})", result.block_id))
171 }
172 "OpenFiles" => {
176 let connection = connection_arg(arguments);
177 let path = arguments
178 .get("path")
179 .and_then(|v| v.as_str())
180 .filter(|s| !s.is_empty())
181 .ok_or_else(|| anyhow::anyhow!("missing required parameter: path"))?;
182 let select: Vec<String> = arguments
183 .get("select")
184 .and_then(|v| v.as_array())
185 .map(|a| a.iter().filter_map(|v| v.as_str()).filter(|s| !s.is_empty()).map(str::to_string).collect())
186 .unwrap_or_default();
187
188 if local_url.is_empty() || auth_key.is_empty() {
189 anyhow::bail!(
190 "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
191 Is this agent pane opened via AgentMux?"
192 );
193 }
194
195 let split = arguments
196 .get("split")
197 .and_then(|v| v.as_str())
198 .filter(|s| matches!(*s, "right" | "left" | "down" | "up"))
199 .unwrap_or("right");
200
201 let url = format!("{}/api/v1/pane/open", local_url.trim_end_matches('/'));
202 let (split_direction, split_reference_block_id) = if block_id.is_empty() {
203 (None, None)
204 } else {
205 (Some(split.to_string()), Some(block_id.to_string()))
206 };
207 let req = PaneOpenRequest {
208 view: "files".to_string(),
209 file: Some(path.to_string()),
210 focus: Some(true),
211 split_direction,
212 split_reference_block_id,
213 title: arguments.get("title").and_then(|v| v.as_str()).map(str::to_string),
214 tree_expanded: None,
215 floating: if arguments.get("floating").and_then(|v| v.as_bool()) == Some(true) {
216 Some(true)
217 } else {
218 None
219 },
220 url: None,
221 cwd: None,
222 tab_id: None,
223 reuse_editor_pane: None, select: if select.is_empty() { None } else { Some(select) },
225 auth: if connection.is_some() { Some(sign_ui_automation_auth()?) } else { None },
228 connection,
229 };
230
231 let resp = client
232 .post(&url)
233 .header(AUTH_KEY_HEADER, auth_key)
234 .json(&req)
235 .send()
236 .await
237 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
238
239 if !resp.status().is_success() {
240 let status = resp.status();
241 let text = resp.text().await.unwrap_or_default();
242 anyhow::bail!("pane.open failed: HTTP {status} — {text}");
243 }
244
245 let result: PaneOpenResponse = resp
246 .json()
247 .await
248 .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
249
250 Ok(format!("Opened {path} in a Files pane (block {})", result.block_id))
251 }
252 "OpenAgent" => {
253 let agent_id = arguments
254 .get("agent_id")
255 .and_then(|v| v.as_str())
256 .filter(|s| !s.is_empty())
257 .ok_or_else(|| anyhow::anyhow!("missing required parameter: agent_id"))?;
258 let tab_id = arguments.get("tab_id").and_then(|v| v.as_str()).map(str::to_string);
259 let focus = arguments.get("focus").and_then(|v| v.as_bool());
260
261 if local_url.is_empty() || auth_key.is_empty() {
262 anyhow::bail!(
263 "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
264 Is this agent pane opened via AgentMux?"
265 );
266 }
267
268 let url = format!("{}/api/v1/agent/open", local_url.trim_end_matches('/'));
269 let body = json!({
270 "agent_id": agent_id,
271 "tab_id": tab_id,
272 "focus": focus,
273 });
274 let resp = client
275 .post(&url)
276 .header(AUTH_KEY_HEADER, auth_key)
277 .json(&body)
278 .send()
279 .await
280 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
281
282 if !resp.status().is_success() {
283 let status = resp.status();
284 let text = resp.text().await.unwrap_or_default();
285 anyhow::bail!("agent open failed: HTTP {status} — {text}");
286 }
287
288 let result: Value = resp
289 .json()
290 .await
291 .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
292
293 Ok(serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()))
294 }
295 "WhoAmI" => {
296 if local_url.is_empty() || auth_key.is_empty() {
297 anyhow::bail!(
298 "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
299 Is this agent pane opened via AgentMux?"
300 );
301 }
302 if block_id.is_empty() {
303 anyhow::bail!(
304 "neither AGENTMUX_AGENT_BUS_ID nor AGENTMUX_BLOCKID is set \
305 — cannot resolve this agent's pane. Is this agent pane opened via AgentMux?"
306 );
307 }
308
309 let url = format!("{}/api/v1/self", local_url.trim_end_matches('/'));
310 let resp = client
311 .get(&url)
312 .header(AUTH_KEY_HEADER, auth_key)
313 .query(&[("block_id", block_id)])
314 .send()
315 .await
316 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
317
318 if !resp.status().is_success() {
319 let status = resp.status();
320 let text = resp.text().await.unwrap_or_default();
321 anyhow::bail!("self lookup failed: HTTP {status} — {text}");
322 }
323
324 let result: Value = resp
325 .json()
326 .await
327 .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
328
329 Ok(serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()))
330 }
331 "SetName" => {
332 let target = arguments
333 .get("target")
334 .and_then(|v| v.as_str())
335 .map(|s| s.trim())
336 .filter(|s| !s.is_empty())
337 .ok_or_else(|| anyhow::anyhow!("missing required parameter: target"))?;
338 let new_name = arguments
339 .get("name")
340 .and_then(|v| v.as_str())
341 .map(|s| s.trim())
342 .filter(|s| !s.is_empty())
343 .ok_or_else(|| anyhow::anyhow!("missing required parameter: name"))?;
344 let target_id = arguments
345 .get("target_id")
346 .and_then(|v| v.as_str())
347 .filter(|s| !s.is_empty())
348 .map(str::to_string);
349
350 if local_url.is_empty() || auth_key.is_empty() {
351 anyhow::bail!(
352 "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
353 Is this agent pane opened via AgentMux?"
354 );
355 }
356 if target_id.is_none() && block_id.is_empty() {
359 anyhow::bail!(
360 "neither AGENTMUX_AGENT_BUS_ID nor AGENTMUX_BLOCKID is set \
361 — pass target_id to name a specific element, or open this \
362 agent in an AgentMux pane to default to your own."
363 );
364 }
365
366 let own_block = if block_id.is_empty() { None } else { Some(block_id.to_string()) };
371 let (path, label, resp_field, body) = match target {
372 "window" => ("window/name", "Window name", "name", serde_json::to_value(WindowNameRequest {
373 block_id: own_block,
374 name: new_name.to_string(),
375 window_id: target_id,
376 })?),
377 "tab" => ("tab/name", "Tab name", "name", serde_json::to_value(TabNameRequest {
378 block_id: own_block,
379 tab_id: target_id,
380 name: new_name.to_string(),
381 })?),
382 "workspace" => ("workspace/name", "Workspace name", "name", serde_json::to_value(WorkspaceNameRequest {
383 block_id: own_block,
384 workspace_id: target_id,
385 name: new_name.to_string(),
386 })?),
387 "pane" => ("pane/title", "Pane title", "title", serde_json::to_value(PaneTitleRequest {
388 block_id: target_id.or(own_block),
389 title: new_name.to_string(),
390 })?),
391 other => anyhow::bail!(
392 "invalid target '{other}' — expected one of: window, tab, pane, workspace"
393 ),
394 };
395 let url = format!("{}/api/v1/{path}", local_url.trim_end_matches('/'));
396 let resp = client
397 .post(&url)
398 .header(AUTH_KEY_HEADER, auth_key)
399 .json(&body)
400 .send()
401 .await
402 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
403 if !resp.status().is_success() {
404 let status = resp.status();
405 let text = resp.text().await.unwrap_or_default();
406 anyhow::bail!("{label} change failed: HTTP {status} — {text}");
407 }
408 let applied = resp
411 .json::<Value>()
412 .await
413 .ok()
414 .and_then(|v| v.get(resp_field).and_then(|s| s.as_str()).map(str::to_string))
415 .unwrap_or_else(|| new_name.to_string());
416 Ok(format!("{label} set to \"{applied}\""))
417 }
418 "Layout" => {
419 if local_url.is_empty() || auth_key.is_empty() {
420 anyhow::bail!(
421 "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
422 Is this agent pane opened via AgentMux?"
423 );
424 }
425 let query = arguments
426 .get("query")
427 .and_then(|v| v.as_str())
428 .map(|s| s.trim())
429 .filter(|s| !s.is_empty())
430 .unwrap_or("layout");
431 let path = match query {
432 "layout" => "layout",
433 "windows" => "windows",
434 "workspaces" => "workspaces",
435 "tabs" => "tabs",
436 other => anyhow::bail!(
437 "invalid query '{other}' — expected one of: layout, windows, workspaces, tabs"
438 ),
439 };
440 let url = format!("{}/api/v1/{path}", local_url.trim_end_matches('/'));
441 let mut reqb = client.get(&url).header(AUTH_KEY_HEADER, auth_key);
442 if query == "tabs" && !block_id.is_empty() {
444 reqb = reqb.query(&[("block_id", block_id)]);
445 }
446 let resp = reqb
447 .send()
448 .await
449 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
450 if !resp.status().is_success() {
451 let status = resp.status();
452 let text = resp.text().await.unwrap_or_default();
453 anyhow::bail!("Layout({query}) failed: HTTP {status} — {text}");
454 }
455 let result: Value = resp
456 .json()
457 .await
458 .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
459 Ok(serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()))
460 }
461 "SetActiveTab" => {
462 let tab_id = arguments
463 .get("tab_id")
464 .and_then(|v| v.as_str())
465 .map(|s| s.trim())
466 .filter(|s| !s.is_empty())
467 .ok_or_else(|| anyhow::anyhow!("missing required parameter: tab_id"))?;
468 require_agent_env(local_url, auth_key, block_id)?;
469 let url = format!("{}/api/v1/tab/activate", local_url.trim_end_matches('/'));
470 let resp = client
471 .post(&url)
472 .header(AUTH_KEY_HEADER, auth_key)
473 .json(&TabActivateRequest { tab_id: tab_id.to_string() })
474 .send()
475 .await
476 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
477 if !resp.status().is_success() {
478 let status = resp.status();
479 let text = resp.text().await.unwrap_or_default();
480 anyhow::bail!("set active tab failed: HTTP {status} — {text}");
481 }
482 Ok(format!("Switched to tab {tab_id}"))
483 }
484 "NewTab" => {
485 require_agent_env(local_url, auth_key, block_id)?;
486 let name = arguments.get("name").and_then(|v| v.as_str()).unwrap_or("");
487 let url = format!("{}/api/v1/tab/new", local_url.trim_end_matches('/'));
488 let resp = client
489 .post(&url)
490 .header(AUTH_KEY_HEADER, auth_key)
491 .json(&TabNewRequest {
492 block_id: Some(block_id.to_string()),
493 workspace_id: None,
494 name: if name.is_empty() { None } else { Some(name.to_string()) },
495 })
496 .send()
497 .await
498 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
499 if !resp.status().is_success() {
500 let status = resp.status();
501 let text = resp.text().await.unwrap_or_default();
502 anyhow::bail!("new tab failed: HTTP {status} — {text}");
503 }
504 Ok(if name.is_empty() {
505 "Opened a new tab".to_string()
506 } else {
507 format!("Opened new tab \"{name}\"")
508 })
509 }
510 "FocusWindow" => {
511 require_agent_env(local_url, auth_key, block_id)?;
512 let window_id = arguments.get("window_id").and_then(|v| v.as_str()).unwrap_or("");
513 let url = format!("{}/api/v1/window/focus", local_url.trim_end_matches('/'));
514 let resp = client
515 .post(&url)
516 .header(AUTH_KEY_HEADER, auth_key)
517 .json(&WindowFocusRequest {
518 block_id: Some(block_id.to_string()),
519 window_id: if window_id.is_empty() { None } else { Some(window_id.to_string()) },
520 })
521 .send()
522 .await
523 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
524 if !resp.status().is_success() {
525 let status = resp.status();
526 let text = resp.text().await.unwrap_or_default();
527 anyhow::bail!("focus window failed: HTTP {status} — {text}");
528 }
529 Ok("Focused window".to_string())
530 }
531 "ClosePane" => {
532 require_agent_env(local_url, auth_key, block_id)?;
533 let auth = sign_ui_automation_auth()?;
534 let target_block_id = arguments.get("block_id").and_then(|v| v.as_str()).map(str::to_string);
535 let reason = arguments.get("reason").and_then(|v| v.as_str()).map(str::to_string);
536 let url = format!("{}/api/v1/agent/pane/close", local_url.trim_end_matches('/'));
537 let resp = client
538 .post(&url)
539 .header(AUTH_KEY_HEADER, auth_key)
540 .json(&ClosePaneRequest { auth, block_id: target_block_id.clone(), reason })
541 .send()
542 .await
543 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
544 if !resp.status().is_success() {
545 let status = resp.status();
546 let text = resp.text().await.unwrap_or_default();
547 anyhow::bail!("close failed: HTTP {status} — {text}");
548 }
549 if resp.status().as_u16() == 202 {
550 let body: Value = resp.json().await.unwrap_or(Value::Null);
551 let request_id = body["request_id"].as_str().unwrap_or_default();
552 return match target_block_id.as_deref() {
553 Some(b) => {
555 let now = await_shutdown(client, local_url, auth_key, request_id, true).await;
556 close_pane_outcome(b, &now)
557 }
558 None => {
561 let now = await_shutdown(client, local_url, auth_key, request_id, false).await;
562 match now["status"].as_str().unwrap_or("") {
563 "" | "pending" => anyhow::bail!("ClosePane: no answer on the pending shutdown {request_id}"),
564 state => quit_self_outcome(state, &now),
565 }
566 }
567 };
568 }
569 match target_block_id {
570 Some(b) => Ok(format!("Closed pane {b:?}")),
571 None => quit_self_result(200, &Value::Null),
574 }
575 }
576 "QuitSelf" => {
577 require_agent_env(local_url, auth_key, block_id)?;
578 let field = |k: &str| {
579 arguments
580 .get(k)
581 .and_then(|v| v.as_str())
582 .map(str::trim)
583 .filter(|s| !s.is_empty())
584 .map(str::to_string)
585 .ok_or_else(|| anyhow::anyhow!("missing required parameter: {k}"))
586 };
587 let (reason, user_instruction) = (field("reason")?, field("user_instruction")?);
588 let auth = sign_ui_automation_auth()?;
589 let url = format!("{}/api/v1/agent/self/quit", local_url.trim_end_matches('/'));
590 let resp = client
591 .post(&url)
592 .header(AUTH_KEY_HEADER, auth_key)
593 .json(&QuitSelfRequest { auth, reason, user_instruction })
594 .send()
595 .await
596 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
597 let status = resp.status();
598 let body: Value = resp.json().await.unwrap_or(Value::Null);
599 if status.as_u16() != 202 || body["status"] != "pending_user_override" {
600 return quit_self_result(status.as_u16(), &body);
601 }
602 let request_id = body["request_id"].as_str().unwrap_or_default();
606 let now = await_shutdown(client, local_url, auth_key, request_id, false).await;
609 match now["status"].as_str().unwrap_or("") {
610 "" | "pending" => anyhow::bail!("QuitSelf: no answer on the pending shutdown {request_id}"),
611 state => quit_self_outcome(state, &now),
612 }
613 }
614 "RegisterDevServer" => {
615 require_agent_env(local_url, auth_key, block_id)?;
616 let auth = sign_ui_automation_auth()?;
617 let project = arguments
618 .get("project")
619 .and_then(|v| v.as_str())
620 .filter(|s| !s.is_empty())
621 .ok_or_else(|| anyhow::anyhow!("missing required parameter: project"))?
622 .to_string();
623 let port = arguments
624 .get("port")
625 .and_then(|v| v.as_u64())
626 .filter(|p| *p > 0 && *p <= u16::MAX as u64)
627 .ok_or_else(|| anyhow::anyhow!("missing or invalid required parameter: port (must be 1-65535)"))?
628 as u16;
629 let url = format!(
630 "{}/api/v1/agent/dev_server/register",
631 local_url.trim_end_matches('/')
632 );
633 let resp = client
634 .post(&url)
635 .header(AUTH_KEY_HEADER, auth_key)
636 .json(&RegisterDevServerRequest { auth, project, port })
637 .send()
638 .await
639 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
640 if !resp.status().is_success() {
641 let status = resp.status();
642 let text = resp.text().await.unwrap_or_default();
643 anyhow::bail!("RegisterDevServer failed: HTTP {status} — {text}");
644 }
645 let body: RegisterDevServerResponse = resp
646 .json()
647 .await
648 .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
649 Ok(format!("Registered. Browse to: {}", body.url))
650 }
651 _ => Err(not_in_family()),
652 }
653}
654
655
656fn connection_arg(arguments: &Value) -> Option<String> {
659 arguments
660 .get("connection")
661 .and_then(|v| v.as_str())
662 .map(str::trim)
663 .filter(|c| !c.is_empty() && *c != "local")
664 .map(str::to_string)
665}
666
667#[cfg(test)]
668mod connection_arg_tests {
669 use super::*;
670
671 #[test]
672 fn only_a_named_connection_is_one() {
673 let c = |v: Value| connection_arg(&serde_json::json!({ "connection": v }));
674 assert_eq!(c(serde_json::json!(" user@box ")).as_deref(), Some("user@box"));
675 assert_eq!(c(serde_json::json!("wsl://Ubuntu")).as_deref(), Some("wsl://Ubuntu"));
676 assert_eq!(c(serde_json::json!("local")), None);
677 assert_eq!(c(serde_json::json!("")), None);
678 assert_eq!(connection_arg(&serde_json::json!({})), None);
679 }
680}