feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::types::skill::{LoadedSkill, SkillMetadata, SkillSource};
|
||||
|
||||
/// Parse a SKILL.md file into (metadata, markdown_body).
|
||||
/// Frontmatter is delimited by `---` lines at the start of the file.
|
||||
pub fn parse_skill_file(content: &str) -> Option<(SkillMetadata, String)> {
|
||||
let trimmed = content.trim_start();
|
||||
if !trimmed.starts_with("---") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find the closing `---`
|
||||
let after_first = &trimmed[3..];
|
||||
let rest = after_first.trim_start_matches(['\r', '\n']);
|
||||
let end_idx = rest.find("\n---")?;
|
||||
let yaml_str = &rest[..end_idx];
|
||||
let after_closing = &rest[end_idx + 4..]; // skip "\n---"
|
||||
let body = after_closing.trim_start_matches(['\r', '\n']).to_string();
|
||||
|
||||
let metadata: SkillMetadata = serde_yaml::from_str(yaml_str).ok()?;
|
||||
Some((metadata, body))
|
||||
}
|
||||
|
||||
/// Load a single skill from a directory containing SKILL.md.
|
||||
pub fn load_skill_from_dir(dir: &Path, source: SkillSource) -> Option<LoadedSkill> {
|
||||
let skill_file = dir.join("SKILL.md");
|
||||
let content = std::fs::read_to_string(&skill_file).ok()?;
|
||||
let (metadata, body) = parse_skill_file(&content)?;
|
||||
|
||||
let name = metadata
|
||||
.name
|
||||
.clone();
|
||||
let name = if name.is_empty() {
|
||||
dir.file_name()?.to_string_lossy().to_string()
|
||||
} else {
|
||||
name
|
||||
};
|
||||
|
||||
Some(LoadedSkill {
|
||||
name,
|
||||
path: skill_file,
|
||||
metadata,
|
||||
content: body,
|
||||
source,
|
||||
group: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Scan all subdirectories of `parent_dir` for SKILL.md files.
|
||||
pub fn load_from_dir(parent_dir: &Path, source: SkillSource) -> Vec<LoadedSkill> {
|
||||
let entries = match std::fs::read_dir(parent_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut skills = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(skill) = load_skill_from_dir(&path, source.clone()) {
|
||||
skills.push(skill);
|
||||
} else {
|
||||
// No SKILL.md — treat as a group container and recurse
|
||||
let group_name = match path.file_name() {
|
||||
Some(n) => n.to_string_lossy().to_string(),
|
||||
None => continue,
|
||||
};
|
||||
let mut group_skills = Vec::new();
|
||||
collect_skills_recursive(&path, source.clone(), &mut group_skills, 0, 3);
|
||||
for mut skill in group_skills {
|
||||
skill.group = Some(group_name.clone());
|
||||
skills.push(skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
skills
|
||||
}
|
||||
|
||||
/// Recursively collect skills from nested directories up to `max_depth`.
|
||||
fn collect_skills_recursive(
|
||||
dir: &Path,
|
||||
source: SkillSource,
|
||||
skills: &mut Vec<LoadedSkill>,
|
||||
depth: u32,
|
||||
max_depth: u32,
|
||||
) {
|
||||
if depth >= max_depth {
|
||||
return;
|
||||
}
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(skill) = load_skill_from_dir(&path, source.clone()) {
|
||||
skills.push(skill);
|
||||
} else {
|
||||
collect_skills_recursive(&path, source.clone(), skills, depth + 1, max_depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load skills from all global directories in priority order.
|
||||
/// Deduplicates by name — first found wins.
|
||||
pub fn load_all_global(home_dir: &Path) -> Vec<LoadedSkill> {
|
||||
let dirs: Vec<(PathBuf, SkillSource)> = vec![
|
||||
(home_dir.join(".boosapi").join("skills"), SkillSource::BoosApi),
|
||||
(home_dir.join(".claude").join("skills"), SkillSource::Claude),
|
||||
(home_dir.join(".agents").join("skills"), SkillSource::Agents),
|
||||
];
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
for (dir, source) in dirs {
|
||||
for skill in load_from_dir(&dir, source) {
|
||||
if seen.insert(skill.name.clone()) {
|
||||
result.push(skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Walk up from `cwd` looking for `.git/` or `.boosapi/` to detect the project root.
|
||||
pub fn detect_project_root(cwd: &Path) -> Option<PathBuf> {
|
||||
let mut current = cwd.to_path_buf();
|
||||
loop {
|
||||
if current.join(".git").is_dir() || current.join(".boosapi").is_dir() {
|
||||
return Some(current);
|
||||
}
|
||||
if !current.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load skills from the project's `.boosapi/skills/` directory.
|
||||
pub fn load_project_skills(cwd: &Path) -> Vec<LoadedSkill> {
|
||||
let root = match detect_project_root(cwd) {
|
||||
Some(r) => r,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
load_from_dir(&root.join(".boosapi").join("skills"), SkillSource::Project)
|
||||
}
|
||||
|
||||
/// Substitute template variables in skill content.
|
||||
/// Replaces `$ARGUMENTS`, `${SKILL_DIR}`, and `${CLAUDE_SKILL_DIR}` (compat).
|
||||
pub fn substitute_variables(content: &str, arguments: &str, skill_dir: &str) -> String {
|
||||
content
|
||||
.replace("$ARGUMENTS", arguments)
|
||||
.replace("${SKILL_DIR}", skill_dir)
|
||||
.replace("${CLAUDE_SKILL_DIR}", skill_dir)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod loader;
|
||||
pub mod registry;
|
||||
|
||||
pub use loader::*;
|
||||
pub use registry::*;
|
||||
@@ -0,0 +1,102 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::types::skill::LoadedSkill;
|
||||
|
||||
pub struct SkillRegistry {
|
||||
skills: HashMap<String, LoadedSkill>,
|
||||
disabled: HashSet<String>,
|
||||
}
|
||||
|
||||
impl SkillRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
skills: HashMap::new(),
|
||||
disabled: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a skill. If a skill with the same name exists, ignore (first wins).
|
||||
pub fn register(&mut self, skill: LoadedSkill) {
|
||||
self.skills.entry(skill.name.clone()).or_insert(skill);
|
||||
}
|
||||
|
||||
/// Set the disabled skills set (from database).
|
||||
pub fn set_disabled(&mut self, disabled: HashSet<String>) {
|
||||
self.disabled = disabled;
|
||||
}
|
||||
|
||||
/// Get all registered skills (including disabled).
|
||||
pub fn all(&self) -> Vec<&LoadedSkill> {
|
||||
self.skills.values().collect()
|
||||
}
|
||||
|
||||
/// Get all enabled skills.
|
||||
pub fn all_enabled(&self) -> Vec<&LoadedSkill> {
|
||||
self.skills
|
||||
.values()
|
||||
.filter(|s| !self.disabled.contains(&s.name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get a skill by name (regardless of enabled/disabled).
|
||||
pub fn get(&self, name: &str) -> Option<&LoadedSkill> {
|
||||
self.skills.get(name)
|
||||
}
|
||||
|
||||
/// Get skills that the model can auto-invoke (enabled + not disable_model_invocation).
|
||||
pub fn auto_invocable(&self) -> Vec<&LoadedSkill> {
|
||||
self.skills
|
||||
.values()
|
||||
.filter(|s| !self.disabled.contains(&s.name) && !s.metadata.disable_model_invocation)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get skills that the user can invoke (enabled + user_invocable).
|
||||
pub fn user_invocable(&self) -> Vec<&LoadedSkill> {
|
||||
self.skills
|
||||
.values()
|
||||
.filter(|s| !self.disabled.contains(&s.name) && s.metadata.user_invocable)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate a summary for system prompt injection (only enabled skills).
|
||||
pub fn generate_context_summary(&self) -> String {
|
||||
let skills = self.all_enabled();
|
||||
if skills.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut summary = String::from(
|
||||
"# Available Skills\n\nYou have access to the following skills via the Skill tool:\n\n",
|
||||
);
|
||||
for skill in &skills {
|
||||
summary.push_str(&format!("- **{}**", skill.name));
|
||||
if let Some(desc) = &skill.metadata.description {
|
||||
let desc = if desc.len() > 250 {
|
||||
format!("{}...", &desc[..250])
|
||||
} else {
|
||||
desc.clone()
|
||||
};
|
||||
summary.push_str(&format!(": {}", desc));
|
||||
}
|
||||
if let Some(when) = &skill.metadata.when_to_use {
|
||||
summary.push_str(&format!(" TRIGGER when: {}", when));
|
||||
}
|
||||
if let Some(hint) = &skill.metadata.argument_hint {
|
||||
summary.push_str(&format!(" (usage: /{})", hint));
|
||||
}
|
||||
summary.push('\n');
|
||||
}
|
||||
summary.push_str(
|
||||
"\nWhen a skill matches the user's request, invoke it using the Skill tool. \
|
||||
When the Skill tool returns instructions, treat them as mandatory operational \
|
||||
procedures, not as optional reference material. Follow the instructions step by step.\n",
|
||||
);
|
||||
summary
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SkillRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user