PLATFORM (gyandoot-platform): - Telegram adapter: Long-polling, sendMessage, sendDocument, chat actions - Discord adapter: REST polling, Bearer auth, channel discovery - Webhook adapter: Hyper v1 HTTP server, HMAC-SHA256 verification - CLI UX: Robot head banner, spinner, confirm prompts, colored output SECURITY (gyandoot-security): - CredentialVault: AES-256-GCM encrypted per-key files in ~/.gyandoot/vault/ - Sanitizer: PII redaction (email, phone, API keys, IPs), control char stripping - ThreatDetector: Prompt injection, jailbreak, exfiltration detection with severity SUBAGENTS (gyandoot-subagents): - SubAgentWorker: Isolated agent loop with tool filtering and workdir - SubAgentManager: Bounded concurrency via tokio::Semaphore - Full lifecycle tracking and result collection SKILLS (gyandoot-skills): - SkillsManager: scan, load, inject_skills with YAML frontmatter parsing - Preprocessor: template resolution - SkillBundle: JSON bundle serialization PROFILE (gyandoot-profile): - Profile struct with builder and validation - ConfigResolver: env > symlink > profile > default chain - ProfileManager: create, list, switch, delete, CRUD with real filesystem ops INSTALLER FIX: Fixed camofox-browser directory escaping bug that caused 'could not find Cargo.toml' on macOS. Always CD back to project root.
1309 lines
44 KiB
Rust
1309 lines
44 KiB
Rust
//! # Gyandoot Skills System
|
|
//!
|
|
//! Skills are reusable procedural knowledge — loaded, preprocessed, and invoked
|
|
//! by the agent to perform specific tasks. Supports discovery from filesystem
|
|
//! directories, YAML frontmatter parsing, template variable resolution,
|
|
//! bundle archives, and remote skill hubs.
|
|
//!
|
|
//! ## Architecture
|
|
//!
|
|
//! - [`SkillsManager`]: Central registry for skill discovery, loading, and indexing.
|
|
//! - [`Skill`]: A loaded skill with metadata, content, and template variables.
|
|
//! - [`SkillMeta`]: Lightweight metadata for inventory/index purposes.
|
|
//! - [`Preprocessor`]: Resolves `${VAR}` template placeholders with depth limits.
|
|
//! - [`TrustLevel`]: Classifies skill provenance (Builtin, Trusted, Community, Unverified).
|
|
//! - [`SkillBundle`]: An archive of multiple skills for distribution.
|
|
//! - [`SkillHub`]: Remote fetching of skills from a hub URL.
|
|
//!
|
|
//! ## Example
|
|
//!
|
|
//! ```ignore
|
|
//! use gyandoot_skills::{SkillsManager, Preprocessor};
|
|
//! use std::collections::HashMap;
|
|
//! use std::path::PathBuf;
|
|
//!
|
|
//! let mut manager = SkillsManager::new()
|
|
//! .with_skill_dir(PathBuf::from("/etc/gyandoot/skills"));
|
|
//!
|
|
//! let metas = manager.scan();
|
|
//! if let Some(skill) = manager.load("code-review") {
|
|
//! let pp = Preprocessor::new();
|
|
//! let mut vars = HashMap::new();
|
|
//! vars.insert("FILE".to_string(), "src/main.rs".to_string());
|
|
//! let rendered = pp.process(&skill.content, &vars);
|
|
//! println!("{}", rendered);
|
|
//! }
|
|
//! ```
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use tracing::{debug, info, warn};
|
|
|
|
// ─── Trust Levels ────────────────────────────────────────────────────────
|
|
|
|
/// Provenance classification for a skill.
|
|
///
|
|
/// Determines what level of trust the system places in the skill's content.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum TrustLevel {
|
|
/// Ships with the GyanDoot binary; cannot be modified.
|
|
Builtin,
|
|
/// Signed or verified by a trusted source.
|
|
Trusted,
|
|
/// Contributed by the community, reviewed but not signed.
|
|
Community,
|
|
/// Sourced from unknown or untrusted origins.
|
|
Unverified,
|
|
}
|
|
|
|
impl TrustLevel {
|
|
/// Parse a trust level from a string (case-insensitive).
|
|
pub fn from_str(s: &str) -> Self {
|
|
match s.to_lowercase().as_str() {
|
|
"builtin" => TrustLevel::Builtin,
|
|
"trusted" => TrustLevel::Trusted,
|
|
"community" => TrustLevel::Community,
|
|
_ => TrustLevel::Unverified,
|
|
}
|
|
}
|
|
|
|
/// Return the canonical string representation.
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
TrustLevel::Builtin => "builtin",
|
|
TrustLevel::Trusted => "trusted",
|
|
TrustLevel::Community => "community",
|
|
TrustLevel::Unverified => "unverified",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for TrustLevel {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.as_str())
|
|
}
|
|
}
|
|
|
|
// ─── Metadata ────────────────────────────────────────────────────────────
|
|
|
|
/// Lightweight metadata for a discovered skill, suitable for indexing.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SkillMeta {
|
|
/// Unique skill name (e.g. "code-review", "git-helper").
|
|
pub name: String,
|
|
/// Human-readable one-line description.
|
|
pub description: String,
|
|
/// Optional category (derived from parent directory name).
|
|
pub category: Option<String>,
|
|
/// Searchable tags.
|
|
pub tags: Vec<String>,
|
|
/// Trust level classification.
|
|
pub trust: TrustLevel,
|
|
/// Filesystem path to the skill directory or file.
|
|
pub path: PathBuf,
|
|
/// Optional version string.
|
|
pub version: Option<String>,
|
|
/// Optional author string.
|
|
pub author: Option<String>,
|
|
}
|
|
|
|
impl SkillMeta {
|
|
/// Create a new skill metadata entry.
|
|
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
description: description.into(),
|
|
category: None,
|
|
tags: Vec::new(),
|
|
trust: TrustLevel::Unverified,
|
|
path: PathBuf::new(),
|
|
version: None,
|
|
author: None,
|
|
}
|
|
}
|
|
|
|
/// Builder: set the category.
|
|
pub fn with_category(mut self, category: impl Into<String>) -> Self {
|
|
self.category = Some(category.into());
|
|
self
|
|
}
|
|
|
|
/// Builder: add a tag.
|
|
pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
|
|
self.tags.push(tag.into());
|
|
self
|
|
}
|
|
|
|
/// Builder: set trust level.
|
|
pub fn with_trust(mut self, trust: TrustLevel) -> Self {
|
|
self.trust = trust;
|
|
self
|
|
}
|
|
|
|
/// Builder: set path.
|
|
pub fn with_path(mut self, path: PathBuf) -> Self {
|
|
self.path = path;
|
|
self
|
|
}
|
|
}
|
|
|
|
// ─── Skill ───────────────────────────────────────────────────────────────
|
|
|
|
/// A loaded skill with full content and pre-parsed template variables.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Skill {
|
|
/// Parsed metadata (from YAML frontmatter or registry).
|
|
pub meta: SkillMeta,
|
|
/// Full skill content (SKILL.md body).
|
|
pub content: String,
|
|
/// Template variables extracted from the skill.
|
|
pub templates: HashMap<String, String>,
|
|
}
|
|
|
|
impl Skill {
|
|
/// Create a new skill from its parts.
|
|
pub fn new(meta: SkillMeta, content: impl Into<String>) -> Self {
|
|
Self {
|
|
meta,
|
|
content: content.into(),
|
|
templates: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Build a compact summary for system prompt injection.
|
|
pub fn summary(&self) -> String {
|
|
format!(
|
|
"- {} ({}, {}): {}",
|
|
self.meta.name,
|
|
self.meta.category.as_deref().unwrap_or("general"),
|
|
self.meta.trust,
|
|
self.meta.description
|
|
)
|
|
}
|
|
}
|
|
|
|
// ─── SkillsManager ───────────────────────────────────────────────────────
|
|
|
|
/// Central registry for skills discovery, loading, and indexing.
|
|
///
|
|
/// Scans one or more filesystem directories for skill subdirectories
|
|
/// (containing a `SKILL.md` file), parses YAML frontmatter, and maintains
|
|
/// a cache of loaded skills.
|
|
pub struct SkillsManager {
|
|
/// Directories to scan for skills.
|
|
skill_dirs: Vec<PathBuf>,
|
|
/// Loaded skills keyed by name.
|
|
skills: HashMap<String, Skill>,
|
|
/// Whether to automatically load skills on scan.
|
|
auto_load: bool,
|
|
}
|
|
|
|
impl SkillsManager {
|
|
/// Create a new empty skills manager.
|
|
pub fn new() -> Self {
|
|
Self {
|
|
skill_dirs: Vec::new(),
|
|
skills: HashMap::new(),
|
|
auto_load: true,
|
|
}
|
|
}
|
|
|
|
/// Register a directory to scan for skills.
|
|
pub fn with_skill_dir(mut self, dir: PathBuf) -> Self {
|
|
self.skill_dirs.push(dir);
|
|
self
|
|
}
|
|
|
|
/// Set whether skills are auto-loaded during scan.
|
|
pub fn with_auto_load(mut self, auto: bool) -> Self {
|
|
self.auto_load = auto;
|
|
self
|
|
}
|
|
|
|
/// Add multiple skill directories at once.
|
|
pub fn add_dirs(&mut self, dirs: &[PathBuf]) -> &mut Self {
|
|
self.skill_dirs.extend_from_slice(dirs);
|
|
self
|
|
}
|
|
|
|
/// Get the list of registered skill directories.
|
|
pub fn skill_dirs(&self) -> &[PathBuf] {
|
|
&self.skill_dirs
|
|
}
|
|
|
|
/// Scan all registered directories for skills.
|
|
///
|
|
/// Returns a list of [`SkillMeta`] for discovered skills. If `auto_load`
|
|
/// is true, each discovered skill is also loaded into the cache.
|
|
pub fn scan(&mut self) -> Vec<SkillMeta> {
|
|
let mut metas = Vec::new();
|
|
|
|
for dir in &self.skill_dirs {
|
|
if !dir.exists() {
|
|
debug!(path = %dir.display(), "Skill directory does not exist, skipping");
|
|
continue;
|
|
}
|
|
if !dir.is_dir() {
|
|
debug!(path = %dir.display(), "Skill path is not a directory, skipping");
|
|
continue;
|
|
}
|
|
|
|
info!(path = %dir.display(), "Scanning for skills");
|
|
|
|
let entries = match std::fs::read_dir(dir) {
|
|
Ok(e) => e,
|
|
Err(e) => {
|
|
warn!(path = %dir.display(), error = %e, "Failed to read skill directory");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let category = dir
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.map(String::from);
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if !path.is_dir() {
|
|
continue;
|
|
}
|
|
|
|
let skill_md = path.join("SKILL.md");
|
|
if !skill_md.exists() {
|
|
continue;
|
|
}
|
|
|
|
let name = path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("unknown")
|
|
.to_string();
|
|
|
|
// Parse the SKILL.md to extract metadata
|
|
let content = match std::fs::read_to_string(&skill_md) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
warn!(skill = %name, error = %e, "Failed to read SKILL.md");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let (meta, body) = Self::parse_skill_file(&name, &content, &category, &path);
|
|
|
|
if self.auto_load {
|
|
let skill = Skill {
|
|
meta: meta.clone(),
|
|
content: body,
|
|
templates: HashMap::new(),
|
|
};
|
|
self.skills.insert(name.clone(), skill);
|
|
}
|
|
|
|
metas.push(meta);
|
|
}
|
|
}
|
|
|
|
if metas.is_empty() {
|
|
debug!("No skills discovered in any registered directory");
|
|
} else {
|
|
info!("Discovered {} skill(s)", metas.len());
|
|
for m in &metas {
|
|
debug!(skill = %m.name, category = ?m.category, trust = %m.trust, "Skill discovered");
|
|
}
|
|
}
|
|
|
|
metas
|
|
}
|
|
|
|
/// Load a specific skill by name, searching registered directories.
|
|
///
|
|
/// Returns `None` if the skill is not found in any directory.
|
|
/// If already loaded, returns the cached version.
|
|
pub fn load(&mut self, name: &str) -> Option<&Skill> {
|
|
// Return from cache if already loaded
|
|
if self.skills.contains_key(name) {
|
|
return self.skills.get(name);
|
|
}
|
|
|
|
// Search directories for matching skill
|
|
for dir in &self.skill_dirs {
|
|
let candidate_dir = dir.join(name);
|
|
let candidate_path = candidate_dir.join("SKILL.md");
|
|
|
|
if candidate_path.exists() {
|
|
let content = match std::fs::read_to_string(&candidate_path) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
warn!(skill = %name, path = %candidate_path.display(), error = %e, "Failed to read SKILL.md");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let category = dir
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.map(String::from);
|
|
|
|
let (meta, body) =
|
|
Self::parse_skill_file(name, &content, &category, &candidate_path);
|
|
|
|
let skill = Skill {
|
|
meta,
|
|
content: body,
|
|
templates: HashMap::new(),
|
|
};
|
|
|
|
self.skills.insert(name.to_string(), skill);
|
|
info!(skill = %name, "Loaded skill");
|
|
return self.skills.get(name);
|
|
}
|
|
}
|
|
|
|
warn!(skill = %name, "Skill not found in any registered directory");
|
|
None
|
|
}
|
|
|
|
/// Load a skill directly from a specific path (not requiring pre-registered directories).
|
|
pub fn load_from(&mut self, name: &str, path: &std::path::Path) -> Option<&Skill> {
|
|
if self.skills.contains_key(name) {
|
|
return self.skills.get(name);
|
|
}
|
|
|
|
let skill_md = if path.is_dir() {
|
|
path.join("SKILL.md")
|
|
} else {
|
|
path.to_path_buf()
|
|
};
|
|
|
|
if !skill_md.exists() {
|
|
warn!(skill = %name, path = %skill_md.display(), "SKILL.md not found");
|
|
return None;
|
|
}
|
|
|
|
let content = match std::fs::read_to_string(&skill_md) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
warn!(skill = %name, error = %e, "Failed to read SKILL.md");
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let (meta, body) = Self::parse_skill_file(name, &content, &None, &skill_md);
|
|
|
|
let skill = Skill {
|
|
meta,
|
|
content: body,
|
|
templates: HashMap::new(),
|
|
};
|
|
|
|
self.skills.insert(name.to_string(), skill);
|
|
info!(skill = %name, path = %skill_md.display(), "Loaded skill from path");
|
|
self.skills.get(name)
|
|
}
|
|
|
|
/// Get a reference to a loaded skill by name (without loading if not cached).
|
|
pub fn get(&self, name: &str) -> Option<&Skill> {
|
|
self.skills.get(name)
|
|
}
|
|
|
|
/// Check if a skill is already loaded.
|
|
pub fn is_loaded(&self, name: &str) -> bool {
|
|
self.skills.contains_key(name)
|
|
}
|
|
|
|
/// Return a list of all loaded skill metadata.
|
|
pub fn list_skills(&self) -> Vec<SkillMeta> {
|
|
self.skills.values().map(|s| s.meta.clone()).collect()
|
|
}
|
|
|
|
/// Return the number of loaded skills.
|
|
pub fn len(&self) -> usize {
|
|
self.skills.len()
|
|
}
|
|
|
|
/// True if no skills are loaded.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.skills.is_empty()
|
|
}
|
|
|
|
/// Remove a loaded skill from cache.
|
|
pub fn unload(&mut self, name: &str) -> Option<Skill> {
|
|
self.skills.remove(name)
|
|
}
|
|
|
|
/// Clear all loaded skills (cache only, not filesystem).
|
|
pub fn clear(&mut self) {
|
|
self.skills.clear();
|
|
}
|
|
|
|
/// Build a compact skill index for system prompt injection.
|
|
///
|
|
/// Produces a human-readable listing of available skills with name,
|
|
/// category, and description — suitable for embedding in the LLM's
|
|
/// system prompt so it can invoke `skill_view(name)`.
|
|
pub fn inject_skills(&self) -> String {
|
|
let mut lines = Vec::new();
|
|
|
|
if self.skills.is_empty() {
|
|
lines.push("(no skills loaded)".to_string());
|
|
return lines.join("\n");
|
|
}
|
|
|
|
lines.push("Available skills (load with skill_view for full content):".to_string());
|
|
|
|
// Group by category for readability
|
|
let mut by_category: HashMap<&str, Vec<&Skill>> = HashMap::new();
|
|
for skill in self.skills.values() {
|
|
let cat = skill
|
|
.meta
|
|
.category
|
|
.as_deref()
|
|
.unwrap_or("uncategorized");
|
|
by_category.entry(cat).or_default().push(skill);
|
|
}
|
|
|
|
let mut categories: Vec<&&str> = by_category.keys().collect();
|
|
categories.sort();
|
|
|
|
for cat in categories {
|
|
let skills = &by_category[*cat];
|
|
lines.push(format!(" [{}]", cat));
|
|
for skill in skills {
|
|
lines.push(format!(
|
|
" - {} (trust: {}): {}",
|
|
skill.meta.name, skill.meta.trust, skill.meta.description
|
|
));
|
|
}
|
|
}
|
|
|
|
lines.join("\n")
|
|
}
|
|
|
|
// ── Internal helpers ────────────────────────────────────────────────
|
|
|
|
/// Parse YAML frontmatter from a SKILL.md file.
|
|
///
|
|
/// Frontmatter format:
|
|
/// ```markdown
|
|
/// ---
|
|
/// name: my-skill
|
|
/// description: Does something useful
|
|
/// tags: [tag1, tag2]
|
|
/// trust: trusted
|
|
/// ---
|
|
/// Skill content starts here...
|
|
/// ```
|
|
fn parse_skill_file(
|
|
name: &str,
|
|
content: &str,
|
|
category: &Option<String>,
|
|
path: &std::path::Path,
|
|
) -> (SkillMeta, String) {
|
|
let mut meta = SkillMeta::new(name, "");
|
|
meta.category = category.clone();
|
|
meta.path = path.to_path_buf();
|
|
|
|
// Try to extract YAML frontmatter between --- markers
|
|
let body = if let Some(end_of_fm) = Self::extract_frontmatter(content, &mut meta) {
|
|
content[end_of_fm..].trim().to_string()
|
|
} else {
|
|
// No frontmatter: use first line as description
|
|
let first_line = content.lines().next().unwrap_or("").to_string();
|
|
meta.description = first_line;
|
|
content.to_string()
|
|
};
|
|
|
|
(meta, body)
|
|
}
|
|
|
|
/// Extract YAML-like frontmatter from markdown content.
|
|
///
|
|
/// Returns the byte offset where the body begins (after the closing `---`).
|
|
/// Modifies `meta` in place with parsed fields.
|
|
fn extract_frontmatter(content: &str, meta: &mut SkillMeta) -> Option<usize> {
|
|
let content_bytes = content.as_bytes();
|
|
|
|
// Must start with "---\n" or "---\r\n"
|
|
if content_bytes.len() < 5 {
|
|
return None;
|
|
}
|
|
|
|
if !content.starts_with("---") {
|
|
return None;
|
|
}
|
|
|
|
// Find the closing "---" marker
|
|
let search_start = 3; // skip opening ---
|
|
let remainder = &content[search_start..];
|
|
|
|
// The opening --- might be "---\n" or "---\r\n"
|
|
let opening_newline = if content_bytes.get(3) == Some(&b'\r') && content_bytes.get(4) == Some(&b'\n') {
|
|
search_start + 2
|
|
} else if content_bytes.get(3) == Some(&b'\n') {
|
|
search_start + 1
|
|
} else {
|
|
return None; // malformed opening
|
|
};
|
|
|
|
// Search for closing "---" after the opening line
|
|
if let Some(closing_pos) = remainder[1..].find("\n---") {
|
|
let closing_start = search_start + 1 + closing_pos;
|
|
// Could be "\n---\n" or "\n---\r\n"
|
|
let closing_end = if content_bytes.get(closing_start + 4) == Some(&b'\n') {
|
|
closing_start + 5
|
|
} else if content_bytes.get(closing_start + 4) == Some(&b'\r')
|
|
&& content_bytes.get(closing_start + 5) == Some(&b'\n')
|
|
{
|
|
closing_start + 6
|
|
} else {
|
|
// Just "---" at end of content
|
|
closing_start + 4
|
|
};
|
|
|
|
let fm_text = &content[opening_newline..closing_start];
|
|
|
|
// Parse simple key: value lines from frontmatter
|
|
for line in fm_text.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
|
|
if let Some((key, value)) = line.split_once(':') {
|
|
let key = key.trim().to_lowercase();
|
|
let value = value.trim().trim_matches('"').trim_matches('\'');
|
|
|
|
match key.as_str() {
|
|
"name" => {
|
|
if !value.is_empty() {
|
|
meta.name = value.to_string();
|
|
}
|
|
}
|
|
"description" => {
|
|
meta.description = value.to_string();
|
|
}
|
|
"category" => {
|
|
if meta.category.is_none() && !value.is_empty() {
|
|
meta.category = Some(value.to_string());
|
|
}
|
|
}
|
|
"tags" => {
|
|
// Parse [tag1, tag2, ...] or comma-separated
|
|
let tags_str = value
|
|
.trim_start_matches('[')
|
|
.trim_end_matches(']');
|
|
for tag in tags_str.split(',') {
|
|
let t = tag.trim().trim_matches('"').trim_matches('\'');
|
|
if !t.is_empty() {
|
|
meta.tags.push(t.to_string());
|
|
}
|
|
}
|
|
}
|
|
"trust" => {
|
|
meta.trust = TrustLevel::from_str(value);
|
|
}
|
|
"version" => {
|
|
meta.version = Some(value.to_string());
|
|
}
|
|
"author" => {
|
|
meta.author = Some(value.to_string());
|
|
}
|
|
_ => {
|
|
debug!(key = %key, value = %value, "Unknown frontmatter field");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Some(closing_end)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for SkillsManager {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
// ─── Preprocessor ────────────────────────────────────────────────────────
|
|
|
|
/// Processes skill content by resolving template placeholders.
|
|
///
|
|
/// Supports `${VAR}` syntax with recursive resolution up to a configurable
|
|
/// depth limit to prevent infinite loops.
|
|
pub struct Preprocessor {
|
|
/// Maximum recursion depth for nested variable resolution.
|
|
max_depth: usize,
|
|
}
|
|
|
|
impl Preprocessor {
|
|
/// Create a new preprocessor with default max depth (5).
|
|
pub fn new() -> Self {
|
|
Self { max_depth: 5 }
|
|
}
|
|
|
|
/// Create a preprocessor with a custom max recursion depth.
|
|
pub fn with_max_depth(max_depth: usize) -> Self {
|
|
Self { max_depth }
|
|
}
|
|
|
|
/// Resolve `${VAR}` placeholders in skill content.
|
|
///
|
|
/// Variables are replaced with values from the provided map.
|
|
/// If a variable references another `${VAR}` inside its value, it
|
|
/// is resolved recursively up to `max_depth` levels.
|
|
///
|
|
/// Returns the resolved string. Unresolved placeholders are left as-is.
|
|
pub fn process(&self, content: &str, vars: &HashMap<String, String>) -> String {
|
|
let mut result = content.to_string();
|
|
let mut depth = 0;
|
|
|
|
loop {
|
|
if depth >= self.max_depth {
|
|
warn!("Preprocessor reached max recursion depth ({})", self.max_depth);
|
|
break;
|
|
}
|
|
|
|
let before = result.len();
|
|
for (key, value) in vars {
|
|
// Resolve values recursively
|
|
let resolved_value = self.resolve_value(value, vars, depth);
|
|
result = result.replace(&format!("${{{}}}", key), &resolved_value);
|
|
}
|
|
|
|
// Check if any replacements were made
|
|
if result.len() == before {
|
|
break;
|
|
}
|
|
|
|
// Check if there are still unresolved ${...} patterns
|
|
if !result.contains("${") {
|
|
break;
|
|
}
|
|
|
|
depth += 1;
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
/// Recursively resolve a single variable value.
|
|
fn resolve_value(&self, value: &str, vars: &HashMap<String, String>, depth: usize) -> String {
|
|
if depth >= self.max_depth || !value.contains("${") {
|
|
return value.to_string();
|
|
}
|
|
|
|
let mut result = value.to_string();
|
|
for (key, val) in vars {
|
|
result = result.replace(&format!("${{{}}}", key), val);
|
|
}
|
|
|
|
if result.contains("${") {
|
|
self.resolve_value(&result, vars, depth + 1)
|
|
} else {
|
|
result
|
|
}
|
|
}
|
|
|
|
/// Extract all `${VAR}` placeholder names from content (without resolving).
|
|
pub fn extract_variables(&self, content: &str) -> Vec<String> {
|
|
let mut vars = Vec::new();
|
|
let mut remaining = content;
|
|
|
|
while let Some(start) = remaining.find("${") {
|
|
let after_start = &remaining[start + 2..];
|
|
if let Some(end) = after_start.find('}') {
|
|
let var_name = after_start[..end].to_string();
|
|
if !vars.contains(&var_name) {
|
|
vars.push(var_name);
|
|
}
|
|
remaining = &after_start[end + 1..];
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
vars
|
|
}
|
|
}
|
|
|
|
impl Default for Preprocessor {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
// ─── SkillBundle ─────────────────────────────────────────────────────────
|
|
|
|
/// An archive of multiple skills, typically distributed as a single file.
|
|
///
|
|
/// Bundles allow skills to be packaged together and loaded in one operation.
|
|
/// The format is a JSON or YAML array of skill records.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SkillBundle {
|
|
/// Manifest version for forward compatibility.
|
|
pub version: String,
|
|
/// Bundle name.
|
|
pub name: String,
|
|
/// Human-readable description of the bundle.
|
|
pub description: String,
|
|
/// Skills contained in the bundle.
|
|
pub skills: Vec<BundleSkill>,
|
|
}
|
|
|
|
/// A single skill record within a bundle.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct BundleSkill {
|
|
/// Skill name.
|
|
pub name: String,
|
|
/// One-line description.
|
|
pub description: String,
|
|
/// Optional category.
|
|
pub category: Option<String>,
|
|
/// Tags for discovery.
|
|
pub tags: Vec<String>,
|
|
/// Trust level.
|
|
pub trust: Option<TrustLevel>,
|
|
/// Markdown content (may include frontmatter).
|
|
pub content: String,
|
|
}
|
|
|
|
impl SkillBundle {
|
|
/// Load a bundle from a JSON string.
|
|
pub fn from_json(json: &str) -> Result<Self, anyhow::Error> {
|
|
serde_json::from_str(json).map_err(|e| anyhow::anyhow!("Failed to parse skill bundle JSON: {e}"))
|
|
}
|
|
|
|
/// Load a bundle from a JSON file.
|
|
pub fn from_file(path: &std::path::Path) -> Result<Self, anyhow::Error> {
|
|
let content = std::fs::read_to_string(path)
|
|
.map_err(|e| anyhow::anyhow!("Failed to read bundle file '{}': {e}", path.display()))?;
|
|
Self::from_json(&content)
|
|
}
|
|
|
|
/// Install all skills from this bundle into a [`SkillsManager`].
|
|
///
|
|
/// Returns the number of skills installed.
|
|
pub fn install_into(&self, manager: &mut SkillsManager) -> usize {
|
|
let mut count = 0;
|
|
for bundle_skill in &self.skills {
|
|
let meta = SkillMeta {
|
|
name: bundle_skill.name.clone(),
|
|
description: bundle_skill.description.clone(),
|
|
category: bundle_skill.category.clone(),
|
|
tags: bundle_skill.tags.clone(),
|
|
trust: bundle_skill.trust.clone().unwrap_or(TrustLevel::Community),
|
|
path: PathBuf::new(),
|
|
version: None,
|
|
author: None,
|
|
};
|
|
|
|
let skill = Skill {
|
|
meta,
|
|
content: bundle_skill.content.clone(),
|
|
templates: HashMap::new(),
|
|
};
|
|
|
|
manager.skills.insert(bundle_skill.name.clone(), skill);
|
|
count += 1;
|
|
}
|
|
info!("Installed {} skills from bundle '{}'", count, self.name);
|
|
count
|
|
}
|
|
|
|
/// Serialize this bundle to pretty-printed JSON.
|
|
pub fn to_json_pretty(&self) -> Result<String, anyhow::Error> {
|
|
serde_json::to_string_pretty(self)
|
|
.map_err(|e| anyhow::anyhow!("Failed to serialize bundle: {e}"))
|
|
}
|
|
|
|
/// Write this bundle to a JSON file.
|
|
pub fn write_to(&self, path: &std::path::Path) -> Result<(), anyhow::Error> {
|
|
let json = self.to_json_pretty()?;
|
|
std::fs::write(path, &json)
|
|
.map_err(|e| anyhow::anyhow!("Failed to write bundle to '{}': {e}", path.display()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Create a bundle from a list of loaded skills in a manager.
|
|
pub fn from_manager(
|
|
name: &str,
|
|
description: &str,
|
|
skill_names: &[&str],
|
|
manager: &SkillsManager,
|
|
) -> Result<Self, anyhow::Error> {
|
|
let mut skills = Vec::new();
|
|
for sname in skill_names {
|
|
let skill = manager
|
|
.get(sname)
|
|
.ok_or_else(|| anyhow::anyhow!("Skill '{sname}' not found in manager"))?;
|
|
|
|
skills.push(BundleSkill {
|
|
name: skill.meta.name.clone(),
|
|
description: skill.meta.description.clone(),
|
|
category: skill.meta.category.clone(),
|
|
tags: skill.meta.tags.clone(),
|
|
trust: Some(skill.meta.trust.clone()),
|
|
content: skill.content.clone(),
|
|
});
|
|
}
|
|
|
|
Ok(Self {
|
|
version: "1.0".to_string(),
|
|
name: name.to_string(),
|
|
description: description.to_string(),
|
|
skills,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ─── SkillHub ────────────────────────────────────────────────────────────
|
|
|
|
/// Configuration for connecting to a remote skill hub.
|
|
///
|
|
/// A skill hub is an HTTP server that serves skill metadata and content.
|
|
/// This is an optional feature enabled by the `hub` feature flag.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SkillHubConfig {
|
|
/// Base URL of the hub (e.g. "https://hub.gyandoot.dev").
|
|
pub base_url: String,
|
|
/// Optional API token for private hubs.
|
|
pub api_token: Option<String>,
|
|
/// Timeout in seconds for hub requests.
|
|
pub timeout_secs: u64,
|
|
}
|
|
|
|
impl Default for SkillHubConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
base_url: "https://hub.gyandoot.dev".to_string(),
|
|
api_token: None,
|
|
timeout_secs: 10,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Tests ───────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
|
|
fn create_test_skill_dir() -> tempfile::TempDir {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let skill_dir = dir.path().join("test-skill");
|
|
fs::create_dir_all(&skill_dir).unwrap();
|
|
let content = r#"---
|
|
name: test-skill
|
|
description: A test skill for unit testing
|
|
tags: [test, example]
|
|
trust: trusted
|
|
author: GyanDoot Team
|
|
---
|
|
|
|
# Test Skill
|
|
|
|
This is a test skill used for unit testing.
|
|
"#;
|
|
fs::write(skill_dir.join("SKILL.md"), content).unwrap();
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn test_preprocessor_variable_resolution() {
|
|
let pp = Preprocessor::new();
|
|
let mut vars = HashMap::new();
|
|
vars.insert("NAME".to_string(), "Maya".to_string());
|
|
let result = pp.process("Hello ${NAME}!", &vars);
|
|
assert_eq!(result, "Hello Maya!");
|
|
}
|
|
|
|
#[test]
|
|
fn test_preprocessor_multiple_variables() {
|
|
let pp = Preprocessor::new();
|
|
let mut vars = HashMap::new();
|
|
vars.insert("NAME".to_string(), "Alice".to_string());
|
|
vars.insert("AGE".to_string(), "30".to_string());
|
|
let result = pp.process("${NAME} is ${AGE} years old", &vars);
|
|
assert_eq!(result, "Alice is 30 years old");
|
|
}
|
|
|
|
#[test]
|
|
fn test_preprocessor_unknown_variable() {
|
|
let pp = Preprocessor::new();
|
|
let vars = HashMap::new();
|
|
let result = pp.process("Hello ${UNKNOWN}!", &vars);
|
|
// Unknown variables should remain as-is
|
|
assert_eq!(result, "Hello ${UNKNOWN}!");
|
|
}
|
|
|
|
#[test]
|
|
fn test_preprocessor_recursive_resolution() {
|
|
let pp = Preprocessor::new();
|
|
let mut vars = HashMap::new();
|
|
vars.insert("NAME".to_string(), "Bob".to_string());
|
|
vars.insert("GREETING".to_string(), "Hello ${NAME}".to_string());
|
|
let result = pp.process("${GREETING}!", &vars);
|
|
assert_eq!(result, "Hello Bob!");
|
|
}
|
|
|
|
#[test]
|
|
fn test_preprocessor_no_substitution() {
|
|
let pp = Preprocessor::new();
|
|
let vars = HashMap::new();
|
|
let result = pp.process("No variables here", &vars);
|
|
assert_eq!(result, "No variables here");
|
|
}
|
|
|
|
#[test]
|
|
fn test_preprocessor_extract_variables() {
|
|
let pp = Preprocessor::new();
|
|
let vars = pp.extract_variables("Hello ${NAME}, you are ${AGE} years old");
|
|
assert_eq!(vars.len(), 2);
|
|
assert!(vars.contains(&"NAME".to_string()));
|
|
assert!(vars.contains(&"AGE".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_skills_manager_initialization() {
|
|
let sm = SkillsManager::new();
|
|
assert!(sm.is_empty());
|
|
assert_eq!(sm.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_skills_manager_scan_discovery() {
|
|
let tmp_dir = create_test_skill_dir();
|
|
let mut sm = SkillsManager::new()
|
|
.with_skill_dir(tmp_dir.path().to_path_buf());
|
|
let metas = sm.scan();
|
|
assert_eq!(metas.len(), 1);
|
|
assert_eq!(metas[0].name, "test-skill");
|
|
assert_eq!(metas[0].description, "A test skill for unit testing");
|
|
assert_eq!(metas[0].trust, TrustLevel::Trusted);
|
|
assert_eq!(metas[0].tags, vec!["test", "example"]);
|
|
assert_eq!(metas[0].author.as_deref(), Some("GyanDoot Team"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_skills_manager_load() {
|
|
let tmp_dir = create_test_skill_dir();
|
|
let mut sm = SkillsManager::new()
|
|
.with_skill_dir(tmp_dir.path().to_path_buf());
|
|
sm.scan();
|
|
|
|
let skill = sm.load("test-skill");
|
|
assert!(skill.is_some());
|
|
assert_eq!(skill.unwrap().meta.name, "test-skill");
|
|
}
|
|
|
|
#[test]
|
|
fn test_skills_manager_load_nonexistent() {
|
|
let mut sm = SkillsManager::new();
|
|
let skill = sm.load("nonexistent-skill");
|
|
assert!(skill.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_skills_manager_load_from_path() {
|
|
let tmp_dir = create_test_skill_dir();
|
|
let mut sm = SkillsManager::new();
|
|
let skill = sm.load_from("test-skill", &tmp_dir.path().join("test-skill"));
|
|
assert!(skill.is_some());
|
|
assert_eq!(skill.unwrap().meta.name, "test-skill");
|
|
}
|
|
|
|
#[test]
|
|
fn test_skills_manager_inject_empty() {
|
|
let sm = SkillsManager::new();
|
|
let index = sm.inject_skills();
|
|
assert!(index.contains("no skills loaded"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_skills_manager_inject_with_skills() {
|
|
let tmp_dir = create_test_skill_dir();
|
|
let mut sm = SkillsManager::new()
|
|
.with_skill_dir(tmp_dir.path().to_path_buf());
|
|
sm.scan();
|
|
|
|
let index = sm.inject_skills();
|
|
assert!(index.contains("test-skill"));
|
|
assert!(index.contains("A test skill for unit testing"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_skills_manager_unload() {
|
|
let tmp_dir = create_test_skill_dir();
|
|
let mut sm = SkillsManager::new()
|
|
.with_skill_dir(tmp_dir.path().to_path_buf());
|
|
sm.scan();
|
|
assert_eq!(sm.len(), 1);
|
|
|
|
let unloaded = sm.unload("test-skill");
|
|
assert!(unloaded.is_some());
|
|
assert_eq!(sm.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_skills_manager_clear() {
|
|
let tmp_dir = create_test_skill_dir();
|
|
let mut sm = SkillsManager::new()
|
|
.with_skill_dir(tmp_dir.path().to_path_buf());
|
|
sm.scan();
|
|
assert_eq!(sm.len(), 1);
|
|
sm.clear();
|
|
assert_eq!(sm.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_skill_meta_defaults() {
|
|
let meta = SkillMeta::new("test", "A test");
|
|
assert_eq!(meta.name, "test");
|
|
assert_eq!(meta.description, "A test");
|
|
assert!(meta.category.is_none());
|
|
assert!(meta.tags.is_empty());
|
|
assert_eq!(meta.trust, TrustLevel::Unverified);
|
|
}
|
|
|
|
#[test]
|
|
fn test_skill_meta_builder() {
|
|
let meta = SkillMeta::new("code-review", "Reviews code")
|
|
.with_category("dev")
|
|
.with_tag("code")
|
|
.with_tag("review")
|
|
.with_trust(TrustLevel::Trusted)
|
|
.with_path(PathBuf::from("/skills/code-review"));
|
|
assert_eq!(meta.category.as_deref(), Some("dev"));
|
|
assert_eq!(meta.tags.len(), 2);
|
|
assert_eq!(meta.trust, TrustLevel::Trusted);
|
|
}
|
|
|
|
#[test]
|
|
fn test_trust_level_roundtrip() {
|
|
for level in &[
|
|
TrustLevel::Builtin,
|
|
TrustLevel::Trusted,
|
|
TrustLevel::Community,
|
|
TrustLevel::Unverified,
|
|
] {
|
|
let s = level.to_string();
|
|
let parsed = TrustLevel::from_str(&s);
|
|
assert_eq!(*level, parsed);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_trust_level_unknown_defaults_to_unverified() {
|
|
assert_eq!(TrustLevel::from_str("unknown"), TrustLevel::Unverified);
|
|
}
|
|
|
|
#[test]
|
|
fn test_skill_summary() {
|
|
let meta = SkillMeta::new("git-helper", "Git operations helper")
|
|
.with_category("dev")
|
|
.with_trust(TrustLevel::Builtin);
|
|
let skill = Skill::new(meta, "content");
|
|
let summary = skill.summary();
|
|
assert!(summary.contains("git-helper"));
|
|
assert!(summary.contains("dev"));
|
|
assert!(summary.contains("builtin"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_frontmatter_parsing_basic() {
|
|
let content = r#"---
|
|
name: my-skill
|
|
description: My skill description
|
|
tags: [tag1, tag2]
|
|
trust: trusted
|
|
---
|
|
|
|
# Skill Body"#;
|
|
|
|
let mut meta = SkillMeta::new("my-skill", "");
|
|
let body = SkillsManager::extract_frontmatter(content, &mut meta);
|
|
|
|
assert!(body.is_some());
|
|
assert_eq!(meta.description, "My skill description");
|
|
assert_eq!(meta.tags, vec!["tag1", "tag2"]);
|
|
assert_eq!(meta.trust, TrustLevel::Trusted);
|
|
}
|
|
|
|
#[test]
|
|
fn test_frontmatter_parsing_no_frontmatter() {
|
|
let mut meta = SkillMeta::new("test", "");
|
|
let body = SkillsManager::extract_frontmatter("Just content, no frontmatter", &mut meta);
|
|
assert!(body.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_frontmatter_parsing_empty() {
|
|
let mut meta = SkillMeta::new("test", "");
|
|
let body = SkillsManager::extract_frontmatter("", &mut meta);
|
|
assert!(body.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_skill_bundle_roundtrip() {
|
|
let bundle = SkillBundle {
|
|
version: "1.0".to_string(),
|
|
name: "test-bundle".to_string(),
|
|
description: "A test bundle".to_string(),
|
|
skills: vec![
|
|
BundleSkill {
|
|
name: "skill-one".to_string(),
|
|
description: "First skill".to_string(),
|
|
category: Some("dev".to_string()),
|
|
tags: vec!["rust".to_string()],
|
|
trust: Some(TrustLevel::Trusted),
|
|
content: "# Skill One\nContent".to_string(),
|
|
},
|
|
BundleSkill {
|
|
name: "skill-two".to_string(),
|
|
description: "Second skill".to_string(),
|
|
category: None,
|
|
tags: Vec::new(),
|
|
trust: None,
|
|
content: "# Skill Two\nContent".to_string(),
|
|
},
|
|
],
|
|
};
|
|
|
|
let json = bundle.to_json_pretty().unwrap();
|
|
let parsed = SkillBundle::from_json(&json).unwrap();
|
|
assert_eq!(parsed.name, "test-bundle");
|
|
assert_eq!(parsed.skills.len(), 2);
|
|
assert_eq!(parsed.skills[0].name, "skill-one");
|
|
assert_eq!(parsed.skills[1].name, "skill-two");
|
|
}
|
|
|
|
#[test]
|
|
fn test_skill_bundle_install_into_manager() {
|
|
let bundle = SkillBundle {
|
|
version: "1.0".to_string(),
|
|
name: "test-bundle".to_string(),
|
|
description: "Test".to_string(),
|
|
skills: vec![BundleSkill {
|
|
name: "bundled-skill".to_string(),
|
|
description: "From bundle".to_string(),
|
|
category: Some("bundles".to_string()),
|
|
tags: vec!["bundle".to_string()],
|
|
trust: Some(TrustLevel::Community),
|
|
content: "# Bundled Skill\nContent".to_string(),
|
|
}],
|
|
};
|
|
|
|
let mut manager = SkillsManager::new();
|
|
let count = bundle.install_into(&mut manager);
|
|
assert_eq!(count, 1);
|
|
assert!(manager.is_loaded("bundled-skill"));
|
|
assert_eq!(manager.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_skill_bundle_from_manager() {
|
|
// Setup: create a skill in the manager
|
|
let mut manager = SkillsManager::new();
|
|
let meta = SkillMeta::new("my-skill", "A skill")
|
|
.with_category("dev")
|
|
.with_trust(TrustLevel::Builtin)
|
|
.with_tag("important");
|
|
let skill = Skill::new(meta, "# Content");
|
|
manager.unload("my-skill"); // no-op if not loaded
|
|
// Direct insert
|
|
manager.load_from("my-skill", &PathBuf::from("/tmp/nonexistent")).ok_or(()).unwrap_err();
|
|
|
|
// Use bundle from manager
|
|
// First directly insert into the skills map via a workaround
|
|
let meta2 = SkillMeta::new("direct-skill", "Direct")
|
|
.with_category("test")
|
|
.with_trust(TrustLevel::Trusted);
|
|
let skill2 = Skill::new(meta2, "# Direct content");
|
|
manager.skills.insert("direct-skill".to_string(), skill2);
|
|
|
|
let bundle = SkillBundle::from_manager(
|
|
"export-bundle",
|
|
"Exported skills",
|
|
&["direct-skill"],
|
|
&manager,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(bundle.skills.len(), 1);
|
|
assert_eq!(bundle.skills[0].name, "direct-skill");
|
|
|
|
// Re-import into a fresh manager
|
|
let mut fresh_manager = SkillsManager::new();
|
|
bundle.install_into(&mut fresh_manager);
|
|
assert!(fresh_manager.is_loaded("direct-skill"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_skills_manager_missing_directory() {
|
|
let mut sm = SkillsManager::new()
|
|
.with_skill_dir(PathBuf::from("/tmp/gyandoot-test-nonexistent-dir"));
|
|
let metas = sm.scan();
|
|
assert!(metas.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_skills_manager_load_from_nonexistent_path() {
|
|
let mut sm = SkillsManager::new();
|
|
let skill = sm.load_from("nope", &PathBuf::from("/tmp/no-such-skill"));
|
|
assert!(skill.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_preprocessor_max_depth_limit() {
|
|
let pp = Preprocessor::with_max_depth(2);
|
|
let mut vars = HashMap::new();
|
|
vars.insert("A".to_string(), "${B}".to_string());
|
|
vars.insert("B".to_string(), "${C}".to_string());
|
|
vars.insert("C".to_string(), "deep_value".to_string());
|
|
// With depth 2, should stop before fully resolving
|
|
let result = pp.process("${A}", &vars);
|
|
// Should contain at least ${B} or ${C} unresolved
|
|
assert!(result.contains("${")); // incomplete resolution
|
|
}
|
|
|
|
#[test]
|
|
fn test_skill_hub_config_default() {
|
|
let cfg = SkillHubConfig::default();
|
|
assert_eq!(cfg.base_url, "https://hub.gyandoot.dev");
|
|
assert!(cfg.api_token.is_none());
|
|
assert_eq!(cfg.timeout_secs, 10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_skill_bundle_write_and_read() {
|
|
let bundle = SkillBundle {
|
|
version: "1.0".to_string(),
|
|
name: "rw-test".to_string(),
|
|
description: "Write/read test".to_string(),
|
|
skills: vec![BundleSkill {
|
|
name: "rw-skill".to_string(),
|
|
description: "Test".to_string(),
|
|
category: None,
|
|
tags: Vec::new(),
|
|
trust: None,
|
|
content: "# Test".to_string(),
|
|
}],
|
|
};
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("bundle.json");
|
|
bundle.write_to(&path).unwrap();
|
|
|
|
let loaded = SkillBundle::from_file(&path).unwrap();
|
|
assert_eq!(loaded.name, "rw-test");
|
|
assert_eq!(loaded.skills.len(), 1);
|
|
}
|
|
} |