use semver::Version;
use serde::Deserialize;
use serde_json::{Map as JsonMap, Value as JsonValue};
use std::fs;
use std::path::Path;
#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum SkillLanceDbLogLevel {
Off,
#[default]
Info,
Warning,
}
impl SkillLanceDbLogLevel {
pub fn as_str(&self) -> &'static str {
match self {
Self::Off => "off",
Self::Info => "info",
Self::Warning => "warning",
}
}
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum SkillSqliteLogLevel {
Off,
#[default]
Info,
Warning,
}
impl SkillSqliteLogLevel {
pub fn as_str(&self) -> &'static str {
match self {
Self::Off => "off",
Self::Info => "info",
Self::Warning => "warning",
}
}
}
#[derive(Deserialize, Debug, Clone, Default)]
pub struct SkillLanceDbMeta {
#[serde(default)]
pub enable: bool,
#[serde(default)]
pub log_level: SkillLanceDbLogLevel,
#[serde(default)]
pub slow_log_enabled: bool,
#[serde(default = "default_lancedb_slow_log_threshold_ms")]
pub slow_log_threshold_ms: u64,
}
#[derive(Deserialize, Debug, Clone, Default)]
pub struct SkillSqliteMeta {
#[serde(default)]
pub enable: bool,
#[serde(default)]
pub log_level: SkillSqliteLogLevel,
#[serde(default)]
pub slow_log_enabled: bool,
#[serde(default = "default_sqlite_slow_log_threshold_ms")]
pub slow_log_threshold_ms: u64,
}
fn default_lancedb_slow_log_threshold_ms() -> u64 {
800
}
fn default_sqlite_slow_log_threshold_ms() -> u64 {
500
}
#[derive(Deserialize, Debug, Clone, Default)]
pub struct SkillHelpNodeMeta {
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub file: String,
}
#[derive(Deserialize, Debug, Clone, Default)]
pub struct SkillHelpMeta {
#[serde(default)]
pub main: SkillHelpNodeMeta,
#[serde(default)]
pub topics: Vec<SkillHelpNodeMeta>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct SkillParam {
pub name: String,
#[serde(rename = "type")]
pub param_type: String,
pub description: String,
#[serde(default)]
pub required: bool,
}
#[derive(Deserialize, Debug, Clone)]
pub struct SkillToolMeta {
pub name: String,
#[serde(default)]
pub description: String,
pub lua_entry: String,
pub lua_module: String,
#[serde(default)]
pub parameters: Vec<SkillParam>,
#[serde(default)]
pub input_schema: Option<JsonValue>,
#[serde(default)]
pub input_schema_file: String,
#[serde(default)]
pub help: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct SkillMeta {
pub name: String,
pub version: String,
#[serde(default = "default_skill_enable_flag")]
pub enable: bool,
#[serde(default)]
pub debug: bool,
#[serde(default)]
pub lancedb: SkillLanceDbMeta,
#[serde(default)]
pub sqlite: SkillSqliteMeta,
#[serde(default)]
pub entries: Vec<SkillToolMeta>,
#[serde(default)]
pub help: SkillHelpMeta,
#[serde(skip)]
resolved_skill_id: String,
}
pub fn is_valid_luaskills_identifier(value: &str) -> bool {
let trimmed = value.trim();
if trimmed.is_empty() {
return false;
}
let mut chars = trimmed.chars();
let Some(first_char) = chars.next() else {
return false;
};
if !first_char.is_ascii_lowercase() {
return false;
}
if trimmed.ends_with('-') {
return false;
}
chars.all(|character| {
character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
})
}
pub fn validate_luaskills_identifier(value: &str, label: &str) -> Result<(), String> {
if is_valid_luaskills_identifier(value) {
return Ok(());
}
Err(format!("{label} must match ^[a-z]([a-z0-9-]*[a-z0-9])?$"))
}
pub fn validate_luaskills_version(value: &str, label: &str) -> Result<(), String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(format!("{label} must not be empty"));
}
Version::parse(trimmed)
.map(|_| ())
.map_err(|error| format!("{label} must be a valid semantic version: {}", error))
}
fn default_skill_enable_flag() -> bool {
true
}
fn validate_skill_relative_path(
relative_path: &str,
expected_prefix: &str,
field_label: &str,
) -> Result<(), String> {
let trimmed = relative_path.trim();
if trimmed.is_empty() {
return Err(format!("{field_label} must not be empty"));
}
let path = Path::new(trimmed);
if path.is_absolute() {
return Err(format!(
"{field_label} must be a relative path under {expected_prefix}"
));
}
let normalized = trimmed.replace('\\', "/");
let required_prefix = format!("{expected_prefix}/");
if !normalized.starts_with(&required_prefix) {
return Err(format!("{field_label} must start with {required_prefix}"));
}
for component in path.components() {
if !matches!(component, std::path::Component::Normal(_)) {
return Err(format!("{field_label} must not contain parent"));
}
}
Ok(())
}
fn validate_tool_schema_type_field(
object: &JsonMap<String, JsonValue>,
field_label: &str,
) -> Result<(), String> {
let Some(type_value) = object.get("type") else {
return Ok(());
};
match type_value {
JsonValue::String(type_name) => {
if type_name.trim().is_empty() {
return Err(format!("{field_label}.type must not be empty"));
}
}
JsonValue::Array(items) => {
if items.is_empty() {
return Err(format!("{field_label}.type must not be an empty array"));
}
for (index, item) in items.iter().enumerate() {
let type_name = item
.as_str()
.ok_or_else(|| format!("{field_label}.type[{index}] must be one string"))?;
if type_name.trim().is_empty() {
return Err(format!("{field_label}.type[{index}] must not be empty"));
}
}
}
_ => {
return Err(format!(
"{field_label}.type must be one string or one string array"
));
}
}
Ok(())
}
fn validate_tool_schema_name_array(value: &JsonValue, field_label: &str) -> Result<(), String> {
let items = value
.as_array()
.ok_or_else(|| format!("{field_label} must be an array of strings"))?;
for (index, item) in items.iter().enumerate() {
let item_text = item
.as_str()
.ok_or_else(|| format!("{field_label}[{index}] must be one string"))?;
if item_text.trim().is_empty() {
return Err(format!("{field_label}[{index}] must not be empty"));
}
}
Ok(())
}
fn validate_tool_schema_node_array(value: &JsonValue, field_label: &str) -> Result<(), String> {
let items = value
.as_array()
.ok_or_else(|| format!("{field_label} must be an array of schema objects"))?;
if items.is_empty() {
return Err(format!("{field_label} must not be empty"));
}
for (index, item) in items.iter().enumerate() {
validate_tool_schema_node(item, &format!("{field_label}[{index}]"))?;
}
Ok(())
}
fn validate_tool_schema_object_map(value: &JsonValue, field_label: &str) -> Result<(), String> {
let object = value
.as_object()
.ok_or_else(|| format!("{field_label} must be an object"))?;
for (name, item) in object {
if name.trim().is_empty() {
return Err(format!(
"{field_label} must not contain empty property names"
));
}
validate_tool_schema_node(item, &format!("{field_label}.{}", name))?;
}
Ok(())
}
fn validate_tool_schema_node(schema: &JsonValue, field_label: &str) -> Result<(), String> {
let object = schema
.as_object()
.ok_or_else(|| format!("{field_label} must be one JSON object schema"))?;
validate_tool_schema_type_field(object, field_label)?;
if let Some(properties) = object.get("properties") {
validate_tool_schema_object_map(properties, &format!("{field_label}.properties"))?;
}
if let Some(pattern_properties) = object.get("patternProperties") {
validate_tool_schema_object_map(
pattern_properties,
&format!("{field_label}.patternProperties"),
)?;
}
if let Some(definitions) = object.get("$defs").or_else(|| object.get("definitions")) {
validate_tool_schema_object_map(definitions, &format!("{field_label}.$defs"))?;
}
if let Some(required) = object.get("required") {
validate_tool_schema_name_array(required, &format!("{field_label}.required"))?;
}
if let Some(enum_values) = object.get("enum") {
enum_values
.as_array()
.ok_or_else(|| format!("{field_label}.enum must be an array"))?;
}
if let Some(items) = object.get("items") {
match items {
JsonValue::Object(_) => {
validate_tool_schema_node(items, &format!("{field_label}.items"))?;
}
JsonValue::Array(_) => {
validate_tool_schema_node_array(items, &format!("{field_label}.items"))?;
}
_ => {
return Err(format!(
"{field_label}.items must be one schema object or one schema array"
));
}
}
}
if let Some(prefix_items) = object.get("prefixItems") {
validate_tool_schema_node_array(prefix_items, &format!("{field_label}.prefixItems"))?;
}
if let Some(contains) = object.get("contains") {
validate_tool_schema_node(contains, &format!("{field_label}.contains"))?;
}
if let Some(property_names) = object.get("propertyNames") {
validate_tool_schema_node(property_names, &format!("{field_label}.propertyNames"))?;
}
if let Some(additional_properties) = object.get("additionalProperties") {
match additional_properties {
JsonValue::Bool(_) => {}
JsonValue::Object(_) => validate_tool_schema_node(
additional_properties,
&format!("{field_label}.additionalProperties"),
)?,
_ => {
return Err(format!(
"{field_label}.additionalProperties must be one boolean or one schema object"
));
}
}
}
for keyword in ["oneOf", "anyOf", "allOf"] {
if let Some(value) = object.get(keyword) {
validate_tool_schema_node_array(value, &format!("{field_label}.{keyword}"))?;
}
}
if let Some(not_schema) = object.get("not") {
validate_tool_schema_node(not_schema, &format!("{field_label}.not"))?;
}
if let Some(if_schema) = object.get("if") {
validate_tool_schema_node(if_schema, &format!("{field_label}.if"))?;
}
if let Some(then_schema) = object.get("then") {
validate_tool_schema_node(then_schema, &format!("{field_label}.then"))?;
}
if let Some(else_schema) = object.get("else") {
validate_tool_schema_node(else_schema, &format!("{field_label}.else"))?;
}
Ok(())
}
fn validate_entry_input_schema_root(schema: &JsonValue, field_label: &str) -> Result<(), String> {
validate_tool_schema_node(schema, field_label)?;
let object = schema
.as_object()
.ok_or_else(|| format!("{field_label} must be one JSON object schema"))?;
match object.get("type") {
Some(JsonValue::String(type_name)) if type_name == "object" => Ok(()),
Some(_) => Err(format!("{field_label}.type must be \"object\"")),
None => Err(format!(
"{field_label}.type must be present and equal to \"object\""
)),
}
}
fn build_entry_input_schema_from_parameters(parameters: &[SkillParam]) -> JsonValue {
let mut properties = JsonMap::new();
let mut required = Vec::new();
for parameter in parameters {
properties.insert(
parameter.name.clone(),
build_parameter_schema_fragment(parameter),
);
if parameter.required {
required.push(JsonValue::String(parameter.name.clone()));
}
}
let mut schema = JsonMap::new();
schema.insert("type".to_string(), JsonValue::String("object".to_string()));
schema.insert("properties".to_string(), JsonValue::Object(properties));
if !required.is_empty() {
schema.insert("required".to_string(), JsonValue::Array(required));
}
JsonValue::Object(schema)
}
fn build_parameter_schema_fragment(parameter: &SkillParam) -> JsonValue {
let mut schema = JsonMap::new();
schema.insert(
"type".to_string(),
JsonValue::String(parameter.param_type.clone()),
);
if !parameter.description.trim().is_empty() {
schema.insert(
"description".to_string(),
JsonValue::String(parameter.description.clone()),
);
}
JsonValue::Object(schema)
}
fn derive_parameter_type_from_schema(schema: &JsonValue) -> String {
let Some(object) = schema.as_object() else {
return "schema".to_string();
};
if let Some(type_name) = object.get("type").and_then(JsonValue::as_str) {
return type_name.to_string();
}
if let Some(type_items) = object.get("type").and_then(JsonValue::as_array) {
let names: Vec<String> = type_items
.iter()
.filter_map(JsonValue::as_str)
.map(ToString::to_string)
.collect();
if !names.is_empty() {
return names.join(" | ");
}
}
if object.contains_key("properties") {
return "object".to_string();
}
if object.contains_key("items") || object.contains_key("prefixItems") {
return "array".to_string();
}
if object.contains_key("oneOf") || object.contains_key("anyOf") || object.contains_key("allOf")
{
return "union".to_string();
}
"schema".to_string()
}
fn derive_legacy_parameters_from_input_schema(schema: &JsonValue) -> Vec<SkillParam> {
let Some(object) = schema.as_object() else {
return Vec::new();
};
let Some(properties) = object.get("properties").and_then(JsonValue::as_object) else {
return Vec::new();
};
let required_names = object
.get("required")
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.filter_map(JsonValue::as_str)
.map(ToString::to_string)
.collect::<Vec<String>>()
})
.unwrap_or_default();
properties
.iter()
.map(|(name, property_schema)| SkillParam {
name: name.clone(),
param_type: derive_parameter_type_from_schema(property_schema),
description: property_schema
.as_object()
.and_then(|item| item.get("description"))
.and_then(JsonValue::as_str)
.unwrap_or_default()
.to_string(),
required: required_names
.iter()
.any(|required_name| required_name == name),
})
.collect()
}
fn load_entry_input_schema_file(
skill_dir: &Path,
relative_path: &str,
entry_name: &str,
) -> Result<JsonValue, String> {
validate_skill_relative_path(relative_path, "schemas", "entry.input_schema_file")?;
let schema_path = skill_dir.join(relative_path);
let schema_text = fs::read_to_string(&schema_path).map_err(|error| {
format!(
"skill entry {} input_schema_file {} read failed: {}",
entry_name,
schema_path.display(),
error
)
})?;
serde_json::from_str(&schema_text).map_err(|error| {
format!(
"skill entry {} input_schema_file {} parse failed: {}",
entry_name,
schema_path.display(),
error
)
})
}
impl SkillMeta {
pub fn effective_lancedb(&self) -> SkillLanceDbMeta {
self.lancedb.clone()
}
pub fn effective_sqlite(&self) -> SkillSqliteMeta {
self.sqlite.clone()
}
pub fn bind_directory_skill_id(&mut self, skill_id: String) {
self.resolved_skill_id = skill_id;
}
pub fn resolve_entry_input_schemas(&mut self, skill_dir: &Path) -> Result<(), String> {
for tool in &mut self.entries {
tool.resolve_input_schema(skill_dir)?;
}
Ok(())
}
pub fn effective_skill_id(&self) -> &str {
self.resolved_skill_id.trim()
}
pub fn version(&self) -> &str {
self.version.trim()
}
pub fn is_enabled(&self) -> bool {
self.enable
}
pub fn entries(&self) -> impl Iterator<Item = &SkillToolMeta> {
self.entries.iter()
}
pub fn tool_base_name(&self, tool: &SkillToolMeta) -> String {
format!("{}-{}", self.effective_skill_id(), tool.name.trim())
}
pub fn find_tool_by_local_name(&self, tool_name: &str) -> Option<&SkillToolMeta> {
self.entries().find(|tool| tool.name.trim() == tool_name)
}
pub fn main_help(&self) -> &SkillHelpNodeMeta {
&self.help.main
}
pub fn help_topics(&self) -> impl Iterator<Item = &SkillHelpNodeMeta> {
self.help.topics.iter()
}
pub fn find_help_topic(&self, topic_name: &str) -> Option<&SkillHelpNodeMeta> {
self.help_topics()
.find(|topic| topic.name.trim() == topic_name)
}
pub fn entries_for_help_topic<'a>(
&'a self,
topic_name: &'a str,
) -> impl Iterator<Item = &'a SkillToolMeta> + 'a {
self.entries()
.filter(move |tool| tool.help.trim() == topic_name)
}
}
impl SkillToolMeta {
pub fn resolve_input_schema(&mut self, skill_dir: &Path) -> Result<(), String> {
let entry_name = self.name.trim().to_string();
let has_inline_input_schema = self.input_schema.is_some();
let schema_file = self.input_schema_file.trim().to_string();
if has_inline_input_schema && !schema_file.is_empty() {
return Err(format!(
"skill entry {} must not declare both input_schema and input_schema_file",
entry_name
));
}
let resolved_input_schema = if !schema_file.is_empty() {
load_entry_input_schema_file(skill_dir, &schema_file, &entry_name)?
} else if let Some(schema) = self.input_schema.clone() {
schema
} else {
build_entry_input_schema_from_parameters(&self.parameters)
};
validate_entry_input_schema_root(
&resolved_input_schema,
&format!("skill entry {} input_schema", entry_name),
)?;
if self.parameters.is_empty() {
self.parameters = derive_legacy_parameters_from_input_schema(&resolved_input_schema);
}
self.input_schema = Some(resolved_input_schema);
Ok(())
}
pub fn resolved_input_schema(&self) -> &JsonValue {
self.input_schema
.as_ref()
.expect("entry input schema must be resolved before use")
}
}
#[cfg(test)]
mod tests {
use super::{
SkillMeta, SkillParam, build_entry_input_schema_from_parameters,
derive_legacy_parameters_from_input_schema, is_valid_luaskills_identifier,
validate_luaskills_identifier, validate_luaskills_version,
};
use serde_json::json;
use std::fs;
fn make_manifest_test_dir(label: &str) -> std::path::PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
std::env::temp_dir().join(format!("luaskills_manifest_{label}_{nonce}"))
}
#[test]
fn valid_luaskills_identifiers_are_accepted() {
assert!(is_valid_luaskills_identifier("vulcan-codekit"));
assert!(is_valid_luaskills_identifier("codekit2"));
assert!(is_valid_luaskills_identifier("vulcan-runtime-tools"));
}
#[test]
fn invalid_luaskills_identifiers_are_rejected() {
for candidate in [
"",
"2codekit",
"Vulcan-codekit",
"vulcan_codekit",
"vulcan-codekit-",
"__demo",
] {
assert!(!is_valid_luaskills_identifier(candidate));
assert!(validate_luaskills_identifier(candidate, "skill_id").is_err());
}
}
#[test]
fn semantic_skill_versions_are_validated() {
assert!(validate_luaskills_version("0.1.0", "version").is_ok());
assert!(validate_luaskills_version("1.2.3-beta.1", "version").is_ok());
assert!(validate_luaskills_version("", "version").is_err());
assert!(validate_luaskills_version("v1.0.0", "version").is_err());
assert!(validate_luaskills_version("1", "version").is_err());
}
#[test]
fn legacy_parameters_project_to_input_schema() {
let schema = build_entry_input_schema_from_parameters(&[
SkillParam {
name: "path".to_string(),
param_type: "string".to_string(),
description: "Absolute file path.".to_string(),
required: true,
},
SkillParam {
name: "recursive".to_string(),
param_type: "boolean".to_string(),
description: "Whether to recurse.".to_string(),
required: false,
},
]);
assert_eq!(schema["type"], "object");
assert_eq!(schema["properties"]["path"]["type"], "string");
assert_eq!(schema["properties"]["recursive"]["type"], "boolean");
assert_eq!(schema["required"], json!(["path"]));
}
#[test]
fn input_schema_projects_back_to_legacy_parameters() {
let parameters = derive_legacy_parameters_from_input_schema(&json!({
"type": "object",
"properties": {
"nodes": {
"type": "array",
"description": "Node selector list."
},
"strict": {
"type": "boolean",
"description": "Enable strict validation."
}
},
"required": ["nodes"]
}));
assert_eq!(parameters.len(), 2);
assert_eq!(parameters[0].name, "nodes");
assert_eq!(parameters[0].param_type, "array");
assert!(parameters[0].required);
assert_eq!(parameters[1].name, "strict");
assert_eq!(parameters[1].param_type, "boolean");
assert!(!parameters[1].required);
}
#[test]
fn skill_meta_resolves_external_entry_input_schema_file() {
let skill_dir = make_manifest_test_dir("schema_file");
fs::create_dir_all(skill_dir.join("schemas")).expect("create schemas dir");
fs::write(
skill_dir.join("schemas").join("search.input.schema.json"),
serde_json::to_string_pretty(&json!({
"type": "object",
"properties": {
"nodes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"file": { "type": "string" },
"structural_path": { "type": "string" }
},
"required": ["file", "structural_path"]
}
}
},
"required": ["nodes"]
}))
.expect("serialize schema file"),
)
.expect("write schema file");
let mut meta: SkillMeta = serde_yaml::from_str(
r#"
name: demo-skill
version: 0.1.0
enable: true
entries:
- name: search
description: Demo search.
lua_entry: runtime/search.lua
lua_module: demo_search
input_schema_file: schemas/search.input.schema.json
"#,
)
.expect("parse manifest");
meta.bind_directory_skill_id("demo-skill".to_string());
meta.resolve_entry_input_schemas(&skill_dir)
.expect("resolve entry input schemas");
let tool = meta
.find_tool_by_local_name("search")
.expect("search entry");
assert_eq!(tool.resolved_input_schema()["type"], "object");
assert_eq!(tool.resolved_input_schema()["required"], json!(["nodes"]));
assert_eq!(tool.parameters.len(), 1);
assert_eq!(tool.parameters[0].name, "nodes");
assert_eq!(tool.parameters[0].param_type, "array");
fs::remove_dir_all(&skill_dir).expect("cleanup schema file test dir");
}
#[test]
fn skill_meta_rejects_non_object_entry_input_schema() {
let skill_dir = make_manifest_test_dir("invalid_schema");
fs::create_dir_all(&skill_dir).expect("create invalid schema dir");
let mut meta: SkillMeta = serde_yaml::from_str(
r#"
name: demo-skill
version: 0.1.0
enable: true
entries:
- name: search
description: Demo search.
lua_entry: runtime/search.lua
lua_module: demo_search
input_schema:
type: array
"#,
)
.expect("parse invalid manifest");
meta.bind_directory_skill_id("demo-skill".to_string());
let error = meta
.resolve_entry_input_schemas(&skill_dir)
.expect_err("non-object root schema should fail");
assert!(error.contains("input_schema.type must be \"object\""));
fs::remove_dir_all(&skill_dir).expect("cleanup invalid schema dir");
}
}