Merge branch 'feat/v070-skill-install' (#140 skill install command)

This commit is contained in:
Hunter Bown
2026-04-28 00:46:46 -05:00
11 changed files with 2065 additions and 13 deletions
Generated
+46 -1
View File
@@ -1171,6 +1171,7 @@ dependencies = [
"deepseek-secrets",
"dirs",
"dotenvy",
"flate2",
"futures-util",
"ignore",
"image",
@@ -1186,9 +1187,11 @@ dependencies = [
"rustyline 15.0.0",
"serde",
"serde_json",
"sha2",
"shellexpand",
"shlex",
"starlark",
"tar",
"tempfile",
"thiserror 2.0.17",
"tiny_http",
@@ -1597,6 +1600,17 @@ dependencies = [
"winapi",
]
[[package]]
name = "filetime"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
dependencies = [
"cfg-if",
"libc",
"libredox",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.7"
@@ -2484,6 +2498,7 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags 2.10.0",
"libc",
"redox_syscall 0.7.4",
]
[[package]]
@@ -3063,7 +3078,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"redox_syscall 0.5.18",
"smallvec",
"windows-link 0.2.1",
]
@@ -3458,6 +3473,15 @@ dependencies = [
"bitflags 2.10.0",
]
[[package]]
name = "redox_syscall"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
dependencies = [
"bitflags 2.10.0",
]
[[package]]
name = "redox_users"
version = "0.4.6"
@@ -4335,6 +4359,17 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "tar"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "tempfile"
version = "3.24.0"
@@ -5487,6 +5522,16 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.3",
]
[[package]]
name = "xdg-home"
version = "1.3.0"
+17
View File
@@ -198,6 +198,23 @@ Full reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
---
## Publishing your own skill
DeepSeek-TUI can install community skills directly from a GitHub repo, with no
backend service in the loop:
1. Create a public GitHub repo with a `SKILL.md` at the root containing the
usual `---` frontmatter (`name`, `description`).
2. Multi-skill bundles use `skills/<name>/SKILL.md` instead — the installer
picks the first match and names the install after the frontmatter `name`.
3. Push to `main` (or `master`); the installer fetches
`archive/refs/heads/main.tar.gz` and falls back to `master.tar.gz`.
4. Users install via `/skill install github:<owner>/<repo>` — installs are
gated by the `[network]` policy, validated for path traversal and size, and
placed under `~/.deepseek/skills/<name>/`.
5. Submit a PR to the curated `index.json` (default registry) to make the skill
installable by name (`/skill install <name>`) instead of the GitHub spec.
## Documentation
| Doc | Topic |
+19
View File
@@ -124,6 +124,25 @@ max_subagents = 5 # optional (1-20)
# deny = []
# audit = true # one line per call to ~/.deepseek/audit.log
# ─────────────────────────────────────────────────────────────────────────────────
# Skills (#140)
# ─────────────────────────────────────────────────────────────────────────────────
# Settings for the `/skill install <spec>` community-skill installer.
# * registry_url — curated index.json that resolves bare names to
# `github:owner/repo` specs. Override to point at
# a private fork or internal mirror.
# * max_install_size_bytes — per-skill uncompressed size cap. Tarballs that
# exceed this limit are rejected during validation.
# Default: 5 MiB.
#
# `/skill install` is gated by `[network]`. Make sure `github.com` and
# `raw.githubusercontent.com` are reachable (default `prompt` is fine — you'll
# be asked once and can persist) before running it.
#
# [skills]
# registry_url = "https://raw.githubusercontent.com/Hmbown/deepseek-skills/main/index.json"
# max_install_size_bytes = 5_242_880
# ─────────────────────────────────────────────────────────────────────────────────
# TUI
# ─────────────────────────────────────────────────────────────────────────────────
+19
View File
@@ -128,10 +128,29 @@ pub struct ConfigToml {
/// to a permissive default that mirrors pre-v0.7.0 behavior.
#[serde(default)]
pub network: Option<NetworkPolicyToml>,
/// Community skill installer settings (#140). Mirrors
/// [`SkillsToml`] from the TUI side; the dispatcher consults
/// `registry_url` when running `deepseek skill install`.
#[serde(default)]
pub skills: Option<SkillsToml>,
#[serde(flatten)]
pub extras: BTreeMap<String, toml::Value>,
}
/// On-disk schema for the `[skills]` table (#140). See `config.example.toml`
/// for documentation.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SkillsToml {
/// Curated registry index URL. When unset, the TUI falls back to the
/// bundled default (community-curated GitHub raw).
#[serde(default)]
pub registry_url: Option<String>,
/// Per-skill maximum *uncompressed* size in bytes. When unset, the TUI
/// uses 5 MiB.
#[serde(default)]
pub max_install_size_bytes: Option<u64>,
}
/// On-disk schema for the `[network]` table (#135). See `config.example.toml`
/// for documentation.
#[derive(Debug, Clone, Serialize, Deserialize)]
+3
View File
@@ -57,6 +57,9 @@ zeroize = "1.8.2"
ignore = "0.4"
image = { version = "0.25", default-features = false, features = ["png"] }
pdf-extract = "0.7"
tar = "0.4"
flate2 = "1.1"
sha2 = "0.10"
[dev-dependencies]
wiremock = "0.6"
+5 -5
View File
@@ -322,14 +322,14 @@ pub const COMMANDS: &[CommandInfo] = &[
CommandInfo {
name: "skills",
aliases: &[],
description: "List available skills",
usage: "/skills",
description: "List local skills (or --remote to browse the curated registry)",
usage: "/skills [--remote]",
},
CommandInfo {
name: "skill",
aliases: &[],
description: "Activate a skill for next message",
usage: "/skill <name>",
description: "Activate a skill, or install/update/uninstall/trust a community skill",
usage: "/skill <name|install <spec>|update <name>|uninstall <name>|trust <name>>",
},
CommandInfo {
name: "review",
@@ -409,7 +409,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult {
"init" => init::init(app),
// Skills commands
"skills" => skills::list_skills(app),
"skills" => skills::list_skills(app, arg),
"skill" => skills::run_skill(app, arg),
"review" => review::review(app, arg),
+269 -7
View File
@@ -2,7 +2,12 @@
use std::fmt::Write;
use crate::network_policy::NetworkPolicy;
use crate::skills::SkillRegistry;
use crate::skills::install::{
self, DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL, InstallOutcome, InstallSource,
RegistryFetchResult, UpdateResult,
};
use crate::tui::app::App;
use crate::tui::history::HistoryCell;
@@ -21,8 +26,18 @@ fn render_skill_warnings(registry: &SkillRegistry) -> String {
out
}
/// List all available skills
pub fn list_skills(app: &mut App) -> CommandResult {
/// List all available skills. Pass `--remote` (or `remote`) to fetch the
/// curated registry instead of scanning the local skills directory.
pub fn list_skills(app: &mut App, arg: Option<&str>) -> CommandResult {
if let Some(arg) = arg {
let trimmed = arg.trim();
if trimmed == "--remote" || trimmed == "remote" {
return list_remote_skills(app);
}
if !trimmed.is_empty() {
return CommandResult::error("Usage: /skills [--remote]");
}
}
let skills_dir = app.skills_dir.clone();
let registry = SkillRegistry::discover(&skills_dir);
let warnings = render_skill_warnings(&registry);
@@ -61,15 +76,35 @@ pub fn list_skills(app: &mut App) -> CommandResult {
CommandResult::message(output)
}
/// Run a specific skill - activates skill for next user message
/// Run a specific skill activates skill for next user message, or
/// dispatches a sub-command (`install`, `update`, `uninstall`, `trust`).
pub fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult {
let name = match name {
let raw = match name {
Some(n) => n.trim(),
None => {
return CommandResult::error("Usage: /skill <name>");
return CommandResult::error(
"Usage: /skill <name>\n\nSubcommands:\n /skill install <github:owner/repo|https://…|<registry-name>>\n /skill update <name>\n /skill uninstall <name>\n /skill trust <name>",
);
}
};
// Sub-command dispatch happens before the activation path so users can't
// accidentally activate a skill literally named "install".
let mut iter = raw.splitn(2, char::is_whitespace);
let head = iter.next().unwrap_or("").trim();
let rest = iter.next().unwrap_or("").trim();
match head {
"install" => return install_skill(app, rest),
"update" => return update_skill(app, rest),
"uninstall" => return uninstall_skill(app, rest),
"trust" => return trust_skill(app, rest),
_ => {}
}
activate_skill(app, raw)
}
fn activate_skill(app: &mut App, name: &str) -> CommandResult {
// `/skill new` is a friendly alias for `/skill skill-creator`.
let name = if name == "new" { "skill-creator" } else { name };
@@ -111,6 +146,214 @@ pub fn run_skill(app: &mut App, name: Option<&str>) -> CommandResult {
}
}
// ─── /skill install ────────────────────────────────────────────────────────
fn install_skill(app: &mut App, spec: &str) -> CommandResult {
if spec.is_empty() {
return CommandResult::error(
"Usage: /skill install <github:owner/repo|https://…|<registry-name>>",
);
}
let source = match InstallSource::parse(spec) {
Ok(s) => s,
Err(err) => return CommandResult::error(format!("Invalid install source: {err}")),
};
let skills_dir = app.skills_dir.clone();
let (network, max_size, registry_url) = installer_settings(app);
let outcome = run_async(async move {
install::install_with_registry(
source,
&skills_dir,
max_size,
&network,
false,
&registry_url,
)
.await
});
match outcome {
Ok(InstallOutcome::Installed(installed)) => {
let path_str = path_or_default(&installed.path);
CommandResult::message(format!(
"Installed skill '{}' from {}.\nLocation: {}\n\nRun /skills to see it in the list.",
installed.name, spec, path_str
))
}
Ok(InstallOutcome::NeedsApproval(host)) => {
CommandResult::error(needs_approval_message(&host))
}
Ok(InstallOutcome::NetworkDenied(host)) => {
CommandResult::error(network_denied_message(&host))
}
Err(err) => CommandResult::error(format!("Install failed: {err:#}")),
}
}
// ─── /skill update ─────────────────────────────────────────────────────────
fn update_skill(app: &mut App, name: &str) -> CommandResult {
if name.is_empty() {
return CommandResult::error("Usage: /skill update <name>");
}
let skills_dir = app.skills_dir.clone();
let (network, max_size, registry_url) = installer_settings(app);
let owned_name = name.to_string();
let outcome = run_async(async move {
install::update_with_registry(&owned_name, &skills_dir, max_size, &network, &registry_url)
.await
});
match outcome {
Ok(UpdateResult::NoChange) => {
CommandResult::message(format!("Skill '{name}': no upstream change."))
}
Ok(UpdateResult::Updated(installed)) => CommandResult::message(format!(
"Skill '{}' updated. Location: {}",
installed.name,
path_or_default(&installed.path)
)),
Ok(UpdateResult::NeedsApproval(host)) => {
CommandResult::error(needs_approval_message(&host))
}
Ok(UpdateResult::NetworkDenied(host)) => {
CommandResult::error(network_denied_message(&host))
}
Err(err) => CommandResult::error(format!("Update failed: {err:#}")),
}
}
// ─── /skill uninstall ──────────────────────────────────────────────────────
fn uninstall_skill(app: &mut App, name: &str) -> CommandResult {
if name.is_empty() {
return CommandResult::error("Usage: /skill uninstall <name>");
}
match install::uninstall(name, &app.skills_dir) {
Ok(()) => CommandResult::message(format!("Removed skill '{name}'.")),
Err(err) => CommandResult::error(format!("Uninstall failed: {err:#}")),
}
}
// ─── /skill trust ──────────────────────────────────────────────────────────
fn trust_skill(app: &mut App, name: &str) -> CommandResult {
if name.is_empty() {
return CommandResult::error("Usage: /skill trust <name>");
}
match install::trust(name, &app.skills_dir) {
Ok(()) => CommandResult::message(format!(
"Marked skill '{name}' as trusted. Tools that consult the .trusted marker may now invoke its scripts/."
)),
Err(err) => CommandResult::error(format!("Trust failed: {err:#}")),
}
}
// ─── /skills --remote ──────────────────────────────────────────────────────
/// List skills available in the configured curated registry.
pub fn list_remote_skills(app: &mut App) -> CommandResult {
let (network, _max_size, registry_url) = installer_settings(app);
let registry = run_async(async move { install::fetch_registry(&network, &registry_url).await });
match registry {
Ok(RegistryFetchResult::Loaded(doc)) => {
if doc.skills.is_empty() {
return CommandResult::message("Registry is empty.");
}
let mut out = format!("Available remote skills ({}):\n", doc.skills.len());
out.push_str("─────────────────────────────\n");
for (name, entry) in &doc.skills {
let _ = writeln!(
out,
" {name} — {} (source: {})",
entry.description.clone().unwrap_or_default(),
entry.source
);
}
let _ = write!(out, "\nInstall with: /skill install <name>");
CommandResult::message(out)
}
Ok(RegistryFetchResult::NeedsApproval(host)) => {
CommandResult::error(needs_approval_message(&host))
}
Ok(RegistryFetchResult::Denied(host)) => {
CommandResult::error(network_denied_message(&host))
}
Err(err) => CommandResult::error(format!("Failed to fetch registry: {err:#}")),
}
}
// ─── helpers ───────────────────────────────────────────────────────────────
/// Read the active config knobs for the installer.
///
/// We load `Config::load` on demand because [`App`] does not carry a `Config`
/// field — and loading is cheap (small TOML file) compared to the network
/// round-trip the install/update operation will incur next. If the config
/// fails to parse, we fall back to defaults so the user still gets a
/// network-gated install rather than a silent crash.
fn installer_settings(_app: &App) -> (NetworkPolicy, u64, String) {
let cfg = crate::config::Config::load(None, None).unwrap_or_default();
let network = cfg
.network
.clone()
.map(|policy| policy.into_runtime())
.unwrap_or_default();
let skills_cfg = cfg.skills.as_ref();
let max_size = skills_cfg
.and_then(|s| s.max_install_size_bytes)
.unwrap_or(DEFAULT_MAX_SIZE_BYTES);
let registry_url = skills_cfg
.and_then(|s| s.registry_url.clone())
.unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string());
(network, max_size, registry_url)
}
fn run_async<F, T>(future: F) -> T
where
F: std::future::Future<Output = T>,
{
// We're on the TUI's thread, which is part of the multi-threaded runtime.
// `block_in_place` + `Handle::current().block_on` is the pattern used by
// `commands/cycle.rs` to bridge sync slash-command handlers back into the
// async ecosystem.
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
}
fn path_or_default(path: &std::path::Path) -> String {
path.file_name()
.map(|n| {
// Display with parent so the user sees the full skill location.
// We intentionally use `display()` here because it's just for
// user-facing output, not for path comparisons.
let parent = path
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default();
if parent.is_empty() {
n.to_string_lossy().to_string()
} else {
format!("{parent}/{}", n.to_string_lossy())
}
})
.unwrap_or_else(|| path.display().to_string())
}
fn needs_approval_message(host: &str) -> String {
format!(
"Network policy requires approval for {host}.\n\
Add it to your allow list with `/network allow {host}` (or set [network].default = \"allow\" in ~/.deepseek/config.toml), then retry."
)
}
fn network_denied_message(host: &str) -> String {
format!(
"Network policy denied access to {host}.\n\
Remove the deny entry from ~/.deepseek/config.toml under [network] or contact your administrator."
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -150,7 +393,7 @@ mod tests {
fn test_list_skills_empty_directory() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = list_skills(&mut app);
let result = list_skills(&mut app, None);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("No skills found"));
@@ -166,13 +409,32 @@ mod tests {
"---\nname: test-skill\ndescription: A test skill\n---\nDo something",
);
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = list_skills(&mut app);
let result = list_skills(&mut app, None);
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Available skills"));
assert!(msg.contains("/test-skill"));
}
#[test]
fn test_skill_subcommand_dispatch_install_usage() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
// Empty install spec → usage hint, not invalid-source error.
let result = run_skill(&mut app, Some("install"));
let msg = result.message.unwrap();
assert!(msg.contains("/skill install"), "got: {msg}");
}
#[test]
fn test_skill_subcommand_dispatch_uninstall_missing() {
let tmpdir = TempDir::new().unwrap();
let mut app = create_test_app_with_tmpdir(&tmpdir);
let result = run_skill(&mut app, Some("uninstall absent-skill"));
let msg = result.message.unwrap();
assert!(msg.contains("not installed"), "got: {msg}");
}
#[test]
fn test_run_skill_without_name() {
let tmpdir = TempDir::new().unwrap();
+38
View File
@@ -429,6 +429,43 @@ pub struct Config {
/// to a permissive default that mirrors pre-v0.7.0 behavior.
#[serde(default)]
pub network: Option<NetworkPolicyToml>,
/// Community skill installer settings (#140). When absent, installer
/// commands fall back to the bundled defaults
/// ([`crate::skills::install::DEFAULT_REGISTRY_URL`] +
/// [`crate::skills::install::DEFAULT_MAX_SIZE_BYTES`]).
#[serde(default)]
pub skills: Option<SkillsConfig>,
}
/// `[skills]` table — knobs for the community-skill installer.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct SkillsConfig {
/// Curated registry index. `/skill install <name>` looks up the spec here.
/// Defaults to [`crate::skills::install::DEFAULT_REGISTRY_URL`].
#[serde(default)]
pub registry_url: Option<String>,
/// Per-skill maximum *uncompressed* size in bytes. Tarballs that exceed
/// this limit are rejected during validation. Defaults to 5 MiB.
#[serde(default)]
pub max_install_size_bytes: Option<u64>,
}
impl SkillsConfig {
/// Resolve the registry URL with the bundled default.
#[must_use]
pub fn registry_url(&self) -> String {
self.registry_url
.clone()
.unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string())
}
/// Resolve the max install size with the bundled default.
#[must_use]
pub fn max_install_size_bytes(&self) -> u64 {
self.max_install_size_bytes
.unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES)
}
}
/// `[network]` table — mirrors `deepseek_config::NetworkPolicyToml` so the live
@@ -1384,6 +1421,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
features: merge_features(base.features, override_cfg.features),
notifications: override_cfg.notifications.or(base.notifications),
network: override_cfg.network.or(base.network),
skills: override_cfg.skills.or(base.skills),
}
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -1,6 +1,17 @@
//! Skill discovery and registry for local SKILL.md files.
pub mod install;
mod system;
// Re-exports kept for documentation parity and downstream consumers; the
// binary itself imports directly from `skills::install`. `#[allow(...)]`
// silences the dead-code warning that fires because no `bin` source path
// references these names through `skills::*`.
#[allow(unused_imports)]
pub use install::{
DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL, INSTALLED_FROM_MARKER, InstallOutcome,
InstallSource, InstalledSkill, RegistryDocument, RegistryEntry, RegistryFetchResult,
UpdateResult,
};
pub use system::install_system_skills;
use std::fs;
+522
View File
@@ -0,0 +1,522 @@
//! Integration tests for the community-skill installer (#140).
//!
//! These tests exercise the full validation pipeline against a tiny in-process
//! HTTP server, so the network gate, download cap, tarball validation, atomic
//! rename, and `.installed-from` marker all run end-to-end. The module is
//! pulled in via `#[path]` includes (matching `integration_mock_llm.rs`) so we
//! get access to private helpers without a separate library crate.
use std::io::Write;
use std::path::Path;
use flate2::Compression;
use flate2::write::GzEncoder;
use tempfile::TempDir;
use tiny_http::{Method, Response, Server};
// Pull the production source files into this test binary so the test can
// reach `install`'s public surface without a dedicated library crate.
//
// `install.rs` only references `crate::network_policy` so we just need that
// one helper module alongside `install` itself.
#[path = "../src/network_policy.rs"]
mod network_policy;
#[path = "../src/skills/install.rs"]
#[allow(dead_code)]
mod install;
use crate::install::{InstallOutcome, InstallSource, UpdateResult};
use crate::network_policy::{DecisionToml, NetworkPolicy};
/// Construct a gzipped tarball from `(path, body)` pairs. Permissions are set
/// to 0o644 so umask differences across platforms don't perturb the bytes.
fn make_tarball(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut gz = GzEncoder::new(Vec::new(), Compression::default());
{
let mut builder = tar::Builder::new(&mut gz);
for (path, body) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(body.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, path, *body)
.expect("append_data");
}
builder.finish().expect("finish tar");
}
gz.finish().expect("finish gz")
}
fn skill_md(name: &str, description: &str) -> Vec<u8> {
format!(
"---\nname: {name}\ndescription: {description}\n---\n# {name}\n\nThis is a test skill.\n"
)
.into_bytes()
}
fn allow_all_policy() -> NetworkPolicy {
NetworkPolicy {
default: DecisionToml::Allow,
allow: Vec::new(),
deny: Vec::new(),
audit: false,
}
}
fn deny_all_policy() -> NetworkPolicy {
NetworkPolicy {
default: DecisionToml::Deny,
allow: Vec::new(),
deny: Vec::new(),
audit: false,
}
}
fn prompt_all_policy() -> NetworkPolicy {
NetworkPolicy {
default: DecisionToml::Prompt,
allow: Vec::new(),
deny: Vec::new(),
audit: false,
}
}
/// Spawn a tiny HTTP server that serves `bytes` at any path with 200 OK and
/// returns the bound URL. The server replies to *every* request (we re-use it
/// across multiple installs in the same test).
fn spawn_tarball_server(
bytes: Vec<u8>,
) -> (
String,
std::sync::mpsc::Sender<()>,
std::thread::JoinHandle<()>,
) {
let server = Server::http("127.0.0.1:0").expect("bind ephemeral port");
let url = format!(
"http://{}/skill.tar.gz",
server.server_addr().to_ip().expect("ip addr")
);
let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>();
let handle = std::thread::spawn(move || {
loop {
// Poll-style with a small recv timeout so we can break out cleanly.
match server.recv_timeout(std::time::Duration::from_millis(100)) {
Ok(Some(req)) => {
if req.method() != &Method::Get {
continue;
}
let response = Response::from_data(bytes.clone());
let _ = req.respond(response);
}
Ok(None) => {}
Err(_) => break,
}
if shutdown_rx.try_recv().is_ok() {
break;
}
}
});
(url, shutdown_tx, handle)
}
fn shutdown(tx: std::sync::mpsc::Sender<()>, handle: std::thread::JoinHandle<()>) {
let _ = tx.send(());
let _ = handle.join();
}
#[tokio::test]
async fn install_happy_path_writes_skill_and_marker() {
let tarball = make_tarball(&[
(
"test-skill-main/SKILL.md",
&skill_md("test-skill", "Test skill"),
),
("test-skill-main/notes.txt", b"hello world"),
]);
let (url, tx, handle) = spawn_tarball_server(tarball);
let tmp = TempDir::new().unwrap();
let policy = allow_all_policy();
let outcome = install::install(
InstallSource::DirectUrl(url),
tmp.path(),
install::DEFAULT_MAX_SIZE_BYTES,
&policy,
false,
)
.await
.expect("install ok");
let installed = match outcome {
InstallOutcome::Installed(s) => s,
other => panic!("expected Installed, got {other:?}"),
};
assert_eq!(installed.name, "test-skill");
let installed_dir = tmp.path().join("test-skill");
assert!(installed_dir.is_dir(), "skill dir created");
assert!(installed_dir.join("SKILL.md").is_file(), "SKILL.md present");
assert!(
installed_dir.join("notes.txt").is_file(),
"extra file present"
);
assert!(
installed_dir.join(install::INSTALLED_FROM_MARKER).is_file(),
".installed-from marker present"
);
shutdown(tx, handle);
}
#[tokio::test]
async fn install_rejects_path_traversal() {
// `tar::Builder::append_data` rejects `..` itself, so we craft the bad
// entry by writing the raw header bytes via `append`.
let mut gz = GzEncoder::new(Vec::new(), Compression::default());
{
let mut builder = tar::Builder::new(&mut gz);
let body = skill_md("test-skill", "T");
let mut hdr = tar::Header::new_gnu();
hdr.set_size(body.len() as u64);
hdr.set_mode(0o644);
hdr.set_cksum();
builder
.append_data(&mut hdr, "test-skill-main/SKILL.md", body.as_slice())
.unwrap();
// Path-traversal entry. The `tar` crate's `set_path` rejects `..`
// itself, so we patch the raw 100-byte name field in the header.
let evil_body: &[u8] = b"not gonna happen";
let mut evil_hdr = tar::Header::new_gnu();
evil_hdr.set_size(evil_body.len() as u64);
evil_hdr.set_mode(0o644);
// Write a name with a `..` directly into the legacy "name" field.
let bytes = evil_hdr.as_old_mut();
let evil_name = b"../etc/passwd";
bytes.name[..evil_name.len()].copy_from_slice(evil_name);
evil_hdr.set_cksum();
builder.append(&evil_hdr, evil_body).unwrap();
builder.finish().unwrap();
}
let tarball = gz.finish().unwrap();
let (url, tx, handle) = spawn_tarball_server(tarball);
let tmp = TempDir::new().unwrap();
let policy = allow_all_policy();
let err = install::install(
InstallSource::DirectUrl(url),
tmp.path(),
install::DEFAULT_MAX_SIZE_BYTES,
&policy,
false,
)
.await
.expect_err("path traversal must be rejected");
let msg = format!("{err:#}");
assert!(
msg.contains("escapes destination"),
"expected path-traversal error, got: {msg}"
);
shutdown(tx, handle);
}
#[tokio::test]
async fn install_rejects_oversized_tarball() {
let big = vec![b'a'; 256 * 1024]; // 256 KiB per file
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
entries.push((
"test-skill-main/SKILL.md".to_string(),
skill_md("test-skill", "T"),
));
for i in 0..50 {
entries.push((format!("test-skill-main/big-{i}.bin"), big.clone()));
}
let entry_refs: Vec<(&str, &[u8])> = entries
.iter()
.map(|(p, b)| (p.as_str(), b.as_slice()))
.collect();
let tarball = make_tarball(&entry_refs);
let (url, tx, handle) = spawn_tarball_server(tarball);
let tmp = TempDir::new().unwrap();
let policy = allow_all_policy();
let small_cap = 1024 * 1024;
let err = install::install(
InstallSource::DirectUrl(url),
tmp.path(),
small_cap,
&policy,
false,
)
.await
.expect_err("oversized must be rejected");
let msg = format!("{err:#}");
assert!(
msg.contains("too large") || msg.contains("exceed"),
"expected size cap error, got: {msg}"
);
shutdown(tx, handle);
}
#[tokio::test]
async fn install_rejects_missing_skill_md() {
let tarball = make_tarball(&[("repo-main/README.md", b"not a skill")]);
let (url, tx, handle) = spawn_tarball_server(tarball);
let tmp = TempDir::new().unwrap();
let policy = allow_all_policy();
let err = install::install(
InstallSource::DirectUrl(url),
tmp.path(),
install::DEFAULT_MAX_SIZE_BYTES,
&policy,
false,
)
.await
.expect_err("missing SKILL.md must be rejected");
assert!(format!("{err:#}").contains("missing SKILL.md"), "{err:#}");
shutdown(tx, handle);
}
#[tokio::test]
async fn install_rejects_missing_required_frontmatter() {
let tarball = make_tarball(&[("repo-main/SKILL.md", b"---\nname: test\n---\nbody\n")]);
let (url, tx, handle) = spawn_tarball_server(tarball);
let tmp = TempDir::new().unwrap();
let policy = allow_all_policy();
let err = install::install(
InstallSource::DirectUrl(url),
tmp.path(),
install::DEFAULT_MAX_SIZE_BYTES,
&policy,
false,
)
.await
.expect_err("missing description must be rejected");
assert!(format!("{err:#}").contains("description"), "{err:#}");
shutdown(tx, handle);
}
#[tokio::test]
async fn install_idempotent_then_uninstall_then_reinstall() {
let tarball_bytes =
make_tarball(&[("repo-main/SKILL.md", &skill_md("idem-skill", "Idempotent"))]);
let (url, tx, handle) = spawn_tarball_server(tarball_bytes);
let tmp = TempDir::new().unwrap();
let policy = allow_all_policy();
install::install(
InstallSource::DirectUrl(url.clone()),
tmp.path(),
install::DEFAULT_MAX_SIZE_BYTES,
&policy,
false,
)
.await
.expect("first install ok");
// Second install with `update = false` must reject.
let err = install::install(
InstallSource::DirectUrl(url.clone()),
tmp.path(),
install::DEFAULT_MAX_SIZE_BYTES,
&policy,
false,
)
.await
.expect_err("second install must reject");
let msg = format!("{err:#}");
assert!(
msg.contains("already installed"),
"expected already-installed error, got: {msg}"
);
// Uninstall then reinstall.
install::uninstall("idem-skill", tmp.path()).expect("uninstall ok");
assert!(!tmp.path().join("idem-skill").exists());
install::install(
InstallSource::DirectUrl(url),
tmp.path(),
install::DEFAULT_MAX_SIZE_BYTES,
&policy,
false,
)
.await
.expect("reinstall ok");
assert!(tmp.path().join("idem-skill").join("SKILL.md").is_file());
shutdown(tx, handle);
}
#[tokio::test]
async fn update_no_change_returns_nochange_without_overwriting() {
let tarball_bytes =
make_tarball(&[("repo-main/SKILL.md", &skill_md("upd-skill", "Update test"))]);
let (url, tx, handle) = spawn_tarball_server(tarball_bytes);
let tmp = TempDir::new().unwrap();
let policy = allow_all_policy();
install::install(
InstallSource::DirectUrl(url.clone()),
tmp.path(),
install::DEFAULT_MAX_SIZE_BYTES,
&policy,
false,
)
.await
.unwrap();
// Patch the marker so update() re-fetches the same URL.
let marker_path = tmp
.path()
.join("upd-skill")
.join(install::INSTALLED_FROM_MARKER);
let marker_body = std::fs::read_to_string(&marker_path).unwrap();
let mut marker_json: serde_json::Value = serde_json::from_str(&marker_body).unwrap();
marker_json["spec"] = serde_json::Value::String(url);
std::fs::write(&marker_path, marker_json.to_string()).unwrap();
// Capture mtime so we can confirm SKILL.md wasn't rewritten.
let skill_md_path = tmp.path().join("upd-skill").join("SKILL.md");
let mtime_before = std::fs::metadata(&skill_md_path)
.unwrap()
.modified()
.unwrap();
let result = install::update(
"upd-skill",
tmp.path(),
install::DEFAULT_MAX_SIZE_BYTES,
&policy,
)
.await
.expect("update ok");
assert!(matches!(result, UpdateResult::NoChange));
let mtime_after = std::fs::metadata(&skill_md_path)
.unwrap()
.modified()
.unwrap();
assert_eq!(mtime_before, mtime_after, "SKILL.md must not be rewritten");
shutdown(tx, handle);
}
#[tokio::test]
async fn install_with_deny_policy_returns_network_denied() {
let tmp = TempDir::new().unwrap();
let policy = deny_all_policy();
let outcome = install::install(
InstallSource::DirectUrl("https://example.invalid/skill.tar.gz".to_string()),
tmp.path(),
install::DEFAULT_MAX_SIZE_BYTES,
&policy,
false,
)
.await
.expect("policy outcome should be Ok");
match outcome {
InstallOutcome::NetworkDenied(host) => {
assert!(host.contains("example.invalid"), "got host {host}");
}
other => panic!("expected NetworkDenied, got {other:?}"),
}
// Verify the temp dir is untouched.
assert!(
std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
"temp dir must be untouched"
);
}
#[tokio::test]
async fn install_with_prompt_policy_returns_needs_approval() {
let tmp = TempDir::new().unwrap();
let policy = prompt_all_policy();
let outcome = install::install(
InstallSource::DirectUrl("https://example.invalid/skill.tar.gz".to_string()),
tmp.path(),
install::DEFAULT_MAX_SIZE_BYTES,
&policy,
false,
)
.await
.expect("policy outcome should be Ok");
match outcome {
InstallOutcome::NeedsApproval(host) => {
assert!(host.contains("example.invalid"), "got host {host}");
}
other => panic!("expected NeedsApproval, got {other:?}"),
}
assert!(
std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
"temp dir must be untouched on prompt"
);
}
#[tokio::test]
async fn install_rejects_symlink_entry() {
let mut gz = GzEncoder::new(Vec::new(), Compression::default());
{
let mut builder = tar::Builder::new(&mut gz);
let body = skill_md("link-skill", "x");
let mut hdr = tar::Header::new_gnu();
hdr.set_size(body.len() as u64);
hdr.set_mode(0o644);
hdr.set_cksum();
builder
.append_data(&mut hdr, "repo-main/SKILL.md", body.as_slice())
.unwrap();
let mut link_hdr = tar::Header::new_gnu();
link_hdr.set_entry_type(tar::EntryType::Symlink);
link_hdr.set_size(0);
link_hdr.set_mode(0o777);
builder
.append_link(&mut link_hdr, "repo-main/escape", Path::new("/etc/passwd"))
.unwrap();
builder.finish().unwrap();
}
let tarball = gz.finish().unwrap();
let (url, tx, handle) = spawn_tarball_server(tarball);
let tmp = TempDir::new().unwrap();
let policy = allow_all_policy();
let err = install::install(
InstallSource::DirectUrl(url),
tmp.path(),
install::DEFAULT_MAX_SIZE_BYTES,
&policy,
false,
)
.await
.expect_err("symlinks must be rejected");
assert!(format!("{err:#}").contains("symlink"), "{err:#}");
shutdown(tx, handle);
}
#[test]
fn uninstall_refuses_system_skill() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("system-skill");
std::fs::create_dir_all(&dir).unwrap();
let mut f = std::fs::File::create(dir.join("SKILL.md")).unwrap();
f.write_all(b"---\nname: system-skill\ndescription: x\n---\n")
.unwrap();
// No `.installed-from` marker — looks like a system skill.
let err = install::uninstall("system-skill", tmp.path()).expect_err("must refuse");
assert!(format!("{err:#}").contains("not installed via"));
assert!(dir.exists(), "directory must be left alone");
}