use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::agents::{self, AgentInstall, InstallOptions, RemoveOptions};
use crate::skill::{self, CommandInfo, SkillFile};
#[derive(Debug, Clone, Default)]
pub struct SyncOptions {
pub cwd: Option<String>,
pub depth: Option<usize>,
pub description: Option<String>,
pub global: bool,
pub include: Option<Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct SyncedSkill {
pub name: String,
pub description: Option<String>,
pub external: bool,
}
#[derive(Debug, Clone)]
pub struct SyncResult {
pub skills: Vec<SyncedSkill>,
pub paths: Vec<PathBuf>,
pub agents: Vec<AgentInstall>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct Meta {
hash: String,
#[serde(default)]
skills: Vec<String>,
#[serde(default)]
at: String,
}
pub async fn sync(
name: &str,
commands: &[CommandInfo],
options: &SyncOptions,
) -> Result<SyncResult, crate::errors::Error> {
let depth = options.depth.unwrap_or(1);
let is_global = options.global;
let cwd = options.cwd.clone().unwrap_or_else(|| {
std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string())
});
let mut groups: BTreeMap<String, String> = BTreeMap::new();
if let Some(desc) = &options.description {
groups.insert(name.to_string(), desc.clone());
}
let files = skill::split(name, commands, depth, &groups);
let tmp_dir =
std::env::temp_dir().join(format!("incur-skills-{}-{}", name, std::process::id()));
let _ = fs::create_dir_all(&tmp_dir);
let result = sync_inner(
name,
commands,
&files,
&tmp_dir,
&cwd,
is_global,
&options.include,
);
let _ = fs::remove_dir_all(&tmp_dir);
result
}
fn sync_inner(
name: &str,
commands: &[CommandInfo],
files: &[SkillFile],
tmp_dir: &Path,
cwd: &str,
is_global: bool,
include: &Option<Vec<String>>,
) -> Result<SyncResult, crate::errors::Error> {
let mut skills: Vec<SyncedSkill> = Vec::new();
for file in files {
let file_path = if file.dir.is_empty() {
tmp_dir.join("SKILL.md")
} else {
tmp_dir.join(&file.dir).join("SKILL.md")
};
if let Some(parent) = file_path.parent() {
let _ = fs::create_dir_all(parent);
}
let content = format!("{}\n", file.content);
let _ = fs::write(&file_path, &content);
let desc = extract_description(&content);
let skill_name = if file.dir.is_empty() {
name.to_string()
} else {
file.dir.clone()
};
skills.push(SyncedSkill {
name: skill_name,
description: desc,
external: false,
});
}
if let Some(patterns) = include {
for pattern in patterns {
let is_root = pattern == "_root";
let search_path = if is_root {
PathBuf::from(cwd).join("SKILL.md")
} else {
PathBuf::from(cwd).join(pattern).join("SKILL.md")
};
if search_path.exists() {
if let Ok(content) = fs::read_to_string(&search_path) {
let skill_name = if is_root {
extract_skill_name(&content).unwrap_or_else(|| name.to_string())
} else {
search_path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or(pattern)
.to_string()
};
let dest = tmp_dir.join(&skill_name).join("SKILL.md");
if let Some(parent) = dest.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = fs::write(&dest, &content);
if !skills.iter().any(|s| s.name == skill_name) {
let desc = extract_description(&content);
skills.push(SyncedSkill {
name: skill_name,
description: desc,
external: true,
});
}
}
}
}
}
let install_result = agents::install(
tmp_dir,
&InstallOptions {
global: Some(is_global),
cwd: Some(cwd.to_string()),
..Default::default()
},
);
let current_names: std::collections::HashSet<String> = install_result
.paths
.iter()
.filter_map(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
})
.collect();
let prev = read_meta(name);
if let Some(prev_meta) = prev {
for old in &prev_meta.skills {
if !current_names.contains(old) {
agents::remove(
old,
&RemoveOptions {
global: Some(is_global),
cwd: Some(cwd.to_string()),
},
);
}
}
}
let hash = skill::hash(commands);
let skill_names: Vec<String> = current_names.into_iter().collect();
write_meta(name, &hash, &skill_names);
Ok(SyncResult {
skills,
paths: install_result.paths,
agents: install_result.agents,
})
}
pub fn read_hash(name: &str) -> Option<String> {
read_meta(name).map(|m| m.hash)
}
fn meta_path(name: &str) -> PathBuf {
let data_home = std::env::var("XDG_DATA_HOME")
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_default()
.join(".local")
.join("share")
});
data_home.join("incur").join(format!("{}.json", name))
}
fn write_meta(name: &str, hash: &str, skills: &[String]) {
let file = meta_path(name);
if let Some(dir) = file.parent() {
let _ = fs::create_dir_all(dir);
}
let meta = Meta {
hash: hash.to_string(),
skills: skills.to_vec(),
at: chrono_now(),
};
if let Ok(json) = serde_json::to_string(&meta) {
let _ = fs::write(&file, format!("{}\n", json));
}
}
fn read_meta(name: &str) -> Option<Meta> {
let file = meta_path(name);
let content = fs::read_to_string(&file).ok()?;
serde_json::from_str(&content).ok()
}
fn extract_description(content: &str) -> Option<String> {
for line in content.lines() {
if let Some(rest) = line.strip_prefix("description:") {
let desc = rest.trim();
if !desc.is_empty() {
return Some(desc.to_string());
}
}
}
None
}
fn extract_skill_name(content: &str) -> Option<String> {
for line in content.lines() {
if let Some(rest) = line.strip_prefix("name:") {
let name = rest.trim();
if !name.is_empty() {
return Some(name.to_string());
}
}
}
None
}
fn chrono_now() -> String {
use std::time::SystemTime;
match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
Ok(d) => format!("{}s", d.as_secs()),
Err(_) => "0s".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_description() {
let content = "---\nname: test\ndescription: A test skill\n---\n";
assert_eq!(
extract_description(content),
Some("A test skill".to_string())
);
}
#[test]
fn test_extract_description_missing() {
let content = "---\nname: test\n---\n";
assert_eq!(extract_description(content), None);
}
#[test]
fn test_extract_skill_name() {
let content = "---\nname: my-skill\n---\n";
assert_eq!(extract_skill_name(content), Some("my-skill".to_string()));
}
#[test]
fn test_meta_path() {
let path = meta_path("mycli");
assert!(path.to_string_lossy().contains("incur"));
assert!(path.to_string_lossy().ends_with("mycli.json"));
}
#[test]
fn test_read_hash_nonexistent() {
assert_eq!(read_hash("nonexistent-test-cli-12345"), None);
}
}