Skip to main content

leviath_cli/commands/
tools.rs

1//! `lev tools` - list and validate the globally available Rhai script tools.
2//! These live in `<leviath-home>/tools/` and are auto-discovered by
3//! every agent at spawn; this command surfaces what's there (and what failed to
4//! compile) without starting the daemon. Agent-specific tools are validated by
5//! `lev validate <agent>` instead.
6
7use std::path::{Path, PathBuf};
8
9use clap::Args;
10use leviath_scripting::{ScriptToolMeta, ScriptToolSet, SkippedTool};
11
12/// Arguments for `lev tools`.
13#[derive(Args)]
14pub struct ToolsArgs {
15    /// Emit the tool inventory as JSON instead of human-readable text.
16    #[arg(long)]
17    pub(crate) json: bool,
18}
19
20/// The global script-tools directory (`~/.leviath/tools/`), mirroring the
21/// daemon's own global scan in `spawn::script_scan_dirs`. `None` when no home
22/// directory resolves.
23///
24/// This resolved to `$HOME/tools/` until the shared resolver landed - the
25/// `"tools"` component was joined onto the *user home* rather than the
26/// `.leviath` data root, unlike `providers/`, `agents/` and `runs/`. Every
27/// `.rhai` file found here is compiled and offered to **every** agent as an
28/// executable tool, and `$HOME/tools` is an ordinary directory a developer may
29/// already have.
30fn global_tools_dir() -> Option<PathBuf> {
31    leviath_core::tools_dir()
32}
33
34/// The outcome of scanning a tools directory: the tools that compiled and the
35/// files that were skipped (with the reason each failed).
36struct ToolsReport {
37    valid: Vec<ScriptToolMeta>,
38    skipped: Vec<SkippedTool>,
39}
40
41/// Discover + compile the script tools in `dir` (if any), returning them sorted
42/// by name alongside the skipped files. A `None`/absent dir yields an empty
43/// report.
44fn scan_tools(dir: Option<&Path>) -> ToolsReport {
45    let dirs: Vec<PathBuf> = dir.map(Path::to_path_buf).into_iter().collect();
46    let (set, skipped) = ScriptToolSet::discover(&dirs);
47    let mut valid = set.metas();
48    valid.sort_by(|a, b| a.name.cmp(&b.name));
49    ToolsReport { valid, skipped }
50}
51
52/// A parameter's type label: the scalar `type` for a flat param, or the `type`
53/// inside a raw `schema` fragment (falling back to `schema` when the fragment has
54/// no top-level `type`, e.g. a `oneOf`).
55fn param_type_label(p: &leviath_scripting::ParamSpec) -> String {
56    match &p.schema {
57        Some(frag) => frag
58            .get("type")
59            .and_then(|v| v.as_str())
60            .unwrap_or("schema")
61            .to_string(),
62        None => p.ty.clone(),
63    }
64}
65
66/// Render one tool's parameters as a compact `name:type[!]` list (`!` marks a
67/// required parameter).
68fn params_summary(meta: &ScriptToolMeta) -> String {
69    meta.params
70        .iter()
71        .map(|p| {
72            let req = if p.required { "!" } else { "" };
73            format!("{}:{}{req}", p.name, param_type_label(p))
74        })
75        .collect::<Vec<_>>()
76        .join(", ")
77}
78
79/// The JSON view of a report (built by hand - no derive - so the shape is
80/// explicit and stable).
81fn report_json(dir_label: &str, report: &ToolsReport) -> serde_json::Value {
82    let tools: Vec<serde_json::Value> = report
83        .valid
84        .iter()
85        .map(|m| {
86            let params: Vec<serde_json::Value> = m
87                .params
88                .iter()
89                .map(|p| {
90                    // A raw `schema` fragment is surfaced verbatim (so `lev tools`
91                    // shows the enum/bounds the model actually sees); otherwise the
92                    // flat type/description.
93                    match &p.schema {
94                        Some(frag) => serde_json::json!({
95                            "name": p.name,
96                            "required": p.required,
97                            "schema": frag,
98                        }),
99                        None => serde_json::json!({
100                            "name": p.name,
101                            "type": p.ty,
102                            "required": p.required,
103                            "description": p.description,
104                        }),
105                    }
106                })
107                .collect();
108            serde_json::json!({
109                "name": m.name,
110                "description": m.description,
111                "requires": m.required_caps,
112                "available": crate::daemon::spawn::current_platform_satisfies(&m.required_caps),
113                "params": params,
114            })
115        })
116        .collect();
117    let skipped: Vec<serde_json::Value> = report
118        .skipped
119        .iter()
120        .map(|s| {
121            serde_json::json!({
122                "path": s.path.display().to_string(),
123                "reason": s.reason,
124            })
125        })
126        .collect();
127    serde_json::json!({ "dir": dir_label, "tools": tools, "skipped": skipped })
128}
129
130/// Print a report in human-readable form. Valid tools are `✓`, skipped files are
131/// `✗` with their reason (non-fatal - invalid scripts are simply not advertised,
132/// exactly as the daemon treats them).
133fn print_human(dir_label: &str, report: &ToolsReport) {
134    println!("Global script tools ({dir_label}):");
135    if report.valid.is_empty() && report.skipped.is_empty() {
136        println!("  (none)");
137        return;
138    }
139    for meta in &report.valid {
140        let desc = if meta.description.is_empty() {
141            String::new()
142        } else {
143            format!(" - {}", meta.description)
144        };
145        // A tool compiles but only loads if the platform satisfies its `@requires`
146        // (the same gate the daemon applies at spawn); flag the ones that won't,
147        // which also catches an unknown/typo'd capability name.
148        let available = crate::daemon::spawn::current_platform_satisfies(&meta.required_caps);
149        let marker = if available { "✓" } else { "⚠" };
150        println!("  {marker} {}{desc}", meta.name);
151        if !available {
152            println!("      unavailable on this platform (unsatisfiable @requires)");
153        }
154        let params = params_summary(meta);
155        if !params.is_empty() {
156            println!("      params: {params}");
157        }
158        if !meta.required_caps.is_empty() {
159            println!("      requires: {}", meta.required_caps.join(", "));
160        }
161    }
162    for s in &report.skipped {
163        println!("  ✗ {}: {}", s.path.display(), s.reason);
164    }
165}
166
167/// Testable core: scan `dir`, then print the report as JSON or text.
168fn run(dir: Option<&Path>, json: bool) -> anyhow::Result<()> {
169    let report = scan_tools(dir);
170    let dir_label = dir.map_or_else(
171        || "<no home directory>".to_string(),
172        |d| d.display().to_string(),
173    );
174    if json {
175        // The report is plain `serde_json::Value`; serialization is infallible.
176        let text = serde_json::to_string_pretty(&report_json(&dir_label, &report))
177            .expect("tools report serializes");
178        println!("{text}");
179    } else {
180        print_human(&dir_label, &report);
181    }
182    Ok(())
183}
184
185/// `lev tools` entry point.
186pub async fn execute(args: ToolsArgs) -> anyhow::Result<()> {
187    run(global_tools_dir().as_deref(), args.json)
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    /// A tools dir with one valid tool (params + a `@requires`) and one broken
195    /// script (no `@tool` directive → skipped).
196    fn dir_with_mixed_tools() -> tempfile::TempDir {
197        let dir = tempfile::tempdir().unwrap();
198        std::fs::write(
199            dir.path().join("upper.rhai"),
200            "// @tool upper\n// @description Upper-case text\n// @param text string required \"in\"\n// @requires network\nparams.text",
201        )
202        .unwrap();
203        std::fs::write(
204            dir.path().join("broken.rhai"),
205            "no tool directive here\nlet",
206        )
207        .unwrap();
208        dir
209    }
210
211    #[test]
212    fn scan_tools_lists_valid_and_skipped() {
213        let dir = dir_with_mixed_tools();
214        let report = scan_tools(Some(dir.path()));
215        assert_eq!(report.valid.len(), 1);
216        assert_eq!(report.valid[0].name, "upper");
217        assert_eq!(report.valid[0].required_caps, ["network"]);
218        assert_eq!(report.skipped.len(), 1);
219        assert!(report.skipped[0].reason.to_lowercase().contains("tool"));
220    }
221
222    #[test]
223    fn scan_tools_none_dir_is_empty() {
224        let report = scan_tools(None);
225        assert!(report.valid.is_empty() && report.skipped.is_empty());
226    }
227
228    #[test]
229    fn params_summary_marks_required() {
230        let meta = ScriptToolMeta {
231            name: "t".to_string(),
232            description: String::new(),
233            params: vec![
234                leviath_scripting::ParamSpec {
235                    name: "a".to_string(),
236                    ty: "string".to_string(),
237                    required: true,
238                    description: String::new(),
239                    schema: None,
240                },
241                leviath_scripting::ParamSpec {
242                    name: "b".to_string(),
243                    ty: "integer".to_string(),
244                    required: false,
245                    description: String::new(),
246                    schema: None,
247                },
248            ],
249            required_caps: vec![],
250        };
251        assert_eq!(params_summary(&meta), "a:string!, b:integer");
252    }
253
254    #[test]
255    fn param_type_label_reads_flat_and_fragment() {
256        let flat = leviath_scripting::ParamSpec {
257            name: "a".into(),
258            ty: "integer".into(),
259            required: false,
260            description: String::new(),
261            schema: None,
262        };
263        assert_eq!(param_type_label(&flat), "integer");
264        // A fragment with a top-level `type`.
265        let typed = leviath_scripting::ParamSpec {
266            schema: Some(serde_json::json!({ "type": "string", "enum": ["a", "b"] })),
267            ..flat.clone()
268        };
269        assert_eq!(param_type_label(&typed), "string");
270        // A fragment without a top-level `type` (e.g. oneOf) → "schema".
271        let typeless = leviath_scripting::ParamSpec {
272            schema: Some(serde_json::json!({ "oneOf": [] })),
273            ..flat
274        };
275        assert_eq!(param_type_label(&typeless), "schema");
276    }
277
278    #[test]
279    fn report_json_surfaces_raw_schema_fragment() {
280        // A `.rhai` with a sibling `.toml` carrying a raw enum schema: the JSON
281        // output shows the fragment verbatim (not a flat `type`).
282        let dir = tempfile::tempdir().unwrap();
283        std::fs::write(dir.path().join("pick.rhai"), "params.choice").unwrap();
284        std::fs::write(
285            dir.path().join("pick.toml"),
286            "[tool]\nname = \"pick\"\n[[tool.params]]\nname = \"choice\"\nrequired = true\nschema = { type = \"string\", enum = [\"x\", \"y\"] }\n",
287        )
288        .unwrap();
289        let report = scan_tools(Some(dir.path()));
290        // params_summary reads the fragment's type.
291        assert_eq!(params_summary(&report.valid[0]), "choice:string!");
292        let v = report_json("d", &report);
293        let param = &v["tools"][0]["params"][0];
294        assert_eq!(param["schema"]["enum"][1], "y");
295        assert!(
296            param.get("type").is_none(),
297            "no flat type when a fragment is present"
298        );
299    }
300
301    #[test]
302    fn report_json_shape() {
303        let dir = dir_with_mixed_tools();
304        let report = scan_tools(Some(dir.path()));
305        let v = report_json("d", &report);
306        assert_eq!(v["dir"], "d");
307        assert_eq!(v["tools"][0]["name"], "upper");
308        assert_eq!(v["tools"][0]["requires"][0], "network");
309        // `network` is satisfiable on this (desktop) platform.
310        assert_eq!(v["tools"][0]["available"], true);
311        assert_eq!(v["tools"][0]["params"][0]["required"], true);
312        assert!(v["skipped"][0]["reason"].as_str().is_some());
313    }
314
315    #[test]
316    fn run_text_and_json_and_empty() {
317        // Two valid tools - a full one (description + params + requires) and a
318        // minimal one (none of those) - so print_human covers both the present
319        // and absent branches of each field, and sort_by actually compares.
320        let dir = tempfile::tempdir().unwrap();
321        std::fs::write(
322            dir.path().join("zeta.rhai"),
323            "// @tool zeta\n// @description Full tool\n// @param x string required\n// @requires network\nparams.x",
324        )
325        .unwrap();
326        std::fs::write(dir.path().join("alpha.rhai"), "// @tool alpha\n1").unwrap();
327        // An unsatisfiable capability → the `⚠` / unavailable branch.
328        std::fs::write(
329            dir.path().join("gpu.rhai"),
330            "// @tool gpu\n// @requires gpu\n1",
331        )
332        .unwrap();
333        std::fs::write(dir.path().join("broken.rhai"), "no directive\nlet").unwrap();
334        // Text + JSON over the populated dir.
335        run(Some(dir.path()), false).unwrap();
336        run(Some(dir.path()), true).unwrap();
337        // Empty dir → the "(none)" branch.
338        let empty = tempfile::tempdir().unwrap();
339        run(Some(empty.path()), false).unwrap();
340        // No home directory → the label fallback.
341        run(None, true).unwrap();
342    }
343
344    #[test]
345    fn global_tools_dir_is_under_the_leviath_data_dir() {
346        let home = tempfile::tempdir().unwrap();
347        temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
348            // `<home>/.leviath/tools`, alongside `providers/` and `agents/`.
349            // This asserted `<home>/tools` before - the `"tools"` component was
350            // joined onto the user home rather than the data root. Every `.rhai`
351            // file in this directory becomes an executable tool for *every*
352            // agent, so it belongs inside Leviath's own directory rather than in
353            // a plausible-looking one at the top of the user's home.
354            assert_eq!(
355                global_tools_dir(),
356                Some(home.path().join(".leviath").join("tools"))
357            );
358        });
359    }
360}