skilllite_core/skill/
schema.rs1use serde_json::{json, Value};
7use std::path::Path;
8
9#[derive(Debug)]
11pub struct MultiScriptTool {
12 pub tool_name: String,
13 pub skill_name: String,
14 pub script_path: String,
15 pub language: String,
16 pub input_schema: Value,
17 pub description: String,
18}
19
20pub fn detect_multi_script_tools(skill_dir: &Path, skill_name: &str) -> Vec<MultiScriptTool> {
23 let scripts_dir = skill_dir.join("scripts");
24 if !scripts_dir.exists() || !scripts_dir.is_dir() {
25 return Vec::new();
26 }
27
28 let extensions = [
29 (".py", "python"),
30 (".js", "node"),
31 (".ts", "node"),
32 (".sh", "bash"),
33 ];
34 let skip_names = ["__init__.py"];
35 let mut tools = Vec::new();
36
37 for (ext, lang) in &extensions {
38 if let Ok(entries) = skilllite_fs::read_dir(&scripts_dir) {
39 for (path, _is_dir) in entries {
40 let fname = path
41 .file_name()
42 .map(|n| n.to_string_lossy().to_string())
43 .unwrap_or_default();
44
45 if !fname.ends_with(ext) {
46 continue;
47 }
48 if fname.starts_with("test_")
49 || fname.ends_with("_test.py")
50 || fname.starts_with('.')
51 || skip_names.contains(&fname.as_str())
52 {
53 continue;
54 }
55
56 let script_stem = fname.trim_end_matches(ext).replace('_', "-");
57 let tool_name = format!(
58 "{}__{}",
59 sanitize_tool_name(skill_name),
60 sanitize_tool_name(&script_stem)
61 );
62 let script_path = format!("scripts/{}", fname);
63
64 let desc = format!("Execute {} from {} skill", script_path, skill_name);
65
66 let input_schema = if fname.ends_with(".py") {
67 parse_argparse_schema(&path).unwrap_or_else(flexible_schema)
68 } else {
69 flexible_schema()
70 };
71
72 tools.push(MultiScriptTool {
73 tool_name,
74 skill_name: skill_name.to_string(),
75 script_path,
76 language: lang.to_string(),
77 input_schema,
78 description: desc,
79 });
80 }
81 }
82 }
83
84 tools
85}
86
87pub fn parse_argparse_schema(script_path: &Path) -> Option<Value> {
89 let content = skilllite_fs::read_file(script_path).ok()?;
90
91 let arg_re = regex::Regex::new(
92 r#"\.add_argument\s*\(\s*['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?([^)]*)\)"#,
93 )
94 .ok()?;
95
96 let mut properties = serde_json::Map::new();
97 let mut required = Vec::new();
98
99 let re_help = regex::Regex::new(r#"help\s*=\s*['"]([^'"]+)['"]"#).ok();
100 let re_type = regex::Regex::new(r"type\s*=\s*(\w+)").ok();
101 let re_action = regex::Regex::new(r#"action\s*=\s*['"](\w+)['"]"#).ok();
102 let re_nargs = regex::Regex::new(r#"nargs\s*=\s*['"]?([^,\s)]+)['"]?"#).ok();
103 let re_choices = regex::Regex::new(r"choices\s*=\s*\[([^\]]+)\]").ok();
104 let re_choice_quoted = regex::Regex::new(r#"['"]([^'"]+)['"]"#).ok();
105
106 for caps in arg_re.captures_iter(&content) {
107 let arg_name = caps.get(1)?.as_str();
108 let second_arg = caps.get(2).map(|m| m.as_str());
109 let kwargs_str = caps.get(3).map(|m| m.as_str()).unwrap_or("");
110
111 let (param_name, is_positional) = if let Some(stripped) = arg_name.strip_prefix("--") {
112 (stripped.replace('-', "_"), false)
113 } else if let Some(stripped) = arg_name.strip_prefix('-') {
114 if let Some(s) = second_arg {
115 if let Some(s2) = s.strip_prefix("--") {
116 (s2.replace('-', "_"), false)
117 } else {
118 (stripped.to_string(), false)
119 }
120 } else {
121 (stripped.to_string(), false)
122 }
123 } else {
124 (arg_name.replace('-', "_"), true)
125 };
126
127 let mut prop = serde_json::Map::new();
128 prop.insert("type".to_string(), json!("string"));
129
130 if let Some(help_cap) = re_help.as_ref().and_then(|re| re.captures(kwargs_str)) {
131 prop.insert(
132 "description".to_string(),
133 json!(help_cap.get(1).map(|m| m.as_str()).unwrap_or("")),
134 );
135 }
136
137 if let Some(type_cap) = re_type.as_ref().and_then(|re| re.captures(kwargs_str)) {
138 match type_cap.get(1).map(|m| m.as_str()).unwrap_or("") {
139 "int" => {
140 let _ = prop.insert("type".to_string(), json!("integer"));
141 }
142 "float" => {
143 let _ = prop.insert("type".to_string(), json!("number"));
144 }
145 "bool" => {
146 let _ = prop.insert("type".to_string(), json!("boolean"));
147 }
148 _ => {}
149 };
150 }
151
152 if let Some(action_cap) = re_action.as_ref().and_then(|re| re.captures(kwargs_str)) {
153 let action = action_cap.get(1).map(|m| m.as_str()).unwrap_or("");
154 if action == "store_true" || action == "store_false" {
155 prop.insert("type".to_string(), json!("boolean"));
156 }
157 }
158
159 if let Some(nargs_cap) = re_nargs.as_ref().and_then(|re| re.captures(kwargs_str)) {
160 let nargs = nargs_cap.get(1).map(|m| m.as_str()).unwrap_or("");
161 if nargs == "*" || nargs == "+" || nargs.parse::<u32>().is_ok() {
162 prop.insert("type".to_string(), json!("array"));
163 prop.insert("items".to_string(), json!({"type": "string"}));
164 }
165 }
166
167 if let Some(choices_cap) = re_choices.as_ref().and_then(|re| re.captures(kwargs_str)) {
168 let choices_str = choices_cap.get(1).map(|m| m.as_str()).unwrap_or("");
169 let choices: Vec<String> = re_choice_quoted
170 .as_ref()
171 .map(|re| {
172 re.captures_iter(choices_str)
173 .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
174 .collect()
175 })
176 .unwrap_or_default();
177 if !choices.is_empty() {
178 prop.insert("enum".to_string(), json!(choices));
179 }
180 }
181
182 let is_required = kwargs_str.contains("required=True") || is_positional;
183 if is_required {
184 required.push(param_name.clone());
185 }
186
187 properties.insert(param_name, Value::Object(prop));
188 }
189
190 if properties.is_empty() {
191 return None;
192 }
193
194 Some(json!({
195 "type": "object",
196 "properties": properties,
197 "required": required
198 }))
199}
200
201fn flexible_schema() -> Value {
202 json!({
203 "type": "object",
204 "properties": {},
205 "additionalProperties": true
206 })
207}
208
209fn sanitize_tool_name(name: &str) -> String {
210 name.chars()
211 .map(|c| {
212 if c.is_alphanumeric() || c == '_' {
213 c
214 } else {
215 '_'
216 }
217 })
218 .collect::<String>()
219 .to_lowercase()
220}