1use 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
9pub 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
230pub 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#[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}