agentmux_mcp/tools/
widgets.rs1use super::*;
8
9const INSTALL_WAIT_SECS: u64 = 300;
11const INSTALL_WAIT_MAX_SECS: u64 = 600;
12
13fn require_srv(local_url: &str, auth_key: &str) -> Result<()> {
14 if local_url.is_empty() || auth_key.is_empty() {
15 anyhow::bail!("AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. Is this agent pane opened via AgentMux?");
16 }
17 Ok(())
18}
19
20async fn list(cx: &ToolCtx<'_>) -> Result<Vec<Value>> {
21 let url = format!("{}/api/v1/widgets", cx.local_url.trim_end_matches('/'));
22 let resp = cx.client.get(&url).header(AUTH_KEY_HEADER, cx.auth_key).send().await.map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
23 if !resp.status().is_success() {
24 let status = resp.status();
25 anyhow::bail!("listing widgets failed: HTTP {status}: {}", resp.text().await.unwrap_or_default());
26 }
27 let body: Value = resp.json().await.map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
28 Ok(body.get("packages").and_then(Value::as_array).cloned().unwrap_or_default())
29}
30
31fn resolve_path(path: &str) -> String {
33 let p = std::path::Path::new(path);
34 if p.is_absolute() {
35 return path.to_string();
36 }
37 let base = std::env::var("AGENTMUX_AGENT_WORKDIR")
38 .ok()
39 .filter(|s| !s.is_empty())
40 .map(std::path::PathBuf::from)
41 .or_else(|| std::env::current_dir().ok())
42 .unwrap_or_default();
43 base.join(p).to_string_lossy().into_owned()
44}
45
46pub(super) async fn call(name: &str, arguments: &Value, cx: &ToolCtx<'_>) -> Result<String> {
47 match name {
48 "WidgetList" => {
49 require_srv(cx.local_url, cx.auth_key)?;
50 let rows: Vec<Value> = list(cx)
51 .await?
52 .iter()
53 .map(|p| {
54 json!({
55 "id": p["id"], "name": p["name"], "version": p["version"], "kind": p["kind"], "state": p["state"],
56 "permissions": p["permissions"], "error": p["error"], "folder": p["folder"],
57 "views": p["panes"].as_array().map(|ps| ps.iter().map(|x| x["view"].clone()).collect::<Vec<_>>()).unwrap_or_default(),
58 })
59 })
60 .collect();
61 Ok(serde_json::to_string_pretty(&json!({ "widgets": rows })).unwrap_or_default())
62 }
63 "WidgetInstall" => {
64 require_srv(cx.local_url, cx.auth_key)?;
65 let path = arguments
66 .get("path")
67 .and_then(Value::as_str)
68 .filter(|s| !s.is_empty())
69 .ok_or_else(|| anyhow::anyhow!("missing required parameter: path"))?;
70 let wait = arguments.get("wait_secs").and_then(Value::as_u64).unwrap_or(INSTALL_WAIT_SECS).min(INSTALL_WAIT_MAX_SECS);
71 let body = json!({
74 "path": resolve_path(path),
75 "replace": arguments.get("replace").and_then(Value::as_bool).unwrap_or(true),
76 "auth": crate::identity::sign_ui_automation_auth().ok(),
77 "wait_secs": wait,
78 });
79 let url = format!("{}/api/v1/widgets/install", cx.local_url.trim_end_matches('/'));
80 let resp = cx
81 .client
82 .post(&url)
83 .header(AUTH_KEY_HEADER, cx.auth_key)
84 .timeout(std::time::Duration::from_secs(wait + 60))
85 .json(&body)
86 .send()
87 .await
88 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
89 let status = resp.status();
90 let result: Value = resp.json().await.unwrap_or_default();
91 if !status.is_success() {
92 anyhow::bail!("the widget wasn't installed: {}", result["error"].as_str().unwrap_or("unknown error"));
93 }
94 Ok(serde_json::to_string_pretty(&result).unwrap_or_default())
95 }
96 "OpenWidget" => {
97 require_srv(cx.local_url, cx.auth_key)?;
98 let view = arguments
99 .get("view")
100 .and_then(Value::as_str)
101 .filter(|s| !s.is_empty())
102 .ok_or_else(|| anyhow::anyhow!("missing required parameter: view"))?;
103 let packages = list(cx).await?;
104 let found = packages.iter().find_map(|p| {
105 p["panes"].as_array().and_then(|ps| ps.iter().find(|x| x["view"] == view)).map(|pane| (p, pane))
106 });
107 let Some((pkg, pane)) = found else {
108 anyhow::bail!("no installed widget has the view {view}; WidgetList shows the views there are");
109 };
110 if pkg["state"] != "approved" {
111 anyhow::bail!(
112 "{} is {}, so it can't open; the user approves or turns it on in Settings → Widgets",
113 pkg["id"].as_str().unwrap_or(""),
114 pkg["state"].as_str().unwrap_or("not approved")
115 );
116 }
117 let id = pkg["id"].as_str().unwrap_or_default();
120 let mut meta = pane["default_meta"].as_object().cloned().unwrap_or_default();
121 if let Some(own) = arguments.get("meta").and_then(Value::as_object) {
122 for (k, v) in own {
123 meta.insert(format!("widget:{id}:{k}"), v.clone());
124 }
125 }
126 meta.insert("view".into(), json!(view));
127 let split = arguments.get("split").and_then(Value::as_str).filter(|s| matches!(*s, "right" | "down")).unwrap_or("right");
128 let mut req = json!({ "view": view, "meta": meta, "focus": true });
129 if !cx.block_id.is_empty() {
130 req["split_direction"] = json!(split);
131 req["split_reference_block_id"] = json!(cx.block_id);
132 }
133 let url = format!("{}/api/v1/pane/open", cx.local_url.trim_end_matches('/'));
134 let resp = cx
135 .client
136 .post(&url)
137 .header(AUTH_KEY_HEADER, cx.auth_key)
138 .json(&req)
139 .send()
140 .await
141 .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
142 let status = resp.status();
143 let result: Value = resp.json().await.unwrap_or_default();
144 if !status.is_success() {
145 anyhow::bail!("the pane didn't open: HTTP {status}: {result}");
146 }
147 Ok(format!("Opened {view} (pane {}).", result["block_id"].as_str().unwrap_or("?")))
148 }
149 _ => Err(not_in_family()),
150 }
151}