Skip to main content

pawan/tools/
mise.rs

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