use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use thiserror::Error;
pub fn default_cache_skills_dir() -> PathBuf {
dirs::home_dir().map_or_else(
|| PathBuf::from("/tmp/deepseek/cache/skills"),
|_| zagens_config::user_data_path_or_relative("cache/skills"),
)
}
pub const DEFAULT_REGISTRY_URL: &str =
"https://raw.githubusercontent.com/Hmbown/deepseek-skills/main/index.json";
pub const DEFAULT_MAX_SIZE_BYTES: u64 = 5 * 1024 * 1024;
pub const INSTALLED_FROM_MARKER: &str = ".installed-from";
pub const TRUSTED_MARKER: &str = ".trusted";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallSource {
GitHubRepo(String),
DirectUrl(String),
Registry(String),
}
impl InstallSource {
pub fn parse(spec: &str) -> Result<Self> {
let trimmed = spec.trim();
if trimmed.is_empty() {
bail!("install source must not be empty");
}
if let Some(rest) = trimmed.strip_prefix("github:") {
let rest = rest.trim();
let (owner, repo) = rest.split_once('/').with_context(|| {
format!("github source must be 'github:owner/repo' (got {spec})")
})?;
let owner = owner.trim();
let repo = repo.trim().trim_end_matches('/');
if owner.is_empty() || repo.is_empty() {
bail!("github source must be 'github:owner/repo' (got {spec})");
}
if owner.contains('/') || repo.contains('/') {
bail!("github source must be 'github:owner/repo' (got {spec})");
}
return Ok(Self::GitHubRepo(format!("{owner}/{repo}")));
}
if trimmed.starts_with("https://") || trimmed.starts_with("http://") {
if let Some(repo) = parse_github_browser_url(trimmed) {
return Ok(Self::GitHubRepo(repo));
}
return Ok(Self::DirectUrl(trimmed.to_string()));
}
Ok(Self::Registry(trimmed.to_string()))
}
}
pub(super) fn parse_github_browser_url(url: &str) -> Option<String> {
let after_scheme = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))?;
let (host, rest) = after_scheme.split_once('/')?;
if !host.eq_ignore_ascii_case("github.com") && !host.eq_ignore_ascii_case("www.github.com") {
return None;
}
let trimmed = rest.trim_end_matches('/');
let mut parts = trimmed.splitn(3, '/');
let owner = parts.next()?.trim();
let repo = parts.next()?.trim().trim_end_matches(".git");
if owner.is_empty() || repo.is_empty() {
return None;
}
if parts.next().is_some() {
return None;
}
Some(format!("{owner}/{repo}"))
}
#[derive(Debug)]
pub enum InstallOutcome {
Installed(InstalledSkill),
NeedsApproval(String),
NetworkDenied(String),
}
#[derive(Debug, Clone)]
pub struct InstalledSkill {
pub name: String,
pub path: PathBuf,
#[allow(dead_code)]
pub source_checksum: String,
}
#[derive(Debug)]
pub enum UpdateResult {
NoChange,
Updated(InstalledSkill),
NeedsApproval(String),
NetworkDenied(String),
}
#[derive(Debug, Error)]
pub enum InstallError {
#[error("entry escapes destination directory: {0}")]
PathTraversal(String),
#[error("entry is too large; uncompressed total would exceed {limit} bytes")]
OversizedTarball { limit: u64 },
#[error("missing SKILL.md in archive")]
MissingSkillMd,
#[error("SKILL.md frontmatter missing required field: {0}")]
MissingFrontmatterField(&'static str),
#[error("symlinks are not allowed in skill tarballs")]
SymlinkRejected,
#[error("skill '{0}' is already installed; use update or remove it first")]
AlreadyInstalled(String),
#[error("skill '{0}' was not installed via /skill install (no .installed-from marker)")]
NotInstalledHere(String),
}
#[derive(Debug)]
pub enum SkillSyncOutcome {
Downloaded { name: String, path: PathBuf },
Fresh { name: String },
Failed { name: String, reason: String },
Denied { name: String, host: String },
NeedsApproval { name: String, host: String },
}
#[derive(Debug)]
pub enum SyncResult {
Done { outcomes: Vec<SkillSyncOutcome> },
RegistryDenied(String),
RegistryNeedsApproval(String),
}
#[derive(Debug, Clone, Deserialize)]
pub struct RegistryDocument {
#[serde(default)]
pub skills: std::collections::BTreeMap<String, RegistryEntry>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RegistryEntry {
pub source: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug)]
pub enum RegistryFetchResult {
Loaded(RegistryDocument),
NeedsApproval(String),
Denied(String),
}
pub(super) enum UrlResolution {
Resolved(Vec<String>),
NeedsApproval(String),
Denied(String),
}
pub(super) enum DownloadOutcome {
Bytes { bytes: Vec<u8>, url: String },
NeedsApproval(String),
Denied(String),
}
pub(super) enum DownloadAttempt {
Bytes(Vec<u8>),
NotFound(reqwest::StatusCode),
}