use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::{Result, SkillError};
use crate::types::AGENTS_DIR;
const LOCK_FILE: &str = ".skill-lock.json";
const CURRENT_VERSION: u32 = 3;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillLockEntry {
pub source: String,
pub source_type: String,
pub source_url: String,
#[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
pub git_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skill_path: Option<String>,
pub skill_folder_hash: String,
pub installed_at: String,
pub updated_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub plugin_name: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DismissedPrompts {
#[serde(skip_serializing_if = "Option::is_none")]
pub find_skills_prompt: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillLockFile {
pub version: u32,
pub skills: std::collections::HashMap<String, SkillLockEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dismissed: Option<DismissedPrompts>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_selected_agents: Option<Vec<String>>,
}
impl SkillLockFile {
fn empty() -> Self {
Self {
version: CURRENT_VERSION,
skills: std::collections::HashMap::new(),
dismissed: Some(DismissedPrompts::default()),
last_selected_agents: None,
}
}
}
#[must_use]
pub fn lock_file_path() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_STATE_HOME")
&& !xdg.trim().is_empty()
{
return PathBuf::from(xdg).join("skills").join(LOCK_FILE);
}
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
home.join(AGENTS_DIR).join(LOCK_FILE)
}
pub async fn read_skill_lock() -> Result<SkillLockFile> {
let path = lock_file_path();
match tokio::fs::read_to_string(&path).await {
Ok(content) => {
let parsed: SkillLockFile = serde_json::from_str(&content)?;
if parsed.version < CURRENT_VERSION {
return Ok(SkillLockFile::empty());
}
Ok(parsed)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(SkillLockFile::empty()),
Err(e) => Err(SkillError::io(path, e)),
}
}
pub async fn write_skill_lock(lock: &SkillLockFile) -> Result<()> {
let path = lock_file_path();
let Some(parent) = path.parent() else {
return Err(SkillError::io(
&path,
std::io::Error::other("lock file path has no parent"),
));
};
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| SkillError::io(parent, e))?;
let content = serde_json::to_string_pretty(lock)?;
let tmp_path = parent.join(".skill-lock.tmp");
tokio::fs::write(&tmp_path, &content)
.await
.map_err(|e| SkillError::io(&tmp_path, e))?;
tokio::fs::rename(&tmp_path, &path)
.await
.map_err(|e| SkillError::io(&path, e))
}
#[derive(Debug)]
pub struct AddLockInput<'a> {
pub name: &'a str,
pub source: &'a str,
pub source_type: &'a str,
pub source_url: &'a str,
pub git_ref: Option<&'a str>,
pub skill_path: Option<&'a str>,
pub skill_folder_hash: &'a str,
pub plugin_name: Option<&'a str>,
}
pub async fn add_skill_to_lock(input: &AddLockInput<'_>) -> Result<()> {
let mut lock = read_skill_lock().await?;
let now = crate::util::time::iso8601_now();
let installed_at = lock
.skills
.get(input.name)
.map_or_else(|| now.clone(), |e| e.installed_at.clone());
lock.skills.insert(
input.name.to_owned(),
SkillLockEntry {
source: input.source.to_owned(),
source_type: input.source_type.to_owned(),
source_url: input.source_url.to_owned(),
git_ref: input.git_ref.map(String::from),
skill_path: input.skill_path.map(String::from),
skill_folder_hash: input.skill_folder_hash.to_owned(),
installed_at,
updated_at: now,
plugin_name: input.plugin_name.map(String::from),
},
);
write_skill_lock(&lock).await
}
pub async fn remove_skill_from_lock(skill_name: &str) -> Result<bool> {
let mut lock = read_skill_lock().await?;
let removed = lock.skills.remove(skill_name).is_some();
if removed {
write_skill_lock(&lock).await?;
}
Ok(removed)
}
pub async fn get_skill_from_lock(skill_name: &str) -> Result<Option<SkillLockEntry>> {
let lock = read_skill_lock().await?;
Ok(lock.skills.get(skill_name).cloned())
}
pub async fn get_all_locked_skills() -> Result<std::collections::HashMap<String, SkillLockEntry>> {
let lock = read_skill_lock().await?;
Ok(lock.skills)
}
pub async fn is_prompt_dismissed(prompt: &str) -> Result<bool> {
let lock = read_skill_lock().await?;
Ok(match prompt {
"findSkillsPrompt" => lock
.dismissed
.as_ref()
.and_then(|d| d.find_skills_prompt)
.unwrap_or(false),
_ => false,
})
}
pub async fn dismiss_prompt(prompt: &str) -> Result<()> {
let mut lock = read_skill_lock().await?;
let dismissed = lock.dismissed.get_or_insert_with(DismissedPrompts::default);
if prompt == "findSkillsPrompt" {
dismissed.find_skills_prompt = Some(true);
}
write_skill_lock(&lock).await
}
pub async fn save_selected_agents(agents: &[String]) -> Result<()> {
let mut lock = read_skill_lock().await?;
lock.last_selected_agents = Some(agents.to_vec());
write_skill_lock(&lock).await
}
pub async fn get_last_selected_agents() -> Result<Option<Vec<String>>> {
let lock = read_skill_lock().await?;
Ok(lock.last_selected_agents)
}