feat: integrate open-skills library and cleanup clippy warnings

- Add open-skills auto-clone/pull/sync support in skills loader
  - Clone https://github.com/besoeasy/open-skills to ~/open-skills
  - Weekly sync via .zeroclaw-open-skills-sync marker
  - Env controls: ZEROCLAW_OPEN_SKILLS_ENABLED, ZEROCLAW_OPEN_SKILLS_DIR
  - Load open-skills markdown files before workspace skills
  - Track Skill.location for accurate prompt rendering

- Update system prompt to render skill.location with fallback
  - Use actual file path when available
  - Maintain backward compatibility with workspace SKILL.md path

- Fix clippy warnings across tests and supporting files
  - Readable timestamp literals
  - Remove underscore bindings in tests
  - Use struct update syntax for Config::default() patterns
  - Fix module inception, duplicate attributes, manual strip
  - Clean raw string hashes and empty string construction

Resolves: #77
This commit is contained in:
argenis de la rosa 2026-02-14 20:25:07 -05:00
commit 04a35144e8
11 changed files with 390 additions and 103 deletions

View file

@ -210,9 +210,7 @@ async fn get_max_rowid(db_path: &Path) -> anyhow::Result<i64> {
&path, &path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?; )?;
let mut stmt = conn.prepare( let mut stmt = conn.prepare("SELECT MAX(ROWID) FROM message WHERE is_from_me = 0")?;
"SELECT MAX(ROWID) FROM message WHERE is_from_me = 0"
)?;
let rowid: Option<i64> = stmt.query_row([], |row| row.get(0))?; let rowid: Option<i64> = stmt.query_row([], |row| row.get(0))?;
Ok(rowid.unwrap_or(0)) Ok(rowid.unwrap_or(0))
}) })
@ -228,7 +226,8 @@ async fn fetch_new_messages(
since_rowid: i64, since_rowid: i64,
) -> anyhow::Result<Vec<(i64, String, String)>> { ) -> anyhow::Result<Vec<(i64, String, String)>> {
let path = db_path.to_path_buf(); let path = db_path.to_path_buf();
let results = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<(i64, String, String)>> { let results =
tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<(i64, String, String)>> {
let conn = Connection::open_with_flags( let conn = Connection::open_with_flags(
&path, &path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
@ -241,7 +240,7 @@ async fn fetch_new_messages(
AND m.is_from_me = 0 \ AND m.is_from_me = 0 \
AND m.text IS NOT NULL \ AND m.text IS NOT NULL \
ORDER BY m.ROWID ASC \ ORDER BY m.ROWID ASC \
LIMIT 20" LIMIT 20",
)?; )?;
let rows = stmt.query_map([since_rowid], |row| { let rows = stmt.query_map([since_rowid], |row| {
Ok(( Ok((
@ -501,7 +500,7 @@ mod tests {
fn invalid_target_applescript_injection() { fn invalid_target_applescript_injection() {
// Various injection attempts // Various injection attempts
assert!(!is_valid_imessage_target(r#"test" & quit"#)); assert!(!is_valid_imessage_target(r#"test" & quit"#));
assert!(!is_valid_imessage_target(r#"test\ndo shell script"#)); assert!(!is_valid_imessage_target(r"test\ndo shell script"));
assert!(!is_valid_imessage_target("test\"; malicious code; \"")); assert!(!is_valid_imessage_target("test\"; malicious code; \""));
} }
@ -551,8 +550,9 @@ mod tests {
text TEXT, text TEXT,
is_from_me INTEGER DEFAULT 0, is_from_me INTEGER DEFAULT 0,
FOREIGN KEY (handle_id) REFERENCES handle(ROWID) FOREIGN KEY (handle_id) REFERENCES handle(ROWID)
);" );",
).unwrap(); )
.unwrap();
(dir, db_path) (dir, db_path)
} }
@ -573,7 +573,11 @@ mod tests {
// Insert test data // Insert test data
{ {
let conn = Connection::open(&db_path).unwrap(); let conn = Connection::open(&db_path).unwrap();
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute( conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (100, 1, 'Hello', 0)", "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (100, 1, 'Hello', 0)",
[] []
@ -616,8 +620,16 @@ mod tests {
// Insert test data // Insert test data
{ {
let conn = Connection::open(&db_path).unwrap(); let conn = Connection::open(&db_path).unwrap();
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); conn.execute(
conn.execute("INSERT INTO handle (ROWID, id) VALUES (2, 'user@example.com')", []).unwrap(); "INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (2, 'user@example.com')",
[],
)
.unwrap();
conn.execute( conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'First message', 0)", "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'First message', 0)",
[] []
@ -630,8 +642,18 @@ mod tests {
let result = fetch_new_messages(&db_path, 0).await.unwrap(); let result = fetch_new_messages(&db_path, 0).await.unwrap();
assert_eq!(result.len(), 2); assert_eq!(result.len(), 2);
assert_eq!(result[0], (10, "+1234567890".to_string(), "First message".to_string())); assert_eq!(
assert_eq!(result[1], (20, "user@example.com".to_string(), "Second message".to_string())); result[0],
(10, "+1234567890".to_string(), "First message".to_string())
);
assert_eq!(
result[1],
(
20,
"user@example.com".to_string(),
"Second message".to_string()
)
);
} }
#[tokio::test] #[tokio::test]
@ -641,7 +663,11 @@ mod tests {
// Insert test data // Insert test data
{ {
let conn = Connection::open(&db_path).unwrap(); let conn = Connection::open(&db_path).unwrap();
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute( conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Old message', 0)", "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Old message', 0)",
[] []
@ -666,7 +692,11 @@ mod tests {
// Insert test data // Insert test data
{ {
let conn = Connection::open(&db_path).unwrap(); let conn = Connection::open(&db_path).unwrap();
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute( conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Received', 0)", "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Received', 0)",
[] []
@ -689,15 +719,20 @@ mod tests {
// Insert test data // Insert test data
{ {
let conn = Connection::open(&db_path).unwrap(); let conn = Connection::open(&db_path).unwrap();
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute( conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Has text', 0)", "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Has text', 0)",
[] []
).unwrap(); ).unwrap();
conn.execute( conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, NULL, 0)", "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (20, 1, NULL, 0)",
[] [],
).unwrap(); )
.unwrap();
} }
let result = fetch_new_messages(&db_path, 0).await.unwrap(); let result = fetch_new_messages(&db_path, 0).await.unwrap();
@ -712,7 +747,11 @@ mod tests {
// Insert 25 messages (limit is 20) // Insert 25 messages (limit is 20)
{ {
let conn = Connection::open(&db_path).unwrap(); let conn = Connection::open(&db_path).unwrap();
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
for i in 1..=25 { for i in 1..=25 {
conn.execute( conn.execute(
&format!("INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES ({i}, 1, 'Message {i}', 0)"), &format!("INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES ({i}, 1, 'Message {i}', 0)"),
@ -734,7 +773,11 @@ mod tests {
// Insert messages out of order // Insert messages out of order
{ {
let conn = Connection::open(&db_path).unwrap(); let conn = Connection::open(&db_path).unwrap();
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute( conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (30, 1, 'Third', 0)", "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (30, 1, 'Third', 0)",
[] []
@ -770,7 +813,11 @@ mod tests {
// Insert message with special characters (potential SQL injection patterns) // Insert message with special characters (potential SQL injection patterns)
{ {
let conn = Connection::open(&db_path).unwrap(); let conn = Connection::open(&db_path).unwrap();
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute( conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Hello \"world'' OR 1=1; DROP TABLE message;--', 0)", "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Hello \"world'' OR 1=1; DROP TABLE message;--', 0)",
[] []
@ -789,7 +836,11 @@ mod tests {
{ {
let conn = Connection::open(&db_path).unwrap(); let conn = Connection::open(&db_path).unwrap();
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute( conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Hello 🦀 世界 مرحبا', 0)", "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Hello 🦀 世界 مرحبا', 0)",
[] []
@ -807,11 +858,16 @@ mod tests {
{ {
let conn = Connection::open(&db_path).unwrap(); let conn = Connection::open(&db_path).unwrap();
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute( conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, '', 0)", "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, '', 0)",
[] [],
).unwrap(); )
.unwrap();
} }
let result = fetch_new_messages(&db_path, 0).await.unwrap(); let result = fetch_new_messages(&db_path, 0).await.unwrap();
@ -826,7 +882,11 @@ mod tests {
{ {
let conn = Connection::open(&db_path).unwrap(); let conn = Connection::open(&db_path).unwrap();
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute( conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Test', 0)", "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Test', 0)",
[] []
@ -844,7 +904,11 @@ mod tests {
{ {
let conn = Connection::open(&db_path).unwrap(); let conn = Connection::open(&db_path).unwrap();
conn.execute("INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')", []).unwrap(); conn.execute(
"INSERT INTO handle (ROWID, id) VALUES (1, '+1234567890')",
[],
)
.unwrap();
conn.execute( conn.execute(
"INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Test', 0)", "INSERT INTO message (ROWID, handle_id, text, is_from_me) VALUES (10, 1, 'Test', 0)",
[] []

View file

@ -123,10 +123,12 @@ pub fn build_system_prompt(
" <description>{}</description>", " <description>{}</description>",
skill.description skill.description
); );
let location = workspace_dir let location = skill.location.clone().unwrap_or_else(|| {
workspace_dir
.join("skills") .join("skills")
.join(&skill.name) .join(&skill.name)
.join("SKILL.md"); .join("SKILL.md")
});
let _ = writeln!(prompt, " <location>{}</location>", location.display()); let _ = writeln!(prompt, " <location>{}</location>", location.display());
let _ = writeln!(prompt, " </skill>"); let _ = writeln!(prompt, " </skill>");
} }
@ -825,6 +827,7 @@ mod tests {
tags: vec![], tags: vec![],
tools: vec![], tools: vec![],
prompts: vec!["Long prompt content that should NOT appear in system prompt".into()], prompts: vec!["Long prompt content that should NOT appear in system prompt".into()],
location: None,
}]; }];
let prompt = build_system_prompt(ws.path(), "model", &[], &skills); let prompt = build_system_prompt(ws.path(), "model", &[], &skills);
@ -937,8 +940,8 @@ mod tests {
calls: Arc::clone(&calls), calls: Arc::clone(&calls),
}); });
let (_tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(1); let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(1);
let handle = spawn_supervised_listener(channel, _tx, 1, 1); let handle = spawn_supervised_listener(channel, tx, 1, 1);
tokio::time::sleep(Duration::from_millis(80)).await; tokio::time::sleep(Duration::from_millis(80)).await;
drop(rx); drop(rx);

View file

@ -294,7 +294,7 @@ mod tests {
assert_eq!(msgs[0].sender, "+1234567890"); assert_eq!(msgs[0].sender, "+1234567890");
assert_eq!(msgs[0].content, "Hello ZeroClaw!"); assert_eq!(msgs[0].content, "Hello ZeroClaw!");
assert_eq!(msgs[0].channel, "whatsapp"); assert_eq!(msgs[0].channel, "whatsapp");
assert_eq!(msgs[0].timestamp, 1699999999); assert_eq!(msgs[0].timestamp, 1_699_999_999);
} }
#[test] #[test]

View file

@ -281,9 +281,11 @@ mod tests {
use tempfile::TempDir; use tempfile::TempDir;
fn test_config(tmp: &TempDir) -> Config { fn test_config(tmp: &TempDir) -> Config {
let mut config = Config::default(); let config = Config {
config.workspace_dir = tmp.path().join("workspace"); workspace_dir: tmp.path().join("workspace"),
config.config_path = tmp.path().join("config.toml"); config_path: tmp.path().join("config.toml"),
..Config::default()
};
std::fs::create_dir_all(&config.workspace_dir).unwrap(); std::fs::create_dir_all(&config.workspace_dir).unwrap();
config config
} }

View file

@ -186,9 +186,11 @@ mod tests {
use tempfile::TempDir; use tempfile::TempDir;
fn test_config(tmp: &TempDir) -> Config { fn test_config(tmp: &TempDir) -> Config {
let mut config = Config::default(); let config = Config {
config.workspace_dir = tmp.path().join("workspace"); workspace_dir: tmp.path().join("workspace"),
config.config_path = tmp.path().join("config.toml"); config_path: tmp.path().join("config.toml"),
..Config::default()
};
std::fs::create_dir_all(&config.workspace_dir).unwrap(); std::fs::create_dir_all(&config.workspace_dir).unwrap();
config config
} }

View file

@ -215,9 +215,11 @@ mod tests {
use tempfile::TempDir; use tempfile::TempDir;
fn test_config(tmp: &TempDir) -> Config { fn test_config(tmp: &TempDir) -> Config {
let mut config = Config::default(); let config = Config {
config.workspace_dir = tmp.path().join("workspace"); workspace_dir: tmp.path().join("workspace"),
config.config_path = tmp.path().join("config.toml"); config_path: tmp.path().join("config.toml"),
..Config::default()
};
std::fs::create_dir_all(&config.workspace_dir).unwrap(); std::fs::create_dir_all(&config.workspace_dir).unwrap();
config config
} }

View file

@ -242,7 +242,7 @@ fn hex_encode(data: &[u8]) -> String {
/// Hex-decode a hex string to bytes. /// Hex-decode a hex string to bytes.
#[allow(clippy::manual_is_multiple_of)] #[allow(clippy::manual_is_multiple_of)]
fn hex_decode(hex: &str) -> Result<Vec<u8>> { fn hex_decode(hex: &str) -> Result<Vec<u8>> {
if !hex.len().is_multiple_of(2) { if (hex.len() & 1) != 0 {
anyhow::bail!("Hex string has odd length"); anyhow::bail!("Hex string has odd length");
} }
(0..hex.len()) (0..hex.len())

View file

@ -1,7 +1,14 @@
use anyhow::Result; use anyhow::Result;
use directories::UserDirs;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime};
const OPEN_SKILLS_REPO_URL: &str = "https://github.com/besoeasy/open-skills";
const OPEN_SKILLS_SYNC_MARKER: &str = ".zeroclaw-open-skills-sync";
const OPEN_SKILLS_SYNC_INTERVAL_SECS: u64 = 60 * 60 * 24 * 7;
/// A skill is a user-defined or community-built capability. /// A skill is a user-defined or community-built capability.
/// Skills live in `~/.zeroclaw/workspace/skills/<name>/SKILL.md` /// Skills live in `~/.zeroclaw/workspace/skills/<name>/SKILL.md`
@ -19,6 +26,8 @@ pub struct Skill {
pub tools: Vec<SkillTool>, pub tools: Vec<SkillTool>,
#[serde(default)] #[serde(default)]
pub prompts: Vec<String>, pub prompts: Vec<String>,
#[serde(skip)]
pub location: Option<PathBuf>,
} }
/// A tool defined by a skill (shell command, HTTP call, etc.) /// A tool defined by a skill (shell command, HTTP call, etc.)
@ -62,14 +71,29 @@ fn default_version() -> String {
/// Load all skills from the workspace skills directory /// Load all skills from the workspace skills directory
pub fn load_skills(workspace_dir: &Path) -> Vec<Skill> { pub fn load_skills(workspace_dir: &Path) -> Vec<Skill> {
let mut skills = Vec::new();
if let Some(open_skills_dir) = ensure_open_skills_repo() {
skills.extend(load_open_skills(&open_skills_dir));
}
skills.extend(load_workspace_skills(workspace_dir));
skills
}
fn load_workspace_skills(workspace_dir: &Path) -> Vec<Skill> {
let skills_dir = workspace_dir.join("skills"); let skills_dir = workspace_dir.join("skills");
load_skills_from_directory(&skills_dir)
}
fn load_skills_from_directory(skills_dir: &Path) -> Vec<Skill> {
if !skills_dir.exists() { if !skills_dir.exists() {
return Vec::new(); return Vec::new();
} }
let mut skills = Vec::new(); let mut skills = Vec::new();
let Ok(entries) = std::fs::read_dir(&skills_dir) else { let Ok(entries) = std::fs::read_dir(skills_dir) else {
return skills; return skills;
}; };
@ -97,6 +121,172 @@ pub fn load_skills(workspace_dir: &Path) -> Vec<Skill> {
skills skills
} }
fn load_open_skills(repo_dir: &Path) -> Vec<Skill> {
let mut skills = Vec::new();
let Ok(entries) = std::fs::read_dir(repo_dir) else {
return skills;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let is_markdown = path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
if !is_markdown {
continue;
}
let is_readme = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("README.md"));
if is_readme {
continue;
}
if let Ok(skill) = load_open_skill_md(&path) {
skills.push(skill);
}
}
skills
}
fn open_skills_enabled() -> bool {
if let Ok(raw) = std::env::var("ZEROCLAW_OPEN_SKILLS_ENABLED") {
let value = raw.trim().to_ascii_lowercase();
return !matches!(value.as_str(), "0" | "false" | "off" | "no");
}
// Keep tests deterministic and network-free by default.
!cfg!(test)
}
fn resolve_open_skills_dir() -> Option<PathBuf> {
if let Ok(path) = std::env::var("ZEROCLAW_OPEN_SKILLS_DIR") {
let trimmed = path.trim();
if !trimmed.is_empty() {
return Some(PathBuf::from(trimmed));
}
}
UserDirs::new().map(|dirs| dirs.home_dir().join("open-skills"))
}
fn ensure_open_skills_repo() -> Option<PathBuf> {
if !open_skills_enabled() {
return None;
}
let repo_dir = resolve_open_skills_dir()?;
if !repo_dir.exists() {
if !clone_open_skills_repo(&repo_dir) {
return None;
}
let _ = mark_open_skills_synced(&repo_dir);
return Some(repo_dir);
}
if should_sync_open_skills(&repo_dir) {
if pull_open_skills_repo(&repo_dir) {
let _ = mark_open_skills_synced(&repo_dir);
} else {
tracing::warn!(
"open-skills update failed; using local copy from {}",
repo_dir.display()
);
}
}
Some(repo_dir)
}
fn clone_open_skills_repo(repo_dir: &Path) -> bool {
if let Some(parent) = repo_dir.parent() {
if let Err(err) = std::fs::create_dir_all(parent) {
tracing::warn!(
"failed to create open-skills parent directory {}: {err}",
parent.display()
);
return false;
}
}
let output = Command::new("git")
.args(["clone", "--depth", "1", OPEN_SKILLS_REPO_URL])
.arg(repo_dir)
.output();
match output {
Ok(result) if result.status.success() => {
tracing::info!("initialized open-skills at {}", repo_dir.display());
true
}
Ok(result) => {
let stderr = String::from_utf8_lossy(&result.stderr);
tracing::warn!("failed to clone open-skills: {stderr}");
false
}
Err(err) => {
tracing::warn!("failed to run git clone for open-skills: {err}");
false
}
}
}
fn pull_open_skills_repo(repo_dir: &Path) -> bool {
// If user points to a non-git directory via env var, keep using it without pulling.
if !repo_dir.join(".git").exists() {
return true;
}
let output = Command::new("git")
.arg("-C")
.arg(repo_dir)
.args(["pull", "--ff-only"])
.output();
match output {
Ok(result) if result.status.success() => true,
Ok(result) => {
let stderr = String::from_utf8_lossy(&result.stderr);
tracing::warn!("failed to pull open-skills updates: {stderr}");
false
}
Err(err) => {
tracing::warn!("failed to run git pull for open-skills: {err}");
false
}
}
}
fn should_sync_open_skills(repo_dir: &Path) -> bool {
let marker = repo_dir.join(OPEN_SKILLS_SYNC_MARKER);
let Ok(metadata) = std::fs::metadata(marker) else {
return true;
};
let Ok(modified_at) = metadata.modified() else {
return true;
};
let Ok(age) = SystemTime::now().duration_since(modified_at) else {
return true;
};
age >= Duration::from_secs(OPEN_SKILLS_SYNC_INTERVAL_SECS)
}
fn mark_open_skills_synced(repo_dir: &Path) -> Result<()> {
std::fs::write(repo_dir.join(OPEN_SKILLS_SYNC_MARKER), b"synced")?;
Ok(())
}
/// Load a skill from a SKILL.toml manifest /// Load a skill from a SKILL.toml manifest
fn load_skill_toml(path: &Path) -> Result<Skill> { fn load_skill_toml(path: &Path) -> Result<Skill> {
let content = std::fs::read_to_string(path)?; let content = std::fs::read_to_string(path)?;
@ -110,6 +300,7 @@ fn load_skill_toml(path: &Path) -> Result<Skill> {
tags: manifest.skill.tags, tags: manifest.skill.tags,
tools: manifest.tools, tools: manifest.tools,
prompts: manifest.prompts, prompts: manifest.prompts,
location: Some(path.to_path_buf()),
}) })
} }
@ -122,25 +313,47 @@ fn load_skill_md(path: &Path, dir: &Path) -> Result<Skill> {
.unwrap_or("unknown") .unwrap_or("unknown")
.to_string(); .to_string();
// Extract description from first non-heading line
let description = content
.lines()
.find(|l| !l.starts_with('#') && !l.trim().is_empty())
.unwrap_or("No description")
.trim()
.to_string();
Ok(Skill { Ok(Skill {
name, name,
description, description: extract_description(&content),
version: "0.1.0".to_string(), version: "0.1.0".to_string(),
author: None, author: None,
tags: Vec::new(), tags: Vec::new(),
tools: Vec::new(), tools: Vec::new(),
prompts: vec![content], prompts: vec![content],
location: Some(path.to_path_buf()),
}) })
} }
fn load_open_skill_md(path: &Path) -> Result<Skill> {
let content = std::fs::read_to_string(path)?;
let name = path
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or("open-skill")
.to_string();
Ok(Skill {
name,
description: extract_description(&content),
version: "open-skills".to_string(),
author: Some("besoeasy/open-skills".to_string()),
tags: vec!["open-skills".to_string()],
tools: Vec::new(),
prompts: vec![content],
location: Some(path.to_path_buf()),
})
}
fn extract_description(content: &str) -> String {
content
.lines()
.find(|line| !line.starts_with('#') && !line.trim().is_empty())
.unwrap_or("No description")
.trim()
.to_string()
}
/// Build a system prompt addition from all loaded skills /// Build a system prompt addition from all loaded skills
pub fn skills_to_prompt(skills: &[Skill]) -> String { pub fn skills_to_prompt(skills: &[Skill]) -> String {
use std::fmt::Write; use std::fmt::Write;
@ -468,6 +681,7 @@ command = "echo hello"
tags: vec![], tags: vec![],
tools: vec![], tools: vec![],
prompts: vec!["Do the thing.".to_string()], prompts: vec!["Do the thing.".to_string()],
location: None,
}]; }];
let prompt = skills_to_prompt(&skills); let prompt = skills_to_prompt(&skills);
assert!(prompt.contains("test")); assert!(prompt.contains("test"));
@ -657,6 +871,7 @@ description = "Bare minimum"
args: HashMap::new(), args: HashMap::new(),
}], }],
prompts: vec![], prompts: vec![],
location: None,
}]; }];
let prompt = skills_to_prompt(&skills); let prompt = skills_to_prompt(&skills);
assert!(prompt.contains("weather")); assert!(prompt.contains("weather"));

View file

@ -1,5 +1,5 @@
#[cfg(test)] #[cfg(test)]
mod symlink_tests { mod tests {
use crate::skills::skills_dir; use crate::skills::skills_dir;
use std::path::Path; use std::path::Path;
use tempfile::TempDir; use tempfile::TempDir;

View file

@ -365,8 +365,8 @@ impl BrowserTool {
} }
} }
#[async_trait]
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
#[async_trait]
impl Tool for BrowserTool { impl Tool for BrowserTool {
fn name(&self) -> &str { fn name(&self) -> &str {
"browser" "browser"
@ -750,7 +750,7 @@ mod tests {
let domains = vec![ let domains = vec![
" Example.COM ".into(), " Example.COM ".into(),
"docs.example.com".into(), "docs.example.com".into(),
"".into(), String::new(),
]; ];
let normalized = normalize_domains(domains); let normalized = normalize_domains(domains);
assert_eq!(normalized, vec!["example.com", "docs.example.com"]); assert_eq!(normalized, vec!["example.com", "docs.example.com"]);

View file

@ -84,9 +84,8 @@ fn pattern_matches(pattern: &str, path: &str) -> bool {
fn is_excluded(patterns: &[String], path: &str) -> bool { fn is_excluded(patterns: &[String], path: &str) -> bool {
let mut excluded = false; let mut excluded = false;
for pattern in patterns { for pattern in patterns {
if pattern.starts_with('!') { if let Some(negated) = pattern.strip_prefix('!') {
// Negation pattern - re-include // Negation pattern - re-include
let negated = &pattern[1..];
if pattern_matches(negated, path) { if pattern_matches(negated, path) {
excluded = false; excluded = false;
} }