use serde_json::{json, Value};
use std::path::Path;
#[derive(Debug)]
pub struct MultiScriptTool {
pub tool_name: String,
pub skill_name: String,
pub script_path: String,
pub language: String,
pub input_schema: Value,
pub description: String,
}
pub fn detect_multi_script_tools(skill_dir: &Path, skill_name: &str) -> Vec<MultiScriptTool> {
let scripts_dir = skill_dir.join("scripts");
if !scripts_dir.exists() || !scripts_dir.is_dir() {
return Vec::new();
}
let extensions = [
(".py", "python"),
(".js", "node"),
(".ts", "node"),
(".sh", "bash"),
];
let skip_names = ["__init__.py"];
let mut tools = Vec::new();
for (ext, lang) in &extensions {
if let Ok(entries) = skilllite_fs::read_dir(&scripts_dir) {
for (path, _is_dir) in entries {
let fname = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if !fname.ends_with(ext) {
continue;
}
if fname.starts_with("test_")
|| fname.ends_with("_test.py")
|| fname.starts_with('.')
|| skip_names.contains(&fname.as_str())
{
continue;
}
let script_stem = fname.trim_end_matches(ext).replace('_', "-");
let tool_name = format!(
"{}__{}",
sanitize_tool_name(skill_name),
sanitize_tool_name(&script_stem)
);
let script_path = format!("scripts/{}", fname);
let desc = format!("Execute {} from {} skill", script_path, skill_name);
let input_schema = if fname.ends_with(".py") {
parse_argparse_schema(&path).unwrap_or_else(flexible_schema)
} else {
flexible_schema()
};
tools.push(MultiScriptTool {
tool_name,
skill_name: skill_name.to_string(),
script_path,
language: lang.to_string(),
input_schema,
description: desc,
});
}
}
}
tools
}
pub fn parse_argparse_schema(script_path: &Path) -> Option<Value> {
let content = skilllite_fs::read_file(script_path).ok()?;
let arg_re = regex::Regex::new(
r#"\.add_argument\s*\(\s*['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?([^)]*)\)"#,
)
.ok()?;
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
let re_help = regex::Regex::new(r#"help\s*=\s*['"]([^'"]+)['"]"#).ok();
let re_type = regex::Regex::new(r"type\s*=\s*(\w+)").ok();
let re_action = regex::Regex::new(r#"action\s*=\s*['"](\w+)['"]"#).ok();
let re_nargs = regex::Regex::new(r#"nargs\s*=\s*['"]?([^,\s)]+)['"]?"#).ok();
let re_choices = regex::Regex::new(r"choices\s*=\s*\[([^\]]+)\]").ok();
let re_choice_quoted = regex::Regex::new(r#"['"]([^'"]+)['"]"#).ok();
for caps in arg_re.captures_iter(&content) {
let arg_name = caps.get(1)?.as_str();
let second_arg = caps.get(2).map(|m| m.as_str());
let kwargs_str = caps.get(3).map(|m| m.as_str()).unwrap_or("");
let (param_name, is_positional) = if let Some(stripped) = arg_name.strip_prefix("--") {
(stripped.replace('-', "_"), false)
} else if let Some(stripped) = arg_name.strip_prefix('-') {
if let Some(s) = second_arg {
if let Some(s2) = s.strip_prefix("--") {
(s2.replace('-', "_"), false)
} else {
(stripped.to_string(), false)
}
} else {
(stripped.to_string(), false)
}
} else {
(arg_name.replace('-', "_"), true)
};
let mut prop = serde_json::Map::new();
prop.insert("type".to_string(), json!("string"));
if let Some(help_cap) = re_help.as_ref().and_then(|re| re.captures(kwargs_str)) {
prop.insert(
"description".to_string(),
json!(help_cap.get(1).map(|m| m.as_str()).unwrap_or("")),
);
}
if let Some(type_cap) = re_type.as_ref().and_then(|re| re.captures(kwargs_str)) {
match type_cap.get(1).map(|m| m.as_str()).unwrap_or("") {
"int" => {
let _ = prop.insert("type".to_string(), json!("integer"));
}
"float" => {
let _ = prop.insert("type".to_string(), json!("number"));
}
"bool" => {
let _ = prop.insert("type".to_string(), json!("boolean"));
}
_ => {}
};
}
if let Some(action_cap) = re_action.as_ref().and_then(|re| re.captures(kwargs_str)) {
let action = action_cap.get(1).map(|m| m.as_str()).unwrap_or("");
if action == "store_true" || action == "store_false" {
prop.insert("type".to_string(), json!("boolean"));
}
}
if let Some(nargs_cap) = re_nargs.as_ref().and_then(|re| re.captures(kwargs_str)) {
let nargs = nargs_cap.get(1).map(|m| m.as_str()).unwrap_or("");
if nargs == "*" || nargs == "+" || nargs.parse::<u32>().is_ok() {
prop.insert("type".to_string(), json!("array"));
prop.insert("items".to_string(), json!({"type": "string"}));
}
}
if let Some(choices_cap) = re_choices.as_ref().and_then(|re| re.captures(kwargs_str)) {
let choices_str = choices_cap.get(1).map(|m| m.as_str()).unwrap_or("");
let choices: Vec<String> = re_choice_quoted
.as_ref()
.map(|re| {
re.captures_iter(choices_str)
.filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
.collect()
})
.unwrap_or_default();
if !choices.is_empty() {
prop.insert("enum".to_string(), json!(choices));
}
}
let is_required = kwargs_str.contains("required=True") || is_positional;
if is_required {
required.push(param_name.clone());
}
properties.insert(param_name, Value::Object(prop));
}
if properties.is_empty() {
return None;
}
Some(json!({
"type": "object",
"properties": properties,
"required": required
}))
}
fn flexible_schema() -> Value {
json!({
"type": "object",
"properties": {},
"additionalProperties": true
})
}
fn sanitize_tool_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect::<String>()
.to_lowercase()
}