Skip to main content

skilllite_core/skill/
schema.rs

1//! Schema inference for skill list --json output.
2//!
3//! Provides multi-script tool detection and argparse schema parsing
4//! for Python SDK delegation (Phase 4.8 Metadata 委托).
5
6use serde_json::{json, Value};
7use std::path::Path;
8
9/// Multi-script tool entry for list --json output.
10#[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
20/// Detect all executable scripts in a skill directory and return tool definitions.
21/// Used when skill has no entry_point (multi-script skill like skill-creator).
22pub 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
87/// Parse Python script for argparse `add_argument` calls and generate JSON schema.
88pub 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}