use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use semver::Version;
use serde::{Deserialize, Serialize};
use crate::download::archive::extract_skill_package_zip;
use crate::download::manager::{DownloadManager, DownloadManagerConfig};
use crate::host::options::RuntimeSkillRoot;
use crate::lua_skill::{SkillMeta, validate_luaskills_identifier, validate_luaskills_version};
use crate::skill::source::{
InstalledSkillRecord, InstalledSkillSourceRecord, SkillInstallSourceType,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SkillLifecycleAction {
Install,
Update,
Reload,
Uninstall,
Enable,
Disable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SkillOperationPlane {
Skills,
System,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SkillManagementAuthority {
System,
DelegatedTool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillManagerConfig {
pub skill_root: RuntimeSkillRoot,
pub lifecycle_root: PathBuf,
pub download_cache_root: PathBuf,
pub allow_network_download: bool,
#[serde(default)]
pub github_base_url: Option<String>,
#[serde(default)]
pub github_api_base_url: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SkillInstallRequest {
pub skill_id: Option<String>,
pub source: Option<String>,
#[serde(default)]
pub source_type: SkillInstallSourceType,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillApplyResult {
pub skill_id: String,
pub status: String,
pub message: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub source_type: Option<SkillInstallSourceType>,
#[serde(default)]
pub source_locator: Option<String>,
}
#[derive(Debug, Clone)]
pub enum PreparedSkillApply {
Immediate(SkillApplyResult),
Install(PreparedSkillInstall),
Update(PreparedSkillUpdate),
}
#[derive(Debug, Clone)]
pub struct PreparedSkillInstall {
pub result: SkillApplyResult,
pub target_dir: PathBuf,
pub install_record: InstalledSkillRecord,
}
#[derive(Debug, Clone)]
pub struct PreparedSkillUpdate {
pub result: SkillApplyResult,
pub target_dir: PathBuf,
pub backup_dir: PathBuf,
pub install_record: InstalledSkillRecord,
pub previous_install_record: InstalledSkillRecord,
}
#[derive(Debug, Clone)]
pub struct PreparedSkillUninstall {
pub result: SkillUninstallResult,
pub target_dir: PathBuf,
pub backup_dir: Option<PathBuf>,
pub previous_disabled_record: Option<DisabledSkillRecord>,
pub previous_install_record: Option<InstalledSkillRecord>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillUninstallOptions {
#[serde(default)]
pub remove_sqlite: bool,
#[serde(default)]
pub remove_lancedb: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillUninstallResult {
pub skill_id: String,
pub skill_removed: bool,
pub sqlite_removed: bool,
pub lancedb_removed: bool,
pub sqlite_retained: bool,
pub lancedb_retained: bool,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedSkillInstance {
pub skill_id: String,
pub root_name: String,
pub skills_root: PathBuf,
pub actual_dir: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DisabledSkillRecord {
pub skill_id: String,
pub reason: Option<String>,
pub disabled_at_unix_ms: u128,
}
pub struct SkillManager {
config: SkillManagerConfig,
}
struct TempDirGuard {
path: PathBuf,
disarmed: bool,
}
impl TempDirGuard {
fn new(path: PathBuf) -> Self {
Self {
path,
disarmed: false,
}
}
fn disarm(&mut self) {
self.disarmed = true;
}
}
impl Drop for TempDirGuard {
fn drop(&mut self) {
if !self.disarmed && self.path.exists() {
let _ = fs::remove_dir_all(&self.path);
}
}
}
impl SkillManager {
pub fn new(config: SkillManagerConfig) -> Self {
Self { config }
}
pub fn ensure_state_layout(&self) -> Result<(), String> {
fs::create_dir_all(self.disabled_root()).map_err(|error| {
format!(
"Failed to create disabled root {}: {}",
self.disabled_root().display(),
error
)
})?;
fs::create_dir_all(self.install_record_root()).map_err(|error| {
format!(
"Failed to create install-record root {}: {}",
self.install_record_root().display(),
error
)
})
}
pub fn guard_operation(
&self,
plane: SkillOperationPlane,
action: SkillLifecycleAction,
skill_id: &str,
) -> Result<(), String> {
validate_luaskills_identifier(skill_id, "skill_id")?;
if plane == SkillOperationPlane::Skills && is_root_skill_layer(&self.config.skill_root) {
return Err(format!(
"ROOT skill root is system-controlled and cannot be processed through the skills plane for action {:?}",
action
));
}
Ok(())
}
pub fn is_skill_enabled(&self, skill_id: &str) -> Result<bool, String> {
self.ensure_state_layout()?;
Ok(!self.disabled_record_path(skill_id).exists())
}
pub fn disable_skill(&self, skill_id: &str, reason: Option<&str>) -> Result<(), String> {
self.disable_skill_in_plane(SkillOperationPlane::Skills, skill_id, reason)
}
pub fn disable_skill_in_plane(
&self,
plane: SkillOperationPlane,
skill_id: &str,
reason: Option<&str>,
) -> Result<(), String> {
self.guard_operation(plane, SkillLifecycleAction::Disable, skill_id)?;
self.ensure_state_layout()?;
let record = DisabledSkillRecord {
skill_id: skill_id.to_string(),
reason: reason
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
disabled_at_unix_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
};
let path = self.disabled_record_path(skill_id);
let content = serde_json::to_string_pretty(&record)
.map_err(|error| format!("Failed to serialize disabled record: {}", error))?;
fs::write(&path, content)
.map_err(|error| format!("Failed to write {}: {}", path.display(), error))
}
pub fn enable_skill(&self, skill_id: &str) -> Result<(), String> {
self.enable_skill_in_plane(SkillOperationPlane::Skills, skill_id)
}
pub fn enable_skill_in_plane(
&self,
plane: SkillOperationPlane,
skill_id: &str,
) -> Result<(), String> {
self.guard_operation(plane, SkillLifecycleAction::Enable, skill_id)?;
self.ensure_state_layout()?;
let path = self.disabled_record_path(skill_id);
if path.exists() {
fs::remove_file(&path)
.map_err(|error| format!("Failed to remove {}: {}", path.display(), error))?;
}
Ok(())
}
pub fn disabled_record(&self, skill_id: &str) -> Result<Option<DisabledSkillRecord>, String> {
let path = self.disabled_record_path(skill_id);
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path)
.map_err(|error| format!("Failed to read {}: {}", path.display(), error))?;
let record = serde_json::from_str::<DisabledSkillRecord>(&content)
.map_err(|error| format!("Failed to parse {}: {}", path.display(), error))?;
Ok(Some(record))
}
pub fn uninstall_skill(&self, skill_id: &str) -> Result<SkillUninstallResult, String> {
self.uninstall_skill_in_plane(SkillOperationPlane::Skills, skill_id)
}
pub fn uninstall_skill_in_plane(
&self,
plane: SkillOperationPlane,
skill_id: &str,
) -> Result<SkillUninstallResult, String> {
let skill_dir = self.config.skill_root.skills_dir.join(skill_id);
let prepared =
self.prepare_uninstall_skill_at_path_in_plane(plane, skill_id, &skill_dir)?;
self.commit_prepared_skill_uninstall(&prepared)
.map_err(|error| {
let rollback_error = self.rollback_prepared_skill_uninstall(&prepared);
let rollback_message = rollback_error
.err()
.map(|rollback| format!(" rollback failed: {}", rollback))
.unwrap_or_default();
format!(
"Failed to finalize uninstall: {}.{}",
error, rollback_message
)
})
}
pub fn uninstall_skill_at_path_in_plane(
&self,
plane: SkillOperationPlane,
skill_id: &str,
skill_dir: &Path,
) -> Result<SkillUninstallResult, String> {
let prepared = self.prepare_uninstall_skill_at_path_in_plane(plane, skill_id, skill_dir)?;
self.commit_prepared_skill_uninstall(&prepared)
.map_err(|error| {
let rollback_error = self.rollback_prepared_skill_uninstall(&prepared);
let rollback_message = rollback_error
.err()
.map(|rollback| format!(" rollback failed: {}", rollback))
.unwrap_or_default();
format!(
"Failed to finalize uninstall: {}.{}",
error, rollback_message
)
})
}
pub fn prepare_uninstall_skill_at_path_in_plane(
&self,
plane: SkillOperationPlane,
skill_id: &str,
skill_dir: &Path,
) -> Result<PreparedSkillUninstall, String> {
self.guard_operation(plane, SkillLifecycleAction::Uninstall, skill_id)?;
self.ensure_state_layout()?;
let previous_disabled_record = self.disabled_record(skill_id)?;
let previous_install_record = self.install_record(skill_id)?;
let (skill_removed, backup_dir) = if skill_dir.exists() {
let backup_dir = self
.config
.lifecycle_root
.join("uninstall_backup")
.join(format!("{}-{}", skill_id, current_unix_millis()));
if let Some(parent) = backup_dir.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create {}: {}", parent.display(), error))?;
}
fs::rename(skill_dir, &backup_dir).map_err(|error| {
format!(
"Failed to move current skill {} into uninstall backup {}: {}",
skill_dir.display(),
backup_dir.display(),
error
)
})?;
(true, Some(backup_dir))
} else {
(false, None)
};
Ok(PreparedSkillUninstall {
result: SkillUninstallResult {
skill_id: skill_id.to_string(),
skill_removed,
sqlite_removed: false,
lancedb_removed: false,
sqlite_retained: false,
lancedb_retained: false,
message: if skill_removed {
"skill package removed".to_string()
} else {
"skill package directory not found".to_string()
},
},
target_dir: skill_dir.to_path_buf(),
backup_dir,
previous_disabled_record,
previous_install_record,
})
}
pub fn prepare_install_skill(
&self,
plane: SkillOperationPlane,
skill_roots: &[RuntimeSkillRoot],
request: &SkillInstallRequest,
) -> Result<PreparedSkillApply, String> {
let skill_id = resolve_requested_skill_id(request)?;
self.guard_operation(plane, SkillLifecycleAction::Install, &skill_id)?;
if resolve_declared_skill_instance_from_roots(skill_roots, &skill_id)?.is_some() {
return Ok(PreparedSkillApply::Immediate(SkillApplyResult {
skill_id,
status: "already_installed".to_string(),
message: "skill already exists; use update to evaluate upgrade behavior"
.to_string(),
version: None,
source_type: None,
source_locator: None,
}));
}
match request.source_type {
SkillInstallSourceType::Github => self.prepare_install_skill_from_github(&skill_id, request),
SkillInstallSourceType::Url => Err(
"managed URL install is not implemented yet; GitHub install is currently the only supported install source"
.to_string(),
),
}
}
pub fn prepare_update_skill(
&self,
plane: SkillOperationPlane,
skill_roots: &[RuntimeSkillRoot],
request: &SkillInstallRequest,
) -> Result<PreparedSkillApply, String> {
let skill_id = resolve_requested_skill_id(request)?;
self.guard_operation(plane, SkillLifecycleAction::Update, &skill_id)?;
if resolve_declared_skill_instance_from_roots(skill_roots, &skill_id)?.is_none() {
return Ok(PreparedSkillApply::Immediate(SkillApplyResult {
skill_id,
status: "missing_skill".to_string(),
message: "skill is not installed; use install first".to_string(),
version: None,
source_type: None,
source_locator: None,
}));
}
self.prepare_github_managed_skill_update(&skill_id)
}
fn prepare_install_skill_from_github(
&self,
skill_id: &str,
request: &SkillInstallRequest,
) -> Result<PreparedSkillApply, String> {
let repo = normalize_github_repo_locator(
request
.source
.as_deref()
.ok_or_else(|| "github install requires source repository".to_string())?,
)?;
let repo_skill_id = github_repo_skill_id(&repo)?;
if repo_skill_id != skill_id {
return Err(format!(
"github repository '{}' resolves to skill_id '{}' but the request targets '{}'",
repo, repo_skill_id, skill_id
));
}
let downloader = self.downloader();
let asset = downloader.resolve_github_managed_skill_release_asset(
&crate::skill::dependencies::GithubReleaseSourceSpec {
repo: repo.clone(),
tag_api: None,
},
skill_id,
None,
)?;
let archive_path = downloader.download_with_sha256(
&crate::download::manager::DownloadRequest {
source_type: crate::dependency::types::DependencySourceType::GithubRelease,
source_locator: asset.download_url.clone(),
cache_key: managed_skill_cache_key(skill_id, asset.version.as_str()),
},
asset.sha256.as_deref().ok_or_else(|| {
format!(
"GitHub release '{}' does not expose one SHA-256 checksum for '{}'",
asset.tag_name, asset.asset_name
)
})?,
)?;
let install_temp_root = self.config.lifecycle_root.join("install_tmp").join(format!(
"{}-{}",
skill_id,
current_unix_millis()
));
if install_temp_root.exists() {
fs::remove_dir_all(&install_temp_root).map_err(|error| {
format!(
"Failed to remove stale temp install root {}: {}",
install_temp_root.display(),
error
)
})?;
}
fs::create_dir_all(&install_temp_root).map_err(|error| {
format!(
"Failed to create temp install root {}: {}",
install_temp_root.display(),
error
)
})?;
let mut install_temp_guard = TempDirGuard::new(install_temp_root.clone());
let extracted_skill_dir =
extract_skill_package_zip(&archive_path, &install_temp_root, skill_id)?;
let installed_meta = read_skill_manifest_from_directory(&extracted_skill_dir)?;
if installed_meta.effective_skill_id() != skill_id {
return Err(format!(
"downloaded skill package resolves to skill_id '{}' instead of '{}'",
installed_meta.effective_skill_id(),
skill_id
));
}
if installed_meta.version() != asset.version {
return Err(format!(
"downloaded skill package version '{}' does not match release version '{}'",
installed_meta.version(),
asset.version
));
}
let target_dir = self.skill_root().join(skill_id);
if target_dir.exists() {
return Err(format!(
"target skill directory {} already exists",
target_dir.display()
));
}
fs::rename(&extracted_skill_dir, &target_dir).map_err(|error| {
format!(
"Failed to move extracted skill {} into {}: {}",
extracted_skill_dir.display(),
target_dir.display(),
error
)
})?;
install_temp_guard.disarm();
let _ = fs::remove_dir_all(&install_temp_root);
let record = InstalledSkillRecord {
skill_id: skill_id.to_string(),
version: asset.version.clone(),
managed: true,
source: InstalledSkillSourceRecord {
source_type: SkillInstallSourceType::Github,
locator: repo.clone(),
tag: Some(asset.tag_name.clone()),
},
installed_at_unix_ms: current_unix_millis(),
};
Ok(PreparedSkillApply::Install(PreparedSkillInstall {
result: SkillApplyResult {
skill_id: skill_id.to_string(),
status: "installed".to_string(),
message: format!(
"skill '{}' version {} was installed from GitHub repository '{}'",
skill_id, asset.version, repo
),
version: Some(asset.version),
source_type: Some(SkillInstallSourceType::Github),
source_locator: Some(repo),
},
target_dir,
install_record: record,
}))
}
fn prepare_github_managed_skill_update(
&self,
skill_id: &str,
) -> Result<PreparedSkillApply, String> {
let record = self
.install_record(skill_id)?
.ok_or_else(|| {
format!(
"skill '{}' is not managed by the install workflow; automatic update is unavailable",
skill_id
)
})?;
if !record.managed {
return Err(format!(
"skill '{}' is not managed by the install workflow; automatic update is unavailable",
skill_id
));
}
if record.source.source_type != SkillInstallSourceType::Github {
return Err(format!(
"skill '{}' uses source type '{:?}', but update currently supports only github",
skill_id, record.source.source_type
));
}
let current_version = Version::parse(record.version.as_str()).map_err(|error| {
format!(
"installed version '{}' of skill '{}' is invalid: {}",
record.version, skill_id, error
)
})?;
let downloader = self.downloader();
let asset = downloader.resolve_github_managed_skill_release_asset(
&crate::skill::dependencies::GithubReleaseSourceSpec {
repo: record.source.locator.clone(),
tag_api: None,
},
skill_id,
None,
)?;
let latest_version = Version::parse(asset.version.as_str()).map_err(|error| {
format!(
"latest GitHub release version '{}' of skill '{}' is invalid: {}",
asset.version, skill_id, error
)
})?;
if latest_version <= current_version {
return Ok(PreparedSkillApply::Immediate(SkillApplyResult {
skill_id: skill_id.to_string(),
status: "up_to_date".to_string(),
message: format!(
"skill '{}' is already on version {}",
skill_id, record.version
),
version: Some(record.version),
source_type: Some(SkillInstallSourceType::Github),
source_locator: Some(record.source.locator),
}));
}
let archive_path = downloader.download_with_sha256(
&crate::download::manager::DownloadRequest {
source_type: crate::dependency::types::DependencySourceType::GithubRelease,
source_locator: asset.download_url.clone(),
cache_key: managed_skill_cache_key(skill_id, asset.version.as_str()),
},
asset.sha256.as_deref().ok_or_else(|| {
format!(
"GitHub release '{}' does not expose one SHA-256 checksum for '{}'",
asset.tag_name, asset.asset_name
)
})?,
)?;
let temp_root = self.config.lifecycle_root.join("update_tmp").join(format!(
"{}-{}",
skill_id,
current_unix_millis()
));
if temp_root.exists() {
fs::remove_dir_all(&temp_root).map_err(|error| {
format!(
"Failed to remove stale temp update root {}: {}",
temp_root.display(),
error
)
})?;
}
fs::create_dir_all(&temp_root).map_err(|error| {
format!(
"Failed to create temp update root {}: {}",
temp_root.display(),
error
)
})?;
let mut update_temp_guard = TempDirGuard::new(temp_root.clone());
let extracted_skill_dir = extract_skill_package_zip(&archive_path, &temp_root, skill_id)?;
let updated_meta = read_skill_manifest_from_directory(&extracted_skill_dir)?;
if updated_meta.version() != asset.version {
return Err(format!(
"downloaded update package version '{}' does not match release version '{}'",
updated_meta.version(),
asset.version
));
}
let target_dir = self.skill_root().join(skill_id);
if !target_dir.exists() {
return Err(format!(
"installed skill directory {} does not exist",
target_dir.display()
));
}
let backup_dir = self
.config
.lifecycle_root
.join("update_backup")
.join(format!("{}-{}", skill_id, current_unix_millis()));
if let Some(parent) = backup_dir.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create {}: {}", parent.display(), error))?;
}
fs::rename(&target_dir, &backup_dir).map_err(|error| {
format!(
"Failed to move current skill {} into backup {}: {}",
target_dir.display(),
backup_dir.display(),
error
)
})?;
if let Err(error) = fs::rename(&extracted_skill_dir, &target_dir) {
let _ = fs::rename(&backup_dir, &target_dir);
return Err(format!(
"Failed to move updated skill {} into {}: {}",
extracted_skill_dir.display(),
target_dir.display(),
error
));
}
update_temp_guard.disarm();
let _ = fs::remove_dir_all(&temp_root);
let updated_record = InstalledSkillRecord {
skill_id: skill_id.to_string(),
version: asset.version.clone(),
managed: true,
source: InstalledSkillSourceRecord {
source_type: SkillInstallSourceType::Github,
locator: record.source.locator.clone(),
tag: Some(asset.tag_name.clone()),
},
installed_at_unix_ms: current_unix_millis(),
};
Ok(PreparedSkillApply::Update(PreparedSkillUpdate {
result: SkillApplyResult {
skill_id: skill_id.to_string(),
status: "updated".to_string(),
message: format!(
"skill '{}' was updated from version {} to {}",
skill_id, record.version, asset.version
),
version: Some(asset.version),
source_type: Some(SkillInstallSourceType::Github),
source_locator: Some(record.source.locator.clone()),
},
target_dir,
backup_dir,
install_record: updated_record,
previous_install_record: record,
}))
}
pub fn skill_root(&self) -> &Path {
&self.config.skill_root.skills_dir
}
pub fn state_root(&self) -> &Path {
self.config.lifecycle_root.as_path()
}
fn install_record_root(&self) -> PathBuf {
self.config.lifecycle_root.join("installs")
}
fn disabled_root(&self) -> PathBuf {
self.config.lifecycle_root.join("skills").join("disabled")
}
fn disabled_record_path(&self, skill_id: &str) -> PathBuf {
self.disabled_root().join(format!("{}.json", skill_id))
}
fn install_record_path(&self, skill_id: &str) -> PathBuf {
self.install_record_root()
.join(format!("{}.yaml", skill_id))
}
pub fn install_record(&self, skill_id: &str) -> Result<Option<InstalledSkillRecord>, String> {
validate_luaskills_identifier(skill_id, "skill_id")?;
let path = self.install_record_path(skill_id);
if !path.exists() {
return Ok(None);
}
let yaml = fs::read_to_string(&path)
.map_err(|error| format!("Failed to read {}: {}", path.display(), error))?;
let record: InstalledSkillRecord = serde_yaml::from_str(&yaml)
.map_err(|error| format!("Failed to parse {}: {}", path.display(), error))?;
Ok(Some(record))
}
fn persist_install_record(&self, record: &InstalledSkillRecord) -> Result<(), String> {
self.ensure_state_layout()?;
let path = self.install_record_path(&record.skill_id);
let yaml = serde_yaml::to_string(record)
.map_err(|error| format!("Failed to serialize install record: {}", error))?;
fs::write(&path, yaml)
.map_err(|error| format!("Failed to write {}: {}", path.display(), error))
}
fn remove_install_record(&self, skill_id: &str) -> Result<bool, String> {
validate_luaskills_identifier(skill_id, "skill_id")?;
let path = self.install_record_path(skill_id);
if !path.exists() {
return Ok(false);
}
fs::remove_file(&path)
.map_err(|error| format!("Failed to remove {}: {}", path.display(), error))?;
Ok(true)
}
fn persist_disabled_record(&self, record: &DisabledSkillRecord) -> Result<(), String> {
self.ensure_state_layout()?;
let path = self.disabled_record_path(&record.skill_id);
let content = serde_json::to_string_pretty(record)
.map_err(|error| format!("Failed to serialize disabled record: {}", error))?;
fs::write(&path, content)
.map_err(|error| format!("Failed to write {}: {}", path.display(), error))
}
fn remove_disabled_record(&self, skill_id: &str) -> Result<bool, String> {
validate_luaskills_identifier(skill_id, "skill_id")?;
self.ensure_state_layout()?;
let path = self.disabled_record_path(skill_id);
if !path.exists() {
return Ok(false);
}
fs::remove_file(&path)
.map_err(|error| format!("Failed to remove {}: {}", path.display(), error))?;
Ok(true)
}
fn restore_disabled_record(
&self,
skill_id: &str,
record: Option<&DisabledSkillRecord>,
) -> Result<(), String> {
match record {
Some(record) => self.persist_disabled_record(record),
None => {
self.remove_disabled_record(skill_id)?;
Ok(())
}
}
}
fn restore_install_record(
&self,
skill_id: &str,
record: Option<&InstalledSkillRecord>,
) -> Result<(), String> {
match record {
Some(record) => self.persist_install_record(record),
None => {
self.remove_install_record(skill_id)?;
Ok(())
}
}
}
pub fn commit_prepared_skill_apply(
&self,
prepared: &PreparedSkillApply,
) -> Result<SkillApplyResult, String> {
match prepared {
PreparedSkillApply::Immediate(result) => Ok(result.clone()),
PreparedSkillApply::Install(prepared_install) => {
self.persist_install_record(&prepared_install.install_record)?;
Ok(prepared_install.result.clone())
}
PreparedSkillApply::Update(prepared_update) => {
self.persist_install_record(&prepared_update.install_record)?;
if prepared_update.backup_dir.exists() {
fs::remove_dir_all(&prepared_update.backup_dir).map_err(|error| {
let restore_error =
self.persist_install_record(&prepared_update.previous_install_record);
match restore_error {
Ok(()) => format!(
"Failed to remove update backup {}: previous install record was restored: {}",
prepared_update.backup_dir.display(),
error
),
Err(restore_error) => format!(
"Failed to remove update backup {}: {}. Failed to restore previous install record: {}",
prepared_update.backup_dir.display(),
error,
restore_error
),
}
})?;
}
Ok(prepared_update.result.clone())
}
}
}
pub fn rollback_prepared_skill_apply(
&self,
prepared: &PreparedSkillApply,
) -> Result<(), String> {
match prepared {
PreparedSkillApply::Immediate(_) => Ok(()),
PreparedSkillApply::Install(prepared_install) => {
if prepared_install.target_dir.exists() {
fs::remove_dir_all(&prepared_install.target_dir).map_err(|error| {
format!(
"Failed to roll back installed skill directory {}: {}",
prepared_install.target_dir.display(),
error
)
})?;
}
Ok(())
}
PreparedSkillApply::Update(prepared_update) => {
if prepared_update.target_dir.exists() {
fs::remove_dir_all(&prepared_update.target_dir).map_err(|error| {
format!(
"Failed to remove staged updated skill directory {}: {}",
prepared_update.target_dir.display(),
error
)
})?;
}
if prepared_update.backup_dir.exists() {
fs::rename(&prepared_update.backup_dir, &prepared_update.target_dir).map_err(
|error| {
format!(
"Failed to restore backup {} into {}: {}",
prepared_update.backup_dir.display(),
prepared_update.target_dir.display(),
error
)
},
)?;
}
Ok(())
}
}
}
pub fn commit_prepared_skill_uninstall(
&self,
prepared: &PreparedSkillUninstall,
) -> Result<SkillUninstallResult, String> {
if prepared.previous_disabled_record.is_some() {
self.remove_disabled_record(&prepared.result.skill_id)?;
}
if prepared.previous_install_record.is_some() {
self.remove_install_record(&prepared.result.skill_id)?;
}
if let Some(backup_dir) = &prepared.backup_dir {
fs::remove_dir_all(backup_dir).map_err(|error| {
let disabled_restore_error = self.restore_disabled_record(
&prepared.result.skill_id,
prepared.previous_disabled_record.as_ref(),
);
let install_restore_error = self.restore_install_record(
&prepared.result.skill_id,
prepared.previous_install_record.as_ref(),
);
let mut message = format!(
"Failed to remove uninstall backup {}: {}",
backup_dir.display(),
error
);
if let Err(restore_error) = disabled_restore_error {
message.push_str(&format!(
". Failed to restore previous disabled record: {}",
restore_error
));
}
if let Err(restore_error) = install_restore_error {
message.push_str(&format!(
". Failed to restore previous install record: {}",
restore_error
));
}
message
})?;
}
Ok(prepared.result.clone())
}
pub fn rollback_prepared_skill_uninstall(
&self,
prepared: &PreparedSkillUninstall,
) -> Result<(), String> {
if let Some(backup_dir) = &prepared.backup_dir {
if prepared.target_dir.exists() {
fs::remove_dir_all(&prepared.target_dir).map_err(|error| {
format!(
"Failed to remove staged uninstall target directory {}: {}",
prepared.target_dir.display(),
error
)
})?;
}
if backup_dir.exists() {
fs::rename(backup_dir, &prepared.target_dir).map_err(|error| {
format!(
"Failed to restore uninstall backup {} into {}: {}",
backup_dir.display(),
prepared.target_dir.display(),
error
)
})?;
}
}
self.restore_disabled_record(
&prepared.result.skill_id,
prepared.previous_disabled_record.as_ref(),
)?;
self.restore_install_record(
&prepared.result.skill_id,
prepared.previous_install_record.as_ref(),
)?;
Ok(())
}
fn downloader(&self) -> DownloadManager {
DownloadManager::new(DownloadManagerConfig {
cache_root: self.config.download_cache_root.clone(),
allow_network_download: self.config.allow_network_download,
github_base_url: self.config.github_base_url.clone(),
github_api_base_url: self.config.github_api_base_url.clone(),
})
}
}
fn is_root_skill_layer(root: &RuntimeSkillRoot) -> bool {
root.name.trim().eq_ignore_ascii_case("ROOT")
}
pub(crate) fn resolve_requested_skill_id(request: &SkillInstallRequest) -> Result<String, String> {
let explicit_skill_id = request
.skill_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let derived_skill_id = match request.source_type {
SkillInstallSourceType::Github => request
.source
.as_deref()
.map(normalize_github_repo_locator)
.transpose()?
.map(|repo| github_repo_skill_id(&repo))
.transpose()?,
SkillInstallSourceType::Url => None,
};
let skill_id = explicit_skill_id.or(derived_skill_id).ok_or_else(|| {
"install/update request requires skill_id or one source that can derive it".to_string()
})?;
validate_luaskills_identifier(&skill_id, "skill_id")?;
Ok(skill_id)
}
fn normalize_github_repo_locator(source: &str) -> Result<String, String> {
let normalized = source
.trim()
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/")
.trim_matches('/')
.to_string();
let mut segments = normalized.split('/');
let owner = segments.next().unwrap_or_default().trim();
let repo = segments.next().unwrap_or_default().trim();
if owner.is_empty() || repo.is_empty() || segments.next().is_some() {
return Err(format!(
"github source '{}' must be one repository locator in owner/repo form",
source
));
}
Ok(format!("{}/{}", owner, repo))
}
fn github_repo_skill_id(repo: &str) -> Result<String, String> {
let skill_id = repo
.rsplit('/')
.next()
.unwrap_or_default()
.trim()
.to_string();
validate_luaskills_identifier(&skill_id, "derived github skill_id")?;
Ok(skill_id)
}
fn managed_skill_cache_key(skill_id: &str, version: &str) -> String {
format!("skill-{}-{}", skill_id, version)
}
fn current_unix_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}
fn read_skill_manifest_from_directory(skill_dir: &Path) -> Result<SkillMeta, String> {
let skill_id = skill_dir
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| {
format!(
"Failed to resolve skill id from directory {}",
skill_dir.display()
)
})?
.trim()
.to_string();
validate_luaskills_identifier(&skill_id, "skill_id")?;
let skill_yaml_path = skill_dir.join("skill.yaml");
let yaml_text = fs::read_to_string(&skill_yaml_path)
.map_err(|error| format!("Failed to read {}: {}", skill_yaml_path.display(), error))?;
let yaml_value: serde_yaml::Value = serde_yaml::from_str(&yaml_text)
.map_err(|error| format!("Failed to parse {}: {}", skill_yaml_path.display(), error))?;
if yaml_value
.as_mapping()
.and_then(|mapping| mapping.get(serde_yaml::Value::String("skill_id".to_string())))
.is_some()
{
return Err(format!(
"skill {} must not declare skill_id in skill.yaml; directory name is the only skill_id",
skill_dir.display()
));
}
let mut meta: SkillMeta = serde_yaml::from_value(yaml_value)
.map_err(|error| format!("Failed to decode {}: {}", skill_yaml_path.display(), error))?;
meta.bind_directory_skill_id(skill_id.clone());
validate_luaskills_version(meta.version(), "skill.yaml version")?;
if meta.effective_skill_id() != skill_id {
return Err(format!(
"skill manifest in {} resolved to skill_id '{}' instead of '{}'",
skill_yaml_path.display(),
meta.effective_skill_id(),
skill_id
));
}
Ok(meta)
}
pub fn collect_effective_skill_instances(
base_dir: &Path,
override_dir: Option<&Path>,
) -> Result<Vec<ResolvedSkillInstance>, String> {
let mut roots = vec![RuntimeSkillRoot {
name: "ROOT".to_string(),
skills_dir: base_dir.to_path_buf(),
}];
if let Some(override_dir) = override_dir {
roots.push(RuntimeSkillRoot {
name: "PROJECT".to_string(),
skills_dir: override_dir.to_path_buf(),
});
}
collect_effective_skill_instances_from_roots(&roots)
}
pub fn collect_effective_skill_instances_from_roots(
roots: &[RuntimeSkillRoot],
) -> Result<Vec<ResolvedSkillInstance>, String> {
let mut all_skill_ids = BTreeSet::new();
let mut root_maps = Vec::new();
for root in roots {
let root_map = collect_named_skill_dirs(&root.skills_dir)?;
all_skill_ids.extend(root_map.keys().cloned());
root_maps.push((root.clone(), root_map));
}
let mut resolved = Vec::new();
for skill_id in all_skill_ids {
for (root, root_map) in &root_maps {
let Some(skill_dir) = root_map.get(&skill_id) else {
continue;
};
if is_effective_disable_override(skill_dir)? {
break;
}
if !is_skill_manifest_enabled(skill_dir)? {
break;
}
resolved.push(ResolvedSkillInstance {
skill_id: skill_id.clone(),
root_name: root.name.clone(),
skills_root: root.skills_dir.clone(),
actual_dir: skill_dir.clone(),
});
break;
}
}
Ok(resolved)
}
pub fn resolve_effective_skill_instance(
base_dir: &Path,
override_dir: Option<&Path>,
skill_id: &str,
) -> Result<Option<ResolvedSkillInstance>, String> {
validate_luaskills_identifier(skill_id, "skill_id")?;
Ok(collect_effective_skill_instances(base_dir, override_dir)?
.into_iter()
.find(|instance| instance.skill_id == skill_id))
}
pub fn resolve_effective_skill_instance_from_roots(
roots: &[RuntimeSkillRoot],
skill_id: &str,
) -> Result<Option<ResolvedSkillInstance>, String> {
validate_luaskills_identifier(skill_id, "skill_id")?;
Ok(collect_effective_skill_instances_from_roots(roots)?
.into_iter()
.find(|instance| instance.skill_id == skill_id))
}
pub fn resolve_declared_skill_instance_from_roots(
roots: &[RuntimeSkillRoot],
skill_id: &str,
) -> Result<Option<ResolvedSkillInstance>, String> {
validate_luaskills_identifier(skill_id, "skill_id")?;
for root in roots {
let root_map = collect_named_skill_dirs(&root.skills_dir)?;
if let Some(actual_dir) = root_map.get(skill_id) {
return Ok(Some(ResolvedSkillInstance {
skill_id: skill_id.to_string(),
root_name: root.name.clone(),
skills_root: root.skills_dir.clone(),
actual_dir: actual_dir.clone(),
}));
}
}
Ok(None)
}
fn collect_named_skill_dirs(
root: &Path,
) -> Result<std::collections::BTreeMap<String, PathBuf>, String> {
let mut output = std::collections::BTreeMap::new();
if !root.exists() {
return Ok(output);
}
for entry in fs::read_dir(root)
.map_err(|error| format!("Failed to read {}: {}", root.display(), error))?
{
let entry = entry.map_err(|error| format!("Failed to read skill entry: {}", error))?;
let file_type = entry
.file_type()
.map_err(|error| format!("Failed to inspect skill entry type: {}", error))?;
if !file_type.is_dir() {
continue;
}
let skill_id = match entry.file_name().to_str() {
Some(value) => value.to_string(),
None => continue,
};
if validate_luaskills_identifier(&skill_id, "skill_id").is_err() {
continue;
}
output.insert(skill_id, entry.path());
}
Ok(output)
}
fn is_effective_disable_override(skill_dir: &Path) -> Result<bool, String> {
Ok(fs::read_dir(skill_dir)
.map_err(|error| {
format!(
"Failed to read override dir {}: {}",
skill_dir.display(),
error
)
})?
.next()
.is_none())
}
fn is_skill_manifest_enabled(skill_dir: &Path) -> Result<bool, String> {
let skill_yaml = skill_dir.join("skill.yaml");
if !skill_yaml.exists() {
return Ok(true);
}
let yaml_text = fs::read_to_string(&skill_yaml)
.map_err(|error| format!("Failed to read {}: {}", skill_yaml.display(), error))?;
let yaml_value: serde_yaml::Value = serde_yaml::from_str(&yaml_text)
.map_err(|error| format!("Failed to parse {}: {}", skill_yaml.display(), error))?;
if yaml_value.as_mapping().is_some_and(|mapping| {
mapping.contains_key(serde_yaml::Value::String("skill_id".to_string()))
}) {
return Err(format!(
"skill manifest {} must not declare skill_id; directory name is the only skill_id",
skill_yaml.display()
));
}
#[derive(Debug, Deserialize)]
struct SkillEnableProbe {
#[serde(default = "default_skill_enable")]
enable: bool,
}
fn default_skill_enable() -> bool {
true
}
let probe: SkillEnableProbe = serde_yaml::from_value(yaml_value)
.map_err(|error| format!("Failed to parse {}: {}", skill_yaml.display(), error))?;
Ok(probe.enable)
}
#[cfg(test)]
mod tests {
use super::{
SkillInstallRequest, SkillInstallSourceType, SkillManager, SkillManagerConfig,
SkillOperationPlane, TempDirGuard, collect_effective_skill_instances,
resolve_effective_skill_instance,
};
use crate::runtime_options::RuntimeSkillRoot;
fn test_manager_config(
temp_root: &std::path::Path,
skill_root: RuntimeSkillRoot,
) -> SkillManagerConfig {
SkillManagerConfig {
skill_root,
lifecycle_root: temp_root.join("state"),
download_cache_root: temp_root.join("downloads"),
allow_network_download: false,
github_base_url: None,
github_api_base_url: None,
}
}
#[test]
fn temp_dir_guard_removes_staging_root_on_drop() {
let temp_root =
std::env::temp_dir().join(format!("luaskills_temp_guard_test_{}", std::process::id()));
if temp_root.exists() {
let _ = std::fs::remove_dir_all(&temp_root);
}
std::fs::create_dir_all(&temp_root).expect("temp root should be created");
{
let _guard = TempDirGuard::new(temp_root.clone());
std::fs::write(temp_root.join("staged.txt"), "staged")
.expect("staged marker should be written");
}
assert!(!temp_root.exists());
}
#[test]
fn skill_manager_persists_disabled_state() {
let temp_root = std::env::temp_dir().join(format!(
"luaskills_skill_manager_test_{}",
std::process::id()
));
if temp_root.exists() {
let _ = std::fs::remove_dir_all(&temp_root);
}
let skill_root = temp_root.join("skills");
let manager = SkillManager::new(SkillManagerConfig {
..test_manager_config(
&temp_root,
RuntimeSkillRoot {
name: "USER".to_string(),
skills_dir: skill_root,
},
)
});
assert!(manager.is_skill_enabled("vulcan-codekit").unwrap());
manager
.disable_skill("vulcan-codekit", Some("manual test"))
.expect("disable should succeed");
assert!(!manager.is_skill_enabled("vulcan-codekit").unwrap());
assert_eq!(
manager
.disabled_record("vulcan-codekit")
.unwrap()
.expect("record should exist")
.reason
.as_deref(),
Some("manual test")
);
manager
.enable_skill("vulcan-codekit")
.expect("enable should succeed");
assert!(manager.is_skill_enabled("vulcan-codekit").unwrap());
let _ = std::fs::remove_dir_all(&temp_root);
}
#[test]
fn install_update_entrypoints_return_strict_structured_results() {
let temp_root = std::env::temp_dir().join(format!(
"luaskills_install_update_test_{}",
std::process::id()
));
let skill_root = temp_root.join("skills");
let skill_roots = vec![RuntimeSkillRoot {
name: "USER".to_string(),
skills_dir: skill_root.clone(),
}];
let _ = std::fs::create_dir_all(&skill_root);
let manager = SkillManager::new(test_manager_config(&temp_root, skill_roots[0].clone()));
let install_result = manager
.prepare_install_skill(
SkillOperationPlane::Skills,
&skill_roots,
&SkillInstallRequest {
skill_id: Some("vulcan-codekit".to_string()),
source: None,
source_type: SkillInstallSourceType::Github,
},
)
.expect_err("install without source should fail strictly");
assert!(install_result.contains("github install requires source repository"));
let _ = std::fs::create_dir_all(skill_root.join("vulcan-codekit"));
let update_result = manager
.prepare_update_skill(
SkillOperationPlane::Skills,
&skill_roots,
&SkillInstallRequest {
skill_id: Some("vulcan-codekit".to_string()),
source: None,
source_type: SkillInstallSourceType::Github,
},
)
.expect_err("update without install record should fail strictly");
assert!(update_result.contains("is not managed by the install workflow"));
let _ = std::fs::remove_dir_all(&temp_root);
}
#[test]
fn uninstall_returns_safe_default_database_flags() {
let temp_root = std::env::temp_dir().join(format!(
"luaskills_uninstall_result_test_{}",
std::process::id()
));
if temp_root.exists() {
let _ = std::fs::remove_dir_all(&temp_root);
}
let skill_root = temp_root.join("skills");
let manager = SkillManager::new(test_manager_config(
&temp_root,
RuntimeSkillRoot {
name: "USER".to_string(),
skills_dir: skill_root.clone(),
},
));
let _ = std::fs::create_dir_all(skill_root.join("vulcan-codekit"));
let result = manager
.uninstall_skill("vulcan-codekit")
.expect("uninstall should succeed");
assert!(result.skill_removed);
assert!(!result.sqlite_removed);
assert!(!result.lancedb_removed);
assert!(!skill_root.join("vulcan-codekit").exists());
let _ = std::fs::remove_dir_all(&temp_root);
}
#[test]
fn collect_effective_skill_instances_keeps_root_priority_over_project() {
let temp_root = std::env::temp_dir().join(format!(
"luaskills_collect_effective_instances_test_{}",
std::process::id()
));
if temp_root.exists() {
let _ = std::fs::remove_dir_all(&temp_root);
}
let base_dir = temp_root.join("base");
let override_dir = temp_root.join("override");
let _ = std::fs::create_dir_all(base_dir.join("vulcan-codekit"));
let _ = std::fs::create_dir_all(override_dir.join("vulcan-codekit"));
let _ = std::fs::create_dir_all(override_dir.join("vulcan-runtime"));
let _ = std::fs::write(
base_dir.join("vulcan-codekit").join("skill.yaml"),
"name: vulcan-codekit\nversion: 0.1.0\n",
);
let _ = std::fs::write(
override_dir.join("vulcan-codekit").join("skill.yaml"),
"name: vulcan-codekit\nversion: 0.2.0\n",
);
let _ = std::fs::write(
override_dir.join("vulcan-runtime").join("skill.yaml"),
"name: vulcan-runtime\nversion: 0.1.0\n",
);
let resolved = collect_effective_skill_instances(&base_dir, Some(&override_dir))
.expect("effective skill collection should succeed");
assert_eq!(resolved.len(), 2);
let codekit = resolved
.iter()
.find(|value| value.skill_id == "vulcan-codekit")
.expect("vulcan-codekit should exist");
assert!(codekit.actual_dir.starts_with(&base_dir));
let runtime = resolved
.iter()
.find(|value| value.skill_id == "vulcan-runtime")
.expect("project-only vulcan-runtime should exist");
assert!(runtime.actual_dir.starts_with(&override_dir));
let _ = std::fs::remove_dir_all(&temp_root);
}
#[test]
fn resolve_effective_skill_instance_prefers_root_directory() {
let temp_root = std::env::temp_dir().join(format!(
"luaskills_resolve_effective_instance_test_{}",
std::process::id()
));
if temp_root.exists() {
let _ = std::fs::remove_dir_all(&temp_root);
}
let base_dir = temp_root.join("base");
let override_dir = temp_root.join("override");
let _ = std::fs::create_dir_all(base_dir.join("vulcan-codekit"));
let _ = std::fs::create_dir_all(override_dir.join("vulcan-codekit"));
let _ = std::fs::write(
base_dir.join("vulcan-codekit").join("skill.yaml"),
"name: vulcan-codekit\nversion: 0.1.0\n",
);
let _ = std::fs::write(
override_dir.join("vulcan-codekit").join("skill.yaml"),
"name: vulcan-codekit\nversion: 0.2.0\n",
);
let resolved =
resolve_effective_skill_instance(&base_dir, Some(&override_dir), "vulcan-codekit")
.expect("resolution should succeed")
.expect("instance should exist");
assert!(resolved.actual_dir.starts_with(&base_dir));
let _ = std::fs::remove_dir_all(&temp_root);
}
}