Skip to main content

zagens_runtime/skills/install/
types.rs

1use std::path::PathBuf;
2
3use anyhow::{Context, Result, bail};
4use serde::Deserialize;
5use thiserror::Error;
6
7pub fn default_cache_skills_dir() -> PathBuf {
8    dirs::home_dir().map_or_else(
9        || PathBuf::from("/tmp/deepseek/cache/skills"),
10        |_| zagens_config::user_data_path_or_relative("cache/skills"),
11    )
12}
13
14/// Default registry. Falls back to a community-curated `index.json` hosted on
15/// GitHub raw; users can override via `[skills] registry_url` in config.toml.
16pub const DEFAULT_REGISTRY_URL: &str =
17    "https://raw.githubusercontent.com/Hmbown/deepseek-skills/main/index.json";
18
19/// Default per-skill size cap (5 MiB). Honored at unpack time so a malicious
20/// gzip bomb can't blow up RAM.
21pub const DEFAULT_MAX_SIZE_BYTES: u64 = 5 * 1024 * 1024;
22
23/// File written under each installed skill so [`update`] / [`uninstall`] can
24/// recover the original [`InstallSource`] without re-parsing user input.
25pub const INSTALLED_FROM_MARKER: &str = ".installed-from";
26
27/// File written under each trusted skill. Currently advisory (the install path
28/// never auto-runs anything) — the runtime tool-invocation gate consults this
29/// marker before executing scripts that ship with the skill.
30pub const TRUSTED_MARKER: &str = ".trusted";
31
32// ─────────────────────────────────────────────────────────────────────────────
33// Source parsing
34// ─────────────────────────────────────────────────────────────────────────────
35
36/// Where a skill is being installed from. See [`InstallSource::parse`] for the
37/// accepted spec syntax.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum InstallSource {
40    /// `github:owner/repo`. Resolved to
41    /// `https://github.com/<owner>/<repo>/archive/refs/heads/main.tar.gz`
42    /// with a `master.tar.gz` fallback on 404.
43    GitHubRepo(String),
44    /// Raw `http(s)://…` tarball URL. Used as-is.
45    DirectUrl(String),
46    /// Curated registry lookup key. Looked up via the configured `registry_url`.
47    Registry(String),
48}
49
50impl InstallSource {
51    /// Parse a user-supplied spec. Empty / whitespace-only input is rejected.
52    ///
53    /// * `github:owner/repo` → [`InstallSource::GitHubRepo`]
54    /// * `https://github.com/owner/repo[.git]` (no path past the repo) →
55    ///   [`InstallSource::GitHubRepo`]
56    /// * any other `http://` or `https://` prefix → [`InstallSource::DirectUrl`]
57    /// * anything else → [`InstallSource::Registry`]
58    pub fn parse(spec: &str) -> Result<Self> {
59        let trimmed = spec.trim();
60        if trimmed.is_empty() {
61            bail!("install source must not be empty");
62        }
63        if let Some(rest) = trimmed.strip_prefix("github:") {
64            let rest = rest.trim();
65            // Reject obviously bogus values up front. We intentionally accept
66            // case-insensitive owner/repo so `github:Hmbown/Foo` works.
67            let (owner, repo) = rest.split_once('/').with_context(|| {
68                format!("github source must be 'github:owner/repo' (got {spec})")
69            })?;
70            let owner = owner.trim();
71            let repo = repo.trim().trim_end_matches('/');
72            if owner.is_empty() || repo.is_empty() {
73                bail!("github source must be 'github:owner/repo' (got {spec})");
74            }
75            if owner.contains('/') || repo.contains('/') {
76                bail!("github source must be 'github:owner/repo' (got {spec})");
77            }
78            return Ok(Self::GitHubRepo(format!("{owner}/{repo}")));
79        }
80        if trimmed.starts_with("https://") || trimmed.starts_with("http://") {
81            if let Some(repo) = parse_github_browser_url(trimmed) {
82                return Ok(Self::GitHubRepo(repo));
83            }
84            return Ok(Self::DirectUrl(trimmed.to_string()));
85        }
86        Ok(Self::Registry(trimmed.to_string()))
87    }
88}
89
90/// Detect bare `https://github.com/<owner>/<repo>` URLs (with or without a
91/// trailing `.git`) and return `owner/repo`. Returns `None` for any URL that
92/// already points at a specific archive / blob / tree path — those are real
93/// direct URLs and the caller fetches them as-is.
94pub(super) fn parse_github_browser_url(url: &str) -> Option<String> {
95    let after_scheme = url
96        .strip_prefix("https://")
97        .or_else(|| url.strip_prefix("http://"))?;
98    let (host, rest) = after_scheme.split_once('/')?;
99    if !host.eq_ignore_ascii_case("github.com") && !host.eq_ignore_ascii_case("www.github.com") {
100        return None;
101    }
102    let trimmed = rest.trim_end_matches('/');
103    let mut parts = trimmed.splitn(3, '/');
104    let owner = parts.next()?.trim();
105    let repo = parts.next()?.trim().trim_end_matches(".git");
106    if owner.is_empty() || repo.is_empty() {
107        return None;
108    }
109    // If there is a third segment, the URL points at a sub-resource
110    // (`/archive/...`, `/blob/...`, `/tree/...`). Treat that as a real direct
111    // URL — the user explicitly wants whatever lives at that path.
112    if parts.next().is_some() {
113        return None;
114    }
115    Some(format!("{owner}/{repo}"))
116}
117/// Outcome of an install attempt.
118#[derive(Debug)]
119pub enum InstallOutcome {
120    /// The skill was installed (or already present and idempotent).
121    Installed(InstalledSkill),
122    /// The host requires user approval before the install can proceed. The
123    /// caller should surface this through whatever approval pathway it has and
124    /// retry once approved (typically by adding the host to the policy's
125    /// allow list).
126    NeedsApproval(String),
127    /// The host is denied by network policy. The install is aborted.
128    NetworkDenied(String),
129}
130
131/// Metadata for a successfully installed skill.
132#[derive(Debug, Clone)]
133pub struct InstalledSkill {
134    /// Skill name (taken from SKILL.md frontmatter).
135    pub name: String,
136    /// Final on-disk path: `<skills_dir>/<name>/`.
137    pub path: PathBuf,
138    /// SHA-256 over the downloaded tarball bytes. Used by [`update`] to detect
139    /// upstream changes without re-extracting; also surfaced for telemetry /
140    /// future signature-verification work.
141    #[allow(dead_code)]
142    pub source_checksum: String,
143}
144
145/// Result of an [`update`] call.
146#[derive(Debug)]
147pub enum UpdateResult {
148    /// Upstream tarball is byte-identical to the on-disk checksum; no action.
149    NoChange,
150    /// Upstream changed and the on-disk install was atomically replaced.
151    Updated(InstalledSkill),
152    /// Network policy short-circuited the update. Same semantics as
153    /// [`InstallOutcome::NeedsApproval`].
154    NeedsApproval(String),
155    /// Network policy denied the update.
156    NetworkDenied(String),
157}
158
159/// Errors that can happen during install. Most variants are flattened into
160/// `anyhow::Error` at the public boundary; this enum is used internally so
161/// tests can pattern-match without parsing strings.
162#[derive(Debug, Error)]
163pub enum InstallError {
164    #[error("entry escapes destination directory: {0}")]
165    PathTraversal(String),
166    #[error("entry is too large; uncompressed total would exceed {limit} bytes")]
167    OversizedTarball { limit: u64 },
168    #[error("missing SKILL.md in archive")]
169    MissingSkillMd,
170    #[error("SKILL.md frontmatter missing required field: {0}")]
171    MissingFrontmatterField(&'static str),
172    #[error("symlinks are not allowed in skill tarballs")]
173    SymlinkRejected,
174    #[error("skill '{0}' is already installed; use update or remove it first")]
175    AlreadyInstalled(String),
176    #[error("skill '{0}' was not installed via /skill install (no .installed-from marker)")]
177    NotInstalledHere(String),
178}
179#[derive(Debug)]
180pub enum SkillSyncOutcome {
181    /// Skill downloaded and written to the cache directory.
182    Downloaded { name: String, path: PathBuf },
183    /// Cached bytes match the upstream ETag / SHA-256; nothing written.
184    Fresh { name: String },
185    /// Skill download failed; the error is non-fatal so the sync continues.
186    Failed { name: String, reason: String },
187    /// Network policy blocked the download host.
188    Denied { name: String, host: String },
189    /// Network policy requires user approval for the download host.
190    NeedsApproval { name: String, host: String },
191}
192
193/// Overall result of [`sync_registry`].
194#[derive(Debug)]
195pub enum SyncResult {
196    /// Sync completed. `outcomes` contains one entry per skill in the index.
197    Done { outcomes: Vec<SkillSyncOutcome> },
198    /// The registry fetch was blocked by network policy.
199    RegistryDenied(String),
200    /// The registry fetch requires user approval.
201    RegistryNeedsApproval(String),
202}
203/// Curated-registry document. The shape is intentionally minimal so adding
204/// optional metadata later (homepage, version, signature) is forward-compatible.
205#[derive(Debug, Clone, Deserialize)]
206pub struct RegistryDocument {
207    /// Map of skill name → entry.
208    #[serde(default)]
209    pub skills: std::collections::BTreeMap<String, RegistryEntry>,
210}
211
212/// One row in the curated registry. `description` is optional so old indices
213/// keep parsing.
214#[derive(Debug, Clone, Deserialize)]
215pub struct RegistryEntry {
216    /// Source spec (e.g. `github:owner/repo`).
217    pub source: String,
218    /// Optional human-readable description.
219    #[serde(default)]
220    pub description: Option<String>,
221}
222
223/// Successful registry fetch result. Same shape as [`InstallOutcome`] for the
224/// network-policy outcomes so the caller can drop directly into approval flow.
225#[derive(Debug)]
226pub enum RegistryFetchResult {
227    Loaded(RegistryDocument),
228    NeedsApproval(String),
229    Denied(String),
230}
231
232pub(super) enum UrlResolution {
233    Resolved(Vec<String>),
234    NeedsApproval(String),
235    Denied(String),
236}
237
238pub(super) enum DownloadOutcome {
239    Bytes { bytes: Vec<u8>, url: String },
240    NeedsApproval(String),
241    Denied(String),
242}
243pub(super) enum DownloadAttempt {
244    Bytes(Vec<u8>),
245    NotFound(reqwest::StatusCode),
246}