Skip to main content

pawan/tools/
mise.rs

1//! mise and zoxide tool wrappers.
2
3use super::Tool;
4use super::native_search::{binary_exists, run_cmd};
5use async_trait::async_trait;
6use serde_json::{json, Value};
7use std::path::PathBuf;
8
9// ─── mise (universal tool installer) ────────────────────────────────────────
10
11pub struct MiseTool {
12    workspace_root: PathBuf,
13}
14
15impl MiseTool {
16    pub fn new(workspace_root: PathBuf) -> Self {
17        Self { workspace_root }
18    }
19}
20
21#[async_trait]
22impl Tool for MiseTool {
23    fn name(&self) -> &str { "mise" }
24
25    fn description(&self) -> &str {
26        "mise — polyglot tool manager, environment manager, and task runner. Replaces asdf, nvm, \
27         pyenv, direnv, make, and npm scripts. Three powers: (1) install/manage any dev tool or \
28         language runtime, (2) manage per-project env vars, (3) run/watch project tasks. \
29         Pawan should use this to self-install any missing CLI tool (erd, ast-grep, fd, rg, etc)."
30    }
31
32    fn parameters_schema(&self) -> Value {
33        json!({
34            "type": "object",
35            "properties": {
36                "action": {
37                    "type": "string",
38                    "enum": [
39                        "install", "uninstall", "upgrade", "list", "use", "search",
40                        "exec", "run", "tasks", "env", "outdated", "prune",
41                        "doctor", "self-update", "trust", "watch"
42                    ],
43                    "description": "Tool management: install, uninstall, upgrade, list, use, search, outdated, prune. \
44                                    Execution: exec (run with tool env), run (run a task), watch (rerun task on file change). \
45                                    Environment: env (show/set env vars). Tasks: tasks (list/manage tasks). \
46                                    Maintenance: doctor, self-update, trust, prune."
47                },
48                "tool": {
49                    "type": "string",
50                    "description": "Tool name with optional version. Examples: 'erdtree', 'node@22', 'python@3.12', \
51                                    'ast-grep', 'ripgrep', 'fd', 'sd', 'bat', 'delta', 'jq', 'yq', 'go', 'bun', 'deno'"
52                },
53                "task": {
54                    "type": "string",
55                    "description": "Task name for run/watch/tasks actions (defined in mise.toml or .mise/tasks/)"
56                },
57                "args": {
58                    "type": "string",
59                    "description": "Additional arguments (space-separated). For exec: command to run. For run: task args."
60                },
61                "global": {
62                    "type": "boolean",
63                    "description": "Apply globally (--global flag) instead of project-local. Default: false."
64                }
65            },
66            "required": ["action"]
67        })
68    }
69
70    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
71        use thulp_core::{Parameter, ParameterType};
72        thulp_core::ToolDefinition::builder(self.name())
73            .description(self.description())
74            .parameter(
75                Parameter::builder("action")
76                    .param_type(ParameterType::String)
77                    .required(true)
78                    .description("Tool management: install, uninstall, upgrade, list, use, search, outdated, prune. \
79                                  Execution: exec (run with tool env), run (run a task), watch (rerun task on file change). \
80                                  Environment: env (show/set env vars). Tasks: tasks (list/manage tasks). \
81                                  Maintenance: doctor, self-update, trust, prune.")
82                    .build(),
83            )
84            .parameter(
85                Parameter::builder("tool")
86                    .param_type(ParameterType::String)
87                    .required(false)
88                    .description("Tool name with optional version. Examples: 'erdtree', 'node@22', 'python@3.12', \
89                                  'ast-grep', 'ripgrep', 'fd', 'sd', 'bat', 'delta', 'jq', 'yq', 'go', 'bun', 'deno'")
90                    .build(),
91            )
92            .parameter(
93                Parameter::builder("task")
94                    .param_type(ParameterType::String)
95                    .required(false)
96                    .description("Task name for run/watch/tasks actions (defined in mise.toml or .mise/tasks/)")
97                    .build(),
98            )
99            .parameter(
100                Parameter::builder("args")
101                    .param_type(ParameterType::String)
102                    .required(false)
103                    .description("Additional arguments (space-separated). For exec: command to run. For run: task args.")
104                    .build(),
105            )
106            .parameter(
107                Parameter::builder("global")
108                    .param_type(ParameterType::Boolean)
109                    .required(false)
110                    .description("Apply globally (--global flag) instead of project-local. Default: false.")
111                    .build(),
112            )
113            .build()
114    }
115
116    async fn execute(&self, args: Value) -> crate::Result<Value> {
117        let mise_bin = if binary_exists("mise") {
118            "mise".to_string()
119        } else {
120            let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
121            let local = format!("{}/.local/bin/mise", home);
122            if std::path::Path::new(&local).exists() { local } else {
123                return Err(crate::PawanError::Tool(
124                    "mise not found. Install: curl https://mise.run | sh".into()
125                ));
126            }
127        };
128
129        let action = args["action"].as_str()
130            .ok_or_else(|| crate::PawanError::Tool("action required".into()))?;
131        let global = args["global"].as_bool().unwrap_or(false);
132
133        let cmd_args: Vec<String> = match action {
134            "install" => {
135                let tool = args["tool"].as_str()
136                    .ok_or_else(|| crate::PawanError::Tool("tool required for install".into()))?;
137                vec!["install".into(), tool.into(), "-y".into()]
138            }
139            "uninstall" => {
140                let tool = args["tool"].as_str()
141                    .ok_or_else(|| crate::PawanError::Tool("tool required for uninstall".into()))?;
142                vec!["uninstall".into(), tool.into()]
143            }
144            "upgrade" => {
145                let mut v = vec!["upgrade".into()];
146                if let Some(tool) = args["tool"].as_str() { v.push(tool.into()); }
147                v
148            }
149            "list" => vec!["ls".into()],
150            "search" => {
151                let tool = args["tool"].as_str()
152                    .ok_or_else(|| crate::PawanError::Tool("tool required for search".into()))?;
153                vec!["registry".into(), tool.into()]
154            }
155            "use" => {
156                let tool = args["tool"].as_str()
157                    .ok_or_else(|| crate::PawanError::Tool("tool required for use".into()))?;
158                let mut v = vec!["use".into()];
159                if global { v.push("--global".into()); }
160                v.push(tool.into());
161                v
162            }
163            "outdated" => {
164                let mut v = vec!["outdated".into()];
165                if let Some(tool) = args["tool"].as_str() { v.push(tool.into()); }
166                v
167            }
168            "prune" => {
169                let mut v = vec!["prune".into(), "-y".into()];
170                if let Some(tool) = args["tool"].as_str() { v.push(tool.into()); }
171                v
172            }
173            "exec" => {
174                let tool = args["tool"].as_str()
175                    .ok_or_else(|| crate::PawanError::Tool("tool required for exec".into()))?;
176                let extra = args["args"].as_str().unwrap_or("");
177                let mut v = vec!["exec".into(), tool.into(), "--".into()];
178                if !extra.is_empty() {
179                    v.extend(extra.split_whitespace().map(|s| s.to_string()));
180                }
181                v
182            }
183            "run" => {
184                let task = args["task"].as_str()
185                    .ok_or_else(|| crate::PawanError::Tool("task required for run".into()))?;
186                let mut v = vec!["run".into(), task.into()];
187                if let Some(extra) = args["args"].as_str() {
188                    v.push("--".into());
189                    v.extend(extra.split_whitespace().map(|s| s.to_string()));
190                }
191                v
192            }
193            "watch" => {
194                let task = args["task"].as_str()
195                    .ok_or_else(|| crate::PawanError::Tool("task required for watch".into()))?;
196                let mut v = vec!["watch".into(), task.into()];
197                if let Some(extra) = args["args"].as_str() {
198                    v.push("--".into());
199                    v.extend(extra.split_whitespace().map(|s| s.to_string()));
200                }
201                v
202            }
203            "tasks" => vec!["tasks".into(), "ls".into()],
204            "env" => vec!["env".into()],
205            "doctor" => vec!["doctor".into()],
206            "self-update" => vec!["self-update".into(), "-y".into()],
207            "trust" => {
208                let mut v = vec!["trust".into()];
209                if let Some(extra) = args["args"].as_str() { v.push(extra.into()); }
210                v
211            }
212            _ => return Err(crate::PawanError::Tool(
213                format!("Unknown action: {action}. See tool description for available actions.")
214            )),
215        };
216
217        let cmd_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
218        let (stdout, stderr, success) = run_cmd(&mise_bin, &cmd_refs, &self.workspace_root).await
219            .map_err(crate::PawanError::Tool)?;
220
221        Ok(json!({
222            "success": success,
223            "action": action,
224            "output": stdout,
225            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
226        }))
227    }
228}
229
230// ─── zoxide (smart cd) ─────────────────────────────────────────────────────
231
232pub struct ZoxideTool {
233    workspace_root: PathBuf,
234}
235
236impl ZoxideTool {
237    pub fn new(workspace_root: PathBuf) -> Self {
238        Self { workspace_root }
239    }
240}
241
242#[async_trait]
243impl Tool for ZoxideTool {
244    fn name(&self) -> &str { "z" }
245
246    fn description(&self) -> &str {
247        "zoxide — smart directory jumper. Learns from your cd history. \
248         Use 'query' to find a directory by fuzzy match (e.g. 'myproject' finds ~/projects/myproject). \
249         Use 'add' to teach it a new path. Use 'list' to see known paths."
250    }
251
252    fn parameters_schema(&self) -> Value {
253        json!({
254            "type": "object",
255            "properties": {
256                "action": { "type": "string", "description": "query, add, or list" },
257                "path": { "type": "string", "description": "Path or search term" }
258            },
259            "required": ["action"]
260        })
261    }
262
263    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
264        use thulp_core::{Parameter, ParameterType};
265        thulp_core::ToolDefinition::builder(self.name())
266            .description(self.description())
267            .parameter(
268                Parameter::builder("action")
269                    .param_type(ParameterType::String)
270                    .required(true)
271                    .description("query, add, or list")
272                    .build(),
273            )
274            .parameter(
275                Parameter::builder("path")
276                    .param_type(ParameterType::String)
277                    .required(false)
278                    .description("Path or search term")
279                    .build(),
280            )
281            .build()
282    }
283
284    async fn execute(&self, args: Value) -> crate::Result<Value> {
285        let action = args["action"].as_str()
286            .ok_or_else(|| crate::PawanError::Tool("action required (query/add/list)".into()))?;
287
288        let cmd_args: Vec<String> = match action {
289            "query" => {
290                let path = args["path"].as_str()
291                    .ok_or_else(|| crate::PawanError::Tool("path/search term required for query".into()))?;
292                vec!["query".into(), path.into()]
293            }
294            "add" => {
295                let path = args["path"].as_str()
296                    .ok_or_else(|| crate::PawanError::Tool("path required for add".into()))?;
297                vec!["add".into(), path.into()]
298            }
299            "list" => vec!["query".into(), "--list".into()],
300            _ => return Err(crate::PawanError::Tool(format!("Unknown action: {}. Use query/add/list", action))),
301        };
302
303        let cmd_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
304        let (stdout, stderr, success) = run_cmd("zoxide", &cmd_refs, &self.workspace_root).await
305            .map_err(crate::PawanError::Tool)?;
306
307        Ok(json!({
308            "success": success,
309            "result": stdout.trim(),
310            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
311        }))
312    }
313}
314
315// ─── tests ──────────────────────────────────────────────────────────────────
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use tempfile::TempDir;
321
322    #[tokio::test]
323    async fn test_mise_tool_schema() {
324        let tmp = TempDir::new().unwrap();
325        let tool = MiseTool::new(tmp.path().to_path_buf());
326        assert_eq!(tool.name(), "mise");
327        let schema = tool.parameters_schema();
328        assert!(schema["properties"]["action"].is_object());
329        assert!(schema["properties"]["task"].is_object());
330    }
331
332    #[tokio::test]
333    async fn test_zoxide_tool_basics() {
334        let tmp = TempDir::new().unwrap();
335        let tool = ZoxideTool::new(tmp.path().into());
336        assert_eq!(tool.name(), "z");
337        assert!(!tool.description().is_empty());
338        let schema = tool.parameters_schema();
339        assert!(schema["required"].as_array().unwrap().contains(&serde_json::json!("action")));
340    }
341
342    #[tokio::test]
343    async fn test_mise_tool_unknown_action_returns_error() {
344        let tmp = TempDir::new().unwrap();
345        let tool = MiseTool::new(tmp.path().into());
346        let result = tool
347            .execute(serde_json::json!({ "action": "totally_not_a_real_verb" }))
348            .await;
349        let err = result.expect_err("unknown mise action must error");
350        let msg = format!("{}", err);
351        assert!(
352            msg.contains("Unknown action") && msg.contains("totally_not_a_real_verb"),
353            "error must name the unknown action, got: {}", msg
354        );
355    }
356
357    #[tokio::test]
358    async fn test_mise_tool_install_without_tool_returns_error() {
359        let tmp = TempDir::new().unwrap();
360        let tool = MiseTool::new(tmp.path().into());
361        let result = tool
362            .execute(serde_json::json!({ "action": "install" }))
363            .await;
364        let err = result.expect_err("mise install without tool must error");
365        let msg = format!("{}", err);
366        assert!(
367            msg.contains("tool required for install"),
368            "error should mention 'tool required for install', got: {}", msg
369        );
370    }
371}