context_harness/
tool_script.rs

1//! Lua MCP tool extensions.
2//!
3//! Loads `.lua` tool scripts at server startup, extracts their parameter schemas,
4//! and provides execution with a context bridge back into the Rust core
5//! (search, get, sources).
6//!
7//! # Architecture
8//!
9//! Tool scripts follow the same sandboxed Lua VM model as connectors
10//! ([`crate::connector_script`]), reusing all host APIs from
11//! [`crate::lua_runtime`]. In addition, tools receive a `context` table with:
12//!
13//! - `context.search(query, opts?)` — search the knowledge base
14//! - `context.get(id)` — retrieve a document by UUID
15//! - `context.sources()` — list connector status
16//! - `context.config` — tool-specific configuration from `ctx.toml`
17//!
18//! # Script Interface
19//!
20//! Every tool script defines a global `tool` table:
21//!
22//! ```lua
23//! tool = {
24//!     name = "my_tool",
25//!     description = "Does something useful",
26//!     parameters = {
27//!         { name = "query", type = "string", required = true, description = "Search query" },
28//!     },
29//! }
30//!
31//! function tool.execute(params, context)
32//!     local results = context.search(params.query)
33//!     return { results = results }
34//! end
35//! ```
36//!
37//! # Configuration
38//!
39//! ```toml
40//! [tools.script.my_tool]
41//! path = "tools/my-tool.lua"
42//! timeout = 30
43//! api_key = "${MY_API_KEY}"
44//! ```
45//!
46//! See `docs/LUA_TOOLS.md` for the full specification.
47
48use anyhow::{bail, Context, Result};
49use async_trait::async_trait;
50use mlua::prelude::*;
51use serde::Serialize;
52use std::path::{Path, PathBuf};
53use std::sync::Arc;
54use std::time::{Duration, Instant};
55
56use crate::config::{Config, ScriptToolConfig};
57use crate::get::{get_document, DocumentResponse};
58use crate::lua_runtime::{
59    json_value_to_lua, lua_value_to_json, register_all_host_apis, toml_table_to_lua,
60};
61use crate::search::{search_documents, SearchResultItem};
62use crate::sources::{get_sources, SourceStatus};
63use crate::traits::{Tool, ToolContext};
64
65// ═══════════════════════════════════════════════════════════════════════
66// Types
67// ═══════════════════════════════════════════════════════════════════════
68
69/// Metadata extracted from a loaded Lua tool script.
70///
71/// Created at server startup by [`load_tool_definitions`]. Contains
72/// everything needed to register the tool with the HTTP server and
73/// execute it when called.
74#[derive(Debug, Clone)]
75pub struct ToolDefinition {
76    /// Tool identifier (matches the config key in `[tools.script.<name>]`).
77    pub name: String,
78    /// One-line description for agent discovery.
79    pub description: String,
80    /// OpenAI function-calling JSON Schema for the tool's parameters.
81    pub parameters_schema: serde_json::Value,
82    /// Path to the `.lua` script file.
83    pub script_path: PathBuf,
84    /// Raw Lua source code (cached to avoid re-reading on every call).
85    pub script_source: String,
86    /// Tool-specific config keys from `ctx.toml` (passed as `context.config`).
87    pub config: toml::Table,
88    /// Maximum execution time in seconds.
89    pub timeout: u64,
90}
91
92/// Serializable tool info for the `/tools/list` endpoint.
93#[derive(Debug, Clone, Serialize)]
94pub struct ToolInfo {
95    /// Tool name.
96    pub name: String,
97    /// Tool description.
98    pub description: String,
99    /// Whether this is a built-in tool (`true`) or a Lua tool (`false`).
100    pub builtin: bool,
101    /// OpenAI function-calling JSON Schema.
102    pub parameters: serde_json::Value,
103}
104
105// ═══════════════════════════════════════════════════════════════════════
106// Tool trait adapter
107// ═══════════════════════════════════════════════════════════════════════
108
109/// Adapter that wraps a Lua [`ToolDefinition`] as a [`Tool`] trait object.
110///
111/// This allows Lua tools to participate in the unified tool dispatch
112/// alongside built-in and custom Rust tools.
113pub struct LuaToolAdapter {
114    /// The underlying Lua tool definition.
115    definition: ToolDefinition,
116    /// Application config needed for the Lua context bridge.
117    config: Arc<Config>,
118}
119
120impl LuaToolAdapter {
121    /// Create a new adapter wrapping a Lua tool definition.
122    pub fn new(definition: ToolDefinition, config: Arc<Config>) -> Self {
123        Self { definition, config }
124    }
125}
126
127#[async_trait]
128impl Tool for LuaToolAdapter {
129    fn name(&self) -> &str {
130        &self.definition.name
131    }
132
133    fn description(&self) -> &str {
134        &self.definition.description
135    }
136
137    fn parameters_schema(&self) -> serde_json::Value {
138        self.definition.parameters_schema.clone()
139    }
140
141    async fn execute(
142        &self,
143        params: serde_json::Value,
144        _ctx: &ToolContext,
145    ) -> Result<serde_json::Value> {
146        // Delegate to the existing Lua execution path, which has its own
147        // context bridge (search, get, sources) built into the Lua VM.
148        execute_tool(&self.definition, params, &self.config).await
149    }
150}
151
152// ═══════════════════════════════════════════════════════════════════════
153// Loading
154// ═══════════════════════════════════════════════════════════════════════
155
156/// Load all tool scripts from config and extract their definitions.
157///
158/// For each `[tools.script.<name>]` entry, reads the script file, creates
159/// a temporary Lua VM to extract the `tool` table metadata, and converts
160/// the parameter declarations to OpenAI JSON Schema.
161///
162/// Called once at server startup.
163pub fn load_tool_definitions(config: &Config) -> Result<Vec<ToolDefinition>> {
164    let mut tools = Vec::new();
165
166    for (name, tool_config) in &config.tools.script {
167        let tool_def = load_single_tool(name, tool_config)
168            .with_context(|| format!("Failed to load tool script '{}'", name))?;
169        tools.push(tool_def);
170    }
171
172    Ok(tools)
173}
174
175/// Load a single tool script and extract its definition.
176pub fn load_single_tool(name: &str, tool_config: &ScriptToolConfig) -> Result<ToolDefinition> {
177    let script_src = std::fs::read_to_string(&tool_config.path)
178        .with_context(|| format!("Failed to read tool script: {}", tool_config.path.display()))?;
179
180    // Create a temporary Lua VM just to extract metadata
181    let lua = Lua::new();
182    lua.load(&script_src)
183        .set_name(tool_config.path.to_string_lossy())
184        .exec()
185        .map_err(|e| {
186            anyhow::anyhow!(
187                "Failed to execute tool script {}: {}",
188                tool_config.path.display(),
189                e
190            )
191        })?;
192
193    let tool_table: LuaTable = lua
194        .globals()
195        .get::<LuaTable>("tool")
196        .map_err(|e| anyhow::anyhow!("Script must define a global 'tool' table: {}", e))?;
197
198    let description: String = tool_table
199        .get::<String>("description")
200        .unwrap_or_else(|_| format!("Lua tool: {}", name));
201
202    let params_table: LuaTable = tool_table
203        .get::<LuaTable>("parameters")
204        .unwrap_or_else(|_| lua.create_table().expect("create_table"));
205
206    let schema = lua_params_to_json_schema(&params_table)?;
207
208    Ok(ToolDefinition {
209        name: name.to_string(),
210        description,
211        parameters_schema: schema,
212        script_path: tool_config.path.clone(),
213        script_source: script_src,
214        config: tool_config.extra.clone(),
215        timeout: tool_config.timeout,
216    })
217}
218
219// ═══════════════════════════════════════════════════════════════════════
220// Schema Conversion
221// ═══════════════════════════════════════════════════════════════════════
222
223/// Convert Lua parameter declarations to OpenAI function-calling JSON Schema.
224///
225/// Input format (Lua array of tables):
226/// ```lua
227/// {
228///     { name = "title", type = "string", required = true, description = "Ticket title" },
229///     { name = "priority", type = "string", enum = { "low", "medium", "high" } },
230/// }
231/// ```
232///
233/// Output format:
234/// ```json
235/// {
236///     "type": "object",
237///     "properties": {
238///         "title": { "type": "string", "description": "Ticket title" },
239///         "priority": { "type": "string", "enum": ["low", "medium", "high"] }
240///     },
241///     "required": ["title"]
242/// }
243/// ```
244fn lua_params_to_json_schema(params: &LuaTable) -> Result<serde_json::Value> {
245    let mut properties = serde_json::Map::new();
246    let mut required = Vec::new();
247
248    let len = params.raw_len();
249    for i in 1..=len {
250        let param: LuaTable = params
251            .raw_get(i)
252            .map_err(|e| anyhow::anyhow!("Invalid parameter at index {}: {}", i, e))?;
253
254        let param_name: String = param
255            .get::<String>("name")
256            .map_err(|e| anyhow::anyhow!("Parameter at index {} missing 'name': {}", i, e))?;
257
258        let param_type: String = param
259            .get::<String>("type")
260            .unwrap_or_else(|_| "string".to_string());
261
262        let mut prop = serde_json::Map::new();
263        prop.insert("type".to_string(), serde_json::json!(param_type));
264
265        if let Ok(desc) = param.get::<String>("description") {
266            prop.insert("description".to_string(), serde_json::json!(desc));
267        }
268
269        if let Ok(default_val) = param.get::<LuaValue>("default") {
270            if !matches!(default_val, LuaValue::Nil) {
271                let json_default = lua_value_to_json(default_val)
272                    .map_err(|e| anyhow::anyhow!("Invalid default for '{}': {}", param_name, e))?;
273                prop.insert("default".to_string(), json_default);
274            }
275        }
276
277        if let Ok(enum_table) = param.get::<LuaTable>("enum") {
278            let mut enum_values = Vec::new();
279            let enum_len = enum_table.raw_len();
280            for j in 1..=enum_len {
281                let val: LuaValue = enum_table.raw_get(j)?;
282                let json_val = lua_value_to_json(val).map_err(|e| {
283                    anyhow::anyhow!("Invalid enum value for '{}': {}", param_name, e)
284                })?;
285                enum_values.push(json_val);
286            }
287            prop.insert("enum".to_string(), serde_json::Value::Array(enum_values));
288        }
289
290        let is_required = param.get::<bool>("required").unwrap_or(false);
291        if is_required {
292            required.push(serde_json::json!(param_name));
293        }
294
295        properties.insert(param_name, serde_json::Value::Object(prop));
296    }
297
298    let mut schema = serde_json::Map::new();
299    schema.insert("type".to_string(), serde_json::json!("object"));
300    schema.insert(
301        "properties".to_string(),
302        serde_json::Value::Object(properties),
303    );
304    if !required.is_empty() {
305        schema.insert("required".to_string(), serde_json::Value::Array(required));
306    }
307
308    Ok(serde_json::Value::Object(schema))
309}
310
311// ═══════════════════════════════════════════════════════════════════════
312// Parameter Validation
313// ═══════════════════════════════════════════════════════════════════════
314
315/// Validate incoming JSON parameters against a tool's schema.
316///
317/// Checks required fields, type compatibility, and enum constraints.
318/// Injects default values for missing optional fields. Returns the
319/// validated (and potentially enriched) parameters.
320pub fn validate_params(
321    schema: &serde_json::Value,
322    params: &serde_json::Value,
323) -> Result<serde_json::Value> {
324    let params_obj = params
325        .as_object()
326        .unwrap_or(&serde_json::Map::new())
327        .clone();
328
329    let properties = schema
330        .get("properties")
331        .and_then(|p| p.as_object())
332        .cloned()
333        .unwrap_or_default();
334
335    let required: Vec<String> = schema
336        .get("required")
337        .and_then(|r| r.as_array())
338        .map(|arr| {
339            arr.iter()
340                .filter_map(|v| v.as_str().map(|s| s.to_string()))
341                .collect()
342        })
343        .unwrap_or_default();
344
345    let mut result = params_obj.clone();
346
347    // Check required fields
348    for req_field in &required {
349        if !params_obj.contains_key(req_field) {
350            bail!("missing required parameter: {}", req_field);
351        }
352    }
353
354    // Type checking and enum validation
355    for (prop_name, prop_schema) in &properties {
356        if let Some(value) = params_obj.get(prop_name) {
357            // Type check
358            if let Some(expected_type) = prop_schema.get("type").and_then(|t| t.as_str()) {
359                let type_ok = match expected_type {
360                    "string" => value.is_string(),
361                    "integer" => value.is_i64() || value.is_u64(),
362                    "number" => value.is_number(),
363                    "boolean" => value.is_boolean(),
364                    "array" => value.is_array(),
365                    "object" => value.is_object(),
366                    _ => true,
367                };
368                if !type_ok {
369                    bail!(
370                        "parameter '{}' must be of type '{}', got {}",
371                        prop_name,
372                        expected_type,
373                        json_type_name(value)
374                    );
375                }
376            }
377
378            // Enum validation
379            if let Some(enum_values) = prop_schema.get("enum").and_then(|e| e.as_array()) {
380                if !enum_values.contains(value) {
381                    let allowed: Vec<String> = enum_values.iter().map(|v| v.to_string()).collect();
382                    bail!(
383                        "parameter '{}' must be one of [{}], got {}",
384                        prop_name,
385                        allowed.join(", "),
386                        value
387                    );
388                }
389            }
390        } else {
391            // Inject default value if available
392            if let Some(default) = prop_schema.get("default") {
393                result.insert(prop_name.clone(), default.clone());
394            }
395        }
396    }
397
398    Ok(serde_json::Value::Object(result))
399}
400
401/// Return a human-readable name for a JSON value's type.
402fn json_type_name(value: &serde_json::Value) -> &'static str {
403    match value {
404        serde_json::Value::Null => "null",
405        serde_json::Value::Bool(_) => "boolean",
406        serde_json::Value::Number(_) => "number",
407        serde_json::Value::String(_) => "string",
408        serde_json::Value::Array(_) => "array",
409        serde_json::Value::Object(_) => "object",
410    }
411}
412
413// ═══════════════════════════════════════════════════════════════════════
414// Execution
415// ═══════════════════════════════════════════════════════════════════════
416
417/// Execute a tool script with the given parameters.
418///
419/// Spawns a blocking thread, creates a sandboxed Lua VM with all host APIs
420/// plus the context bridge, and calls `tool.execute(params, context)`.
421///
422/// # Arguments
423///
424/// * `tool` — tool definition (script source, config, timeout).
425/// * `params` — validated JSON parameters for the tool.
426/// * `app_config` — full application config (needed for context bridge).
427///
428/// # Returns
429///
430/// The JSON value returned by `tool.execute()`.
431pub async fn execute_tool(
432    tool: &ToolDefinition,
433    params: serde_json::Value,
434    app_config: &Config,
435) -> Result<serde_json::Value> {
436    let tool = tool.clone();
437    let config = app_config.clone();
438
439    tokio::task::spawn_blocking(move || run_lua_tool(&tool, params, &config))
440        .await
441        .context("Lua tool task panicked")?
442}
443
444/// Run the Lua tool synchronously on a blocking thread.
445fn run_lua_tool(
446    tool: &ToolDefinition,
447    params: serde_json::Value,
448    config: &Config,
449) -> Result<serde_json::Value> {
450    let script_dir = tool
451        .script_path
452        .parent()
453        .unwrap_or(Path::new("."))
454        .to_path_buf();
455
456    let lua = Lua::new();
457
458    // Set up timeout via instruction hook
459    let timeout_secs = tool.timeout;
460    let deadline = Instant::now() + Duration::from_secs(timeout_secs);
461    lua.set_hook(
462        mlua::HookTriggers::new().every_nth_instruction(10_000),
463        move |_lua, _debug| {
464            if Instant::now() > deadline {
465                Err(mlua::Error::RuntimeError(format!(
466                    "tool timed out after {} seconds",
467                    timeout_secs
468                )))
469            } else {
470                Ok(mlua::VmState::Continue)
471            }
472        },
473    );
474
475    // Register all shared host APIs
476    let log_name = format!("tool:{}", tool.name);
477    register_all_host_apis(&lua, &log_name, &script_dir)?;
478
479    // Register context bridge
480    register_context_bridge(&lua, config, &tool.config)?;
481
482    // Load and execute the script
483    lua.load(&tool.script_source)
484        .set_name(tool.script_path.to_string_lossy())
485        .exec()
486        .map_err(|e| {
487            anyhow::anyhow!(
488                "Failed to execute tool script {}: {}",
489                tool.script_path.display(),
490                e
491            )
492        })?;
493
494    // Get tool.execute function
495    let tool_table: LuaTable = lua
496        .globals()
497        .get::<LuaTable>("tool")
498        .map_err(|e| anyhow::anyhow!("Script must define a global 'tool' table: {}", e))?;
499
500    let execute: LuaFunction = tool_table
501        .get::<LuaFunction>("execute")
502        .map_err(|e| anyhow::anyhow!("tool.execute function not defined: {}", e))?;
503
504    // Convert params to Lua table
505    let params_lua = json_value_to_lua(&lua, &params)?;
506
507    // Get the context table we already registered
508    let context: LuaTable = lua
509        .globals()
510        .get::<LuaTable>("context")
511        .map_err(|e| anyhow::anyhow!("context table missing: {}", e))?;
512
513    // Call tool.execute(params, context)
514    let result: LuaValue = execute
515        .call::<LuaValue>((params_lua, context))
516        .map_err(|e| {
517            anyhow::anyhow!(
518                "tool.execute() failed in '{}': {}",
519                tool.script_path.display(),
520                e
521            )
522        })?;
523
524    // Convert result to JSON
525    lua_value_to_json(result)
526        .map_err(|e| anyhow::anyhow!("Failed to convert tool result to JSON: {}", e))
527}
528
529// ═══════════════════════════════════════════════════════════════════════
530// Context Bridge
531// ═══════════════════════════════════════════════════════════════════════
532
533/// Register the `context` table in the Lua VM.
534///
535/// Provides `context.search`, `context.get`, `context.sources`, and
536/// `context.config`. The first three call back into Rust's async core
537/// via `tokio::runtime::Handle::block_on`.
538fn register_context_bridge(lua: &Lua, config: &Config, tool_config: &toml::Table) -> LuaResult<()> {
539    let ctx = lua.create_table()?;
540
541    // context.config — tool-specific config from ctx.toml
542    let config_lua = toml_table_to_lua(lua, tool_config)?;
543    ctx.set("config", config_lua)?;
544
545    // context.search(query, opts?) → results
546    let cfg = config.clone();
547    ctx.set(
548        "search",
549        lua.create_function(move |lua, (query, opts): (String, Option<LuaTable>)| {
550            let mode = opts
551                .as_ref()
552                .and_then(|o| o.get::<String>("mode").ok())
553                .unwrap_or_else(|| "keyword".to_string());
554            let limit = opts
555                .as_ref()
556                .and_then(|o| o.get::<i64>("limit").ok())
557                .unwrap_or(12);
558            let source = opts.as_ref().and_then(|o| o.get::<String>("source").ok());
559
560            let handle = tokio::runtime::Handle::current();
561            let results = handle
562                .block_on(async {
563                    search_documents(
564                        &cfg,
565                        &query,
566                        &mode,
567                        source.as_deref(),
568                        None,
569                        Some(limit),
570                        false,
571                    )
572                    .await
573                })
574                .map_err(mlua::Error::external)?;
575
576            search_results_to_lua(lua, &results)
577        })?,
578    )?;
579
580    // context.get(id) → document
581    let cfg = config.clone();
582    ctx.set(
583        "get",
584        lua.create_function(move |lua, id: String| {
585            let handle = tokio::runtime::Handle::current();
586            let doc = handle
587                .block_on(async { get_document(&cfg, &id).await })
588                .map_err(mlua::Error::external)?;
589
590            doc_response_to_lua(lua, &doc)
591        })?,
592    )?;
593
594    // context.sources() → sources
595    let cfg = config.clone();
596    ctx.set(
597        "sources",
598        lua.create_function(move |lua, ()| {
599            let sources = get_sources(&cfg);
600            sources_to_lua(lua, &sources)
601        })?,
602    )?;
603
604    lua.globals().set("context", ctx)?;
605    Ok(())
606}
607
608/// Convert search results to a Lua array table.
609fn search_results_to_lua(lua: &Lua, results: &[SearchResultItem]) -> LuaResult<LuaTable> {
610    let table = lua.create_table()?;
611    for (i, item) in results.iter().enumerate() {
612        let row = lua.create_table()?;
613        row.set("id", item.id.as_str())?;
614        row.set("score", item.score)?;
615        row.set("source", item.source.as_str())?;
616        row.set("source_id", item.source_id.as_str())?;
617        row.set("updated_at", item.updated_at.as_str())?;
618        row.set("snippet", item.snippet.as_str())?;
619        if let Some(ref title) = item.title {
620            row.set("title", title.as_str())?;
621        }
622        if let Some(ref url) = item.source_url {
623            row.set("source_url", url.as_str())?;
624        }
625        table.set(i as i64 + 1, row)?;
626    }
627    Ok(table)
628}
629
630/// Convert a document response to a Lua table.
631fn doc_response_to_lua(lua: &Lua, doc: &DocumentResponse) -> LuaResult<LuaTable> {
632    let table = lua.create_table()?;
633    table.set("id", doc.id.as_str())?;
634    table.set("source", doc.source.as_str())?;
635    table.set("source_id", doc.source_id.as_str())?;
636    table.set("content_type", doc.content_type.as_str())?;
637    table.set("body", doc.body.as_str())?;
638    table.set("created_at", doc.created_at.as_str())?;
639    table.set("updated_at", doc.updated_at.as_str())?;
640    if let Some(ref title) = doc.title {
641        table.set("title", title.as_str())?;
642    }
643    if let Some(ref author) = doc.author {
644        table.set("author", author.as_str())?;
645    }
646    if let Some(ref url) = doc.source_url {
647        table.set("source_url", url.as_str())?;
648    }
649
650    // Chunks
651    let chunks_table = lua.create_table()?;
652    for (i, chunk) in doc.chunks.iter().enumerate() {
653        let c = lua.create_table()?;
654        c.set("index", chunk.index)?;
655        c.set("text", chunk.text.as_str())?;
656        chunks_table.set(i as i64 + 1, c)?;
657    }
658    table.set("chunks", chunks_table)?;
659
660    Ok(table)
661}
662
663/// Convert source statuses to a Lua array table.
664fn sources_to_lua(lua: &Lua, sources: &[SourceStatus]) -> LuaResult<LuaTable> {
665    let table = lua.create_table()?;
666    for (i, s) in sources.iter().enumerate() {
667        let row = lua.create_table()?;
668        row.set("name", s.name.as_str())?;
669        row.set("configured", s.configured)?;
670        row.set("healthy", s.healthy)?;
671        if let Some(ref notes) = s.notes {
672            row.set("notes", notes.as_str())?;
673        }
674        table.set(i as i64 + 1, row)?;
675    }
676    Ok(table)
677}
678
679// ═══════════════════════════════════════════════════════════════════════
680// Tool Discovery
681// ═══════════════════════════════════════════════════════════════════════
682
683/// Build the list of all tools (built-in + Lua) for the `/tools/list` endpoint.
684pub fn build_tool_list(lua_tools: &[ToolDefinition]) -> Vec<ToolInfo> {
685    let mut tools = Vec::new();
686
687    // Built-in tools
688    tools.push(ToolInfo {
689        name: "search".to_string(),
690        description: "Search the knowledge base".to_string(),
691        builtin: true,
692        parameters: serde_json::json!({
693            "type": "object",
694            "properties": {
695                "query": { "type": "string", "description": "Search query" },
696                "mode": { "type": "string", "enum": ["keyword", "semantic", "hybrid"], "default": "keyword" },
697                "limit": { "type": "integer", "description": "Max results", "default": 12 },
698                "filters": {
699                    "type": "object",
700                    "properties": {
701                        "source": { "type": "string", "description": "Filter by connector source" },
702                        "since": { "type": "string", "description": "Only results updated after this date (YYYY-MM-DD)" }
703                    }
704                }
705            },
706            "required": ["query"]
707        }),
708    });
709
710    tools.push(ToolInfo {
711        name: "get".to_string(),
712        description: "Retrieve a document by UUID".to_string(),
713        builtin: true,
714        parameters: serde_json::json!({
715            "type": "object",
716            "properties": {
717                "id": { "type": "string", "description": "Document UUID" }
718            },
719            "required": ["id"]
720        }),
721    });
722
723    tools.push(ToolInfo {
724        name: "sources".to_string(),
725        description: "List connector configuration and health status".to_string(),
726        builtin: true,
727        parameters: serde_json::json!({
728            "type": "object",
729            "properties": {}
730        }),
731    });
732
733    // Lua tools
734    for tool in lua_tools {
735        tools.push(ToolInfo {
736            name: tool.name.clone(),
737            description: tool.description.clone(),
738            builtin: false,
739            parameters: tool.parameters_schema.clone(),
740        });
741    }
742
743    tools
744}
745
746// ═══════════════════════════════════════════════════════════════════════
747// CLI: scaffold & test
748// ═══════════════════════════════════════════════════════════════════════
749
750/// Scaffold a new tool script from a template.
751///
752/// Creates `tools/<name>.lua` with a commented template showing the
753/// tool interface and available host APIs.
754pub fn scaffold_tool(name: &str) -> Result<()> {
755    let dir = Path::new("tools");
756    std::fs::create_dir_all(dir)?;
757
758    let filename = format!("{}.lua", name.replace('_', "-"));
759    let path = dir.join(&filename);
760
761    if path.exists() {
762        bail!("Tool script already exists: {}", path.display());
763    }
764
765    let template = format!(
766        r#"--[[
767  Context Harness Tool: {name}
768
769  Configuration (add to ctx.toml):
770
771    [tools.script.{name}]
772    path = "tools/{filename}"
773    timeout = 30
774    # api_key = "${{{name_upper}_API_KEY}}"
775
776  Test:
777    ctx tool test tools/{filename} --param key=value
778]]
779
780tool = {{
781    name = "{name}",
782    version = "0.1.0",
783    description = "TODO: describe what this tool does",
784    parameters = {{
785        {{
786            name = "query",
787            type = "string",
788            required = true,
789            description = "Input query",
790        }},
791    }},
792}}
793
794--- Execute the tool with the given parameters and context.
795--- @param params table Validated parameters from the caller
796--- @param context table Bridge to Context Harness (search, get, sources, config)
797--- @return table Result to be serialized as JSON
798function tool.execute(params, context)
799    -- Example: search the knowledge base
800    -- local results = context.search(params.query, {{ mode = "hybrid", limit = 5 }})
801
802    -- Example: access tool config from ctx.toml
803    -- local api_key = context.config.api_key
804
805    return {{
806        success = true,
807        message = "TODO: implement tool logic",
808        query = params.query,
809    }}
810end
811"#,
812        name = name,
813        filename = filename,
814        name_upper = name.to_uppercase().replace('-', "_"),
815    );
816
817    std::fs::write(&path, template)?;
818    println!("Created tool: {}", path.display());
819    println!();
820    println!("Add to your ctx.toml:");
821    println!();
822    println!("  [tools.script.{}]", name);
823    println!("  path = \"tools/{}\"", filename);
824    println!();
825    println!("Then test:");
826    println!();
827    println!("  ctx tool test tools/{} --param query=\"hello\"", filename);
828
829    Ok(())
830}
831
832/// Test a tool script with sample parameters.
833///
834/// Loads the script, executes `tool.execute()` with the provided parameters,
835/// and prints the result. Useful for development and debugging.
836pub async fn test_tool(
837    path: &Path,
838    params: Vec<(String, String)>,
839    config: &Config,
840    source: Option<&str>,
841) -> Result<()> {
842    let script_src = std::fs::read_to_string(path)
843        .with_context(|| format!("Failed to read tool script: {}", path.display()))?;
844
845    let tool_config_extra = if let Some(name) = source {
846        config
847            .tools
848            .script
849            .get(name)
850            .map(|sc| sc.extra.clone())
851            .unwrap_or_default()
852    } else {
853        toml::Table::new()
854    };
855
856    let timeout = source
857        .and_then(|name| config.tools.script.get(name))
858        .map(|sc| sc.timeout)
859        .unwrap_or(30);
860
861    let name = source.unwrap_or("test").to_string();
862    println!("Testing tool: {} ({})", name, path.display());
863
864    // Build params JSON
865    let mut params_json = serde_json::Map::new();
866    for (k, v) in &params {
867        // Try to parse as JSON value first, fall back to string
868        let json_val = serde_json::from_str::<serde_json::Value>(v)
869            .unwrap_or_else(|_| serde_json::Value::String(v.clone()));
870        params_json.insert(k.clone(), json_val);
871    }
872    let params_value = serde_json::Value::Object(params_json);
873
874    let tool_def = ToolDefinition {
875        name: name.clone(),
876        description: String::new(),
877        parameters_schema: serde_json::json!({"type": "object", "properties": {}}),
878        script_path: path.to_path_buf(),
879        script_source: script_src,
880        config: tool_config_extra,
881        timeout,
882    };
883
884    println!("  ✓ Script loaded");
885
886    let result = execute_tool(&tool_def, params_value, config).await?;
887
888    println!("  ✓ Execution completed");
889    println!();
890    println!("Result:");
891
892    let pretty = serde_json::to_string_pretty(&result)?;
893    for line in pretty.lines() {
894        println!("  {}", line);
895    }
896
897    Ok(())
898}
899
900/// List all configured tools and print their info.
901pub fn list_tools(config: &Config) -> Result<()> {
902    let tool_defs = load_tool_definitions(config)?;
903    let tools = build_tool_list(&tool_defs);
904
905    if tools.is_empty() {
906        println!("No tools configured.");
907        return Ok(());
908    }
909
910    println!("{:<24} {:<8} DESCRIPTION", "TOOL", "TYPE");
911    for t in &tools {
912        let type_str = if t.builtin { "built-in" } else { "lua" };
913        println!("{:<24} {:<8} {}", t.name, type_str, t.description);
914    }
915
916    Ok(())
917}