lazyBoy/crates/api/src/file_skills.rs

143 lines
4.3 KiB
Rust
Raw Normal View History

2026-09-06 03:43:38 +00:00
//! Read-only, workspace-wide SKILL.md commands.
use std::path::{Path, PathBuf};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileSkill {
pub name: String,
pub description: String,
#[serde(skip_serializing)]
pub instructions: String,
}
fn valid_name(name: &str) -> bool {
!name.is_empty()
&& name != "goal"
&& name != "skills"
&& name != "help"
2026-09-07 14:23:38 +00:00
&& name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
2026-09-06 03:43:38 +00:00
}
fn parse_file(path: PathBuf, name: String) -> Option<FileSkill> {
let file_type = std::fs::symlink_metadata(&path).ok()?.file_type();
if file_type.is_symlink() || !file_type.is_file() {
return None;
}
if std::fs::metadata(&path).ok()?.len() > 512 * 1024 {
return None;
}
let text = std::fs::read_to_string(&path).ok()?;
let (frontmatter, instructions) = if let Some(rest) = text.strip_prefix("---\n") {
let (header, body) = rest.split_once("\n---")?;
(
header,
body.trim_start_matches(['\n', '\r']).trim().to_string(),
2026-09-06 03:43:38 +00:00
)
} else {
("", text.trim().to_string())
};
2026-09-07 14:23:38 +00:00
let description = frontmatter
.lines()
.find_map(|line| {
let (key, value) = line.split_once(':')?;
(key.trim() == "description").then(|| {
value
.trim()
.trim_matches(|ch| ch == '"' || ch == '\'')
.to_string()
})
})
.unwrap_or_else(|| {
instructions
.lines()
.next()
.unwrap_or("自訂技能")
.chars()
.take(120)
.collect()
});
(!instructions.is_empty()).then_some(FileSkill {
name,
description,
instructions,
})
2026-09-06 03:43:38 +00:00
}
pub fn list(data_dir: &str) -> Vec<FileSkill> {
let root = Path::new(data_dir).join("skills");
2026-09-07 14:23:38 +00:00
let Ok(entries) = std::fs::read_dir(&root) else {
return Vec::new();
};
let mut skills = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let file_type = entry.file_type().ok()?;
if !file_type.is_dir() {
return None;
}
let name = entry.file_name().to_string_lossy().to_string();
valid_name(&name)
.then(|| parse_file(entry.path().join("SKILL.md"), name))
.flatten()
})
.collect::<Vec<_>>();
2026-09-06 03:43:38 +00:00
skills.sort_by(|left, right| left.name.cmp(&right.name));
skills
}
pub fn slash(prompt: &str, data_dir: &str) -> Option<(FileSkill, String)> {
let mut words = prompt.trim().splitn(2, char::is_whitespace);
let command = words.next()?.strip_prefix('/')?;
2026-09-07 14:23:38 +00:00
if !valid_name(command) {
return None;
}
let skill = list(data_dir)
.into_iter()
.find(|skill| skill.name == command)?;
2026-09-06 03:43:38 +00:00
Some((skill, words.next().unwrap_or("").trim().to_string()))
}
pub fn index(data_dir: &str) -> String {
let skills = list(data_dir);
2026-09-07 14:23:38 +00:00
if skills.is_empty() {
return String::new();
}
let lines = skills
.iter()
.map(|skill| format!("/{:<18} {}", skill.name, skill.description))
.collect::<Vec<_>>()
.join("\n");
2026-09-06 03:43:38 +00:00
format!("可用的檔案技能(唯讀):\n{lines}")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn loads_only_safe_skill_names_and_resolves_arguments() {
2026-09-07 14:23:38 +00:00
let root =
std::env::temp_dir().join(format!("lazyboy-file-skills-{}", uuid::Uuid::new_v4()));
2026-09-06 03:43:38 +00:00
fs::create_dir_all(root.join("skills/open-site")).unwrap();
2026-09-07 14:23:38 +00:00
fs::write(
root.join("skills/open-site/SKILL.md"),
"---\ndescription: Open a site\n---\nUse the browser.\n",
)
.unwrap();
2026-09-06 03:43:38 +00:00
fs::create_dir_all(root.join("skills/goal")).unwrap();
let root_text = root.to_str().unwrap();
2026-09-07 14:23:38 +00:00
assert_eq!(
slash("/open-site example.com", root_text).unwrap().1,
"example.com"
);
2026-09-06 03:43:38 +00:00
assert!(slash("/goal do it", root_text).is_none());
assert!(slash("/missing", root_text).is_none());
let _ = fs::remove_dir_all(root);
}
}