use std::{
fmt,
fs::{self, File},
io::Cursor,
path::{Path, PathBuf},
process::Command as ProcessCommand,
};
use anstyle::{AnsiColor, Effects, Style};
use anyhow::{Context, Result, anyhow, bail};
use clap::builder::styling::Styles;
use clap::{Parser, Subcommand};
const HELP_STYLES: Styles = Styles::styled()
.header(AnsiColor::Green.on_default().effects(Effects::BOLD))
.usage(AnsiColor::Green.on_default().effects(Effects::BOLD))
.literal(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
.placeholder(AnsiColor::Blue.on_default())
.error(AnsiColor::Red.on_default().effects(Effects::BOLD))
.valid(AnsiColor::Green.on_default())
.invalid(AnsiColor::Yellow.on_default());
const PUBLIC_REGISTRY_NAME: &str = "public";
const PUBLIC_REGISTRY_URL: &str = "https://knack.ajac-zero.com";
use flate2::{Compression, write::GzEncoder};
use knack_core::{
IndexedSkill, LockedSkill, Lockfile, Manifest, RegistryConfig, RegistryIndex, RegistryKind,
checksum_dir, collect_files, read_skill, validate_skill, validate_skill_name,
};
use tar::{Builder, Header};
use tempfile::TempDir;
#[derive(Debug, Parser)]
#[command(name = "knack")]
#[command(version, about = "Package, share, and install Agent Skills")]
#[command(styles = HELP_STYLES)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Init {
#[arg(long)]
manifest: Option<PathBuf>,
#[arg(long)]
target: Option<PathBuf>,
#[arg(short = 'g', long)]
global: bool,
#[arg(long)]
no_public_registry: bool,
},
Add {
source: String,
#[arg(long)]
manifest: Option<PathBuf>,
#[arg(short = 'g', long)]
global: bool,
},
Sync {
#[arg(long)]
manifest: Option<PathBuf>,
#[arg(short = 'g', long)]
global: bool,
#[arg(long)]
check: bool,
},
Update {
skills: Vec<String>,
#[arg(long)]
manifest: Option<PathBuf>,
#[arg(short = 'g', long)]
global: bool,
#[arg(short = 'f', long)]
force: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Find {
query: String,
#[arg(long)]
manifest: Option<PathBuf>,
#[arg(long, default_value_t = 10)]
limit: usize,
},
Publish {
path: PathBuf,
#[arg(long)]
registry: String,
#[arg(long)]
repo: String,
#[arg(long, default_value = "skills")]
skills_dir: PathBuf,
#[arg(long)]
manifest: Option<PathBuf>,
#[arg(short = 'g', long)]
global: bool,
#[arg(long)]
no_push: bool,
},
Registry {
#[command(subcommand)]
command: RegistryCommand,
},
Index {
#[command(subcommand)]
command: IndexCommand,
},
New {
name: String,
#[arg(long, default_value = ".")]
dir: PathBuf,
},
Validate {
path: PathBuf,
},
Pack {
path: PathBuf,
#[arg(short, long, default_value = ".")]
output: PathBuf,
},
Install {
source: String,
#[arg(long)]
target: Option<PathBuf>,
#[arg(short = 'g', long)]
global: bool,
},
List {
#[arg(long)]
target: Option<PathBuf>,
},
}
#[derive(Debug, Subcommand)]
enum IndexCommand {
Generate {
root: PathBuf,
#[arg(long)]
source_prefix: String,
#[arg(short, long, default_value = "knack.index.toml")]
output: PathBuf,
},
}
#[derive(Debug, Subcommand)]
enum RegistryCommand {
Add {
url: String,
name: Option<String>,
#[arg(long, default_value = "main")]
default_ref: String,
#[arg(long)]
manifest: Option<PathBuf>,
#[arg(short = 'g', long)]
global: bool,
},
List {
#[arg(long)]
manifest: Option<PathBuf>,
},
Remove {
name: String,
#[arg(long)]
manifest: Option<PathBuf>,
#[arg(short = 'g', long)]
global: bool,
},
}
fn infer_registry_kind(url: &str) -> Result<RegistryKind> {
if url.starts_with("git+") {
Ok(RegistryKind::GitHost)
} else if url.starts_with("http://") || url.starts_with("https://") {
Ok(RegistryKind::Http)
} else {
bail!(
"cannot determine registry kind from URL `{url}`; \
expected a scheme of `git+ssh://`, `git+https://`, `http://`, or `https://`"
);
}
}
fn resolve_registry_name(
provided: Option<String>,
url: &str,
kind: RegistryKind,
) -> Result<String> {
if let Some(name) = provided {
return Ok(name);
}
match kind {
RegistryKind::Http => fetch_advertised_registry_name(url),
RegistryKind::GitHost => bail!(
"git-host registries don't advertise a name; \
pass an explicit alias as the second argument, e.g. \
`knack registry add {url} <name>`"
),
}
}
fn fetch_advertised_registry_name(base_url: &str) -> Result<String> {
let base = base_url.trim_end_matches('/');
let info_url = format!("{base}/info");
let response = reqwest::blocking::Client::new()
.get(&info_url)
.header(reqwest::header::USER_AGENT, "knack")
.send()
.with_context(|| format!("failed to fetch {info_url}"))?
.error_for_status()
.with_context(|| format!("registry returned an error for {info_url}"))?;
#[derive(serde::Deserialize)]
struct RegistryInfo {
name: Option<String>,
}
let info: RegistryInfo = response
.json()
.with_context(|| format!("failed to parse {info_url} as RegistryInfo JSON"))?;
info.name.ok_or_else(|| {
anyhow!(
"registry at {base_url} doesn't advertise a name; \
pass an explicit alias as the second argument, e.g. \
`knack registry add {base_url} <name>`"
)
})
}
#[derive(Clone, Copy, Debug)]
enum Scope {
Project,
Global,
System,
}
impl Scope {
fn from_global_flag(global: bool) -> Self {
if global { Self::Global } else { Self::Project }
}
fn manifest_path(self) -> Result<PathBuf> {
match self {
Self::Project => Ok(PathBuf::from(".agents/knack.toml")),
Self::Global => Ok(home_dir()?.join(".agents/knack.toml")),
Self::System => Ok(PathBuf::from("/etc/knack/knack.toml")),
}
}
fn install_target(self) -> Result<PathBuf> {
match self {
Self::Project => Ok(PathBuf::from(".agents/skills")),
Self::Global => Ok(home_dir()?.join(".agents/skills")),
Self::System => Ok(PathBuf::from("/usr/local/share/knack/skills")),
}
}
}
fn home_dir() -> Result<PathBuf> {
std::env::var_os("HOME")
.map(PathBuf::from)
.ok_or_else(|| anyhow!("HOME is not set; cannot resolve global skill directory"))
}
fn resolve_manifest_path(manifest: Option<PathBuf>, scope: Scope) -> Result<PathBuf> {
match manifest {
Some(path) => Ok(path),
None => scope.manifest_path(),
}
}
fn resolve_target_path(target: Option<PathBuf>, scope: Scope) -> Result<PathBuf> {
match target {
Some(path) => Ok(path),
None => scope.install_target(),
}
}
fn success_style() -> Style {
AnsiColor::Green.on_default() | Effects::BOLD
}
fn accent_style() -> Style {
AnsiColor::Cyan.on_default() | Effects::BOLD
}
fn label_style() -> Style {
Style::new().dimmed()
}
fn status(action: &str, value: impl fmt::Display) {
let action_style = success_style();
let value_style = accent_style();
anstream::println!(
"{action_style}{action}{action_style:#} {value_style}{value}{value_style:#}"
);
}
fn notice(message: &str) {
let message_style = success_style();
anstream::println!("{message_style}{message}{message_style:#}");
}
fn warn_style() -> Style {
AnsiColor::Yellow.on_default() | Effects::BOLD
}
fn warn(message: &str) {
let message_style = warn_style();
anstream::eprintln!("{message_style}warning:{message_style:#} {message}");
}
fn print_wrapped(text: &str, width: usize, indent: usize) {
for line in wrap_text(text, width.saturating_sub(indent).max(20)) {
anstream::println!("{:indent$}{line}", "");
}
}
fn wrap_text(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
if current.is_empty() {
current.push_str(word);
} else if current.len() + 1 + word.len() <= width {
current.push(' ');
current.push_str(word);
} else {
lines.push(std::mem::take(&mut current));
current.push_str(word);
}
}
if !current.is_empty() {
lines.push(current);
}
lines
}
fn terminal_width() -> usize {
std::env::var("COLUMNS")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|&w| w > 0)
.unwrap_or(100)
.max(40)
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Init {
manifest,
target,
global,
no_public_registry,
} => {
let scope = Scope::from_global_flag(global);
let manifest = resolve_manifest_path(manifest, scope)?;
let target = resolve_target_path(target, scope)?;
init_manifest(&manifest, &target, !no_public_registry)?;
}
Command::Add {
source,
manifest,
global,
} => {
let scope = Scope::from_global_flag(global);
let manifest = resolve_manifest_path(manifest, scope)?;
let default_target = scope.install_target()?;
add_skill(&manifest, &source, &default_target)?;
}
Command::Sync {
manifest,
global,
check,
} => {
let scope = Scope::from_global_flag(global);
let manifest = resolve_manifest_path(manifest, scope)?;
if check {
check_skills(&manifest)?;
} else {
sync_skills(&manifest)?;
}
}
Command::Update {
skills,
manifest,
global,
force,
dry_run,
} => {
let scope = Scope::from_global_flag(global);
let manifest = resolve_manifest_path(manifest, scope)?;
update_skills(&manifest, force, dry_run, &skills)?;
}
Command::Find {
query,
manifest,
limit,
} => {
find_registry_skills(manifest.as_deref(), &query, limit)?;
}
Command::Publish {
path,
registry,
repo,
skills_dir,
manifest,
global,
no_push,
} => {
let scope = Scope::from_global_flag(global);
let manifest = resolve_manifest_path(manifest, scope)?;
publish_skill(&manifest, &path, ®istry, &repo, &skills_dir, no_push)?;
}
Command::Registry { command } => {
handle_registry_command(command)?;
}
Command::Index { command } => {
handle_index_command(command)?;
}
Command::New { name, dir } => {
new_skill(&name, dir)?;
}
Command::Validate { path } => {
let skill = read_skill(&path)?;
validate_skill(&skill)?;
status("valid skill:", skill.name);
}
Command::Pack { path, output } => {
let archive = pack_skill(&path, &output)?;
status("packed skill:", archive.display());
}
Command::Install {
source,
target,
global,
} => {
let scope = Scope::from_global_flag(global);
let target = resolve_target_path(target, scope)?;
let installed = install_skill(&source, &target)?;
status("installed skill:", installed.path.display());
}
Command::List { target } => {
list_skills(target.as_deref())?;
}
}
Ok(())
}
fn handle_registry_command(command: RegistryCommand) -> Result<()> {
match command {
RegistryCommand::Add {
url,
name,
default_ref,
manifest,
global,
} => {
let scope = Scope::from_global_flag(global);
let manifest_path = resolve_manifest_path(manifest, scope)?;
let default_target = scope.install_target()?;
let kind = infer_registry_kind(&url)?;
let resolved_name = resolve_registry_name(name, &url, kind)?;
registry_add(
&manifest_path,
&resolved_name,
RegistryConfig {
kind,
url,
default_ref,
},
&default_target,
)?;
}
RegistryCommand::List { manifest } => {
registry_list(manifest.as_deref())?;
}
RegistryCommand::Remove {
name,
manifest,
global,
} => {
let scope = Scope::from_global_flag(global);
let manifest_path = resolve_manifest_path(manifest, scope)?;
registry_remove(&manifest_path, &name)?;
}
}
Ok(())
}
fn handle_index_command(command: IndexCommand) -> Result<()> {
match command {
IndexCommand::Generate {
root,
source_prefix,
output,
} => {
generate_index(&root, &source_prefix, &output)?;
status("generated index:", output.display());
}
}
Ok(())
}
fn generate_index(root: &Path, source_prefix: &str, output: &Path) -> Result<()> {
let mut index = RegistryIndex::default();
for skill_dir in collect_skill_dirs(root)? {
let skill = read_skill(&skill_dir)?;
validate_skill(&skill)?;
let relative = skill_dir.strip_prefix(root).with_context(|| {
format!(
"failed to make {} relative to {}",
skill_dir.display(),
root.display()
)
})?;
let relative = relative.to_string_lossy().replace('\\', "/");
index.skill.push(IndexedSkill {
name: skill.name,
namespace: None,
description: skill.description,
source: format!("{}/{}", source_prefix.trim_end_matches('/'), relative),
tags: Vec::new(),
score: None,
});
}
index
.skill
.sort_by(|left, right| left.name.cmp(&right.name));
index.validate()?;
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let contents = toml::to_string_pretty(&index).context("failed to serialize index")?;
fs::write(output, contents).with_context(|| format!("failed to write {}", output.display()))?;
Ok(())
}
fn collect_skill_dirs(root: &Path) -> Result<Vec<PathBuf>> {
let mut skills = Vec::new();
collect_skill_dirs_inner(root, &mut skills)?;
skills.sort();
Ok(skills)
}
fn collect_skill_dirs_inner(path: &Path, skills: &mut Vec<PathBuf>) -> Result<()> {
if path.join("SKILL.md").is_file() {
skills.push(path.to_path_buf());
return Ok(());
}
for entry in fs::read_dir(path).with_context(|| format!("failed to read {}", path.display()))? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() && !is_ignored_scan_dir(&path) {
collect_skill_dirs_inner(&path, skills)?;
}
}
Ok(())
}
fn is_ignored_scan_dir(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| matches!(name, ".git" | "target" | "node_modules"))
}
fn registry_add(
manifest_path: &Path,
name: &str,
config: RegistryConfig,
default_target: &Path,
) -> Result<()> {
validate_registry_name(name)?;
ensure_manifest_exists(manifest_path, default_target)?;
let mut manifest = read_manifest(manifest_path)?;
let prior = manifest.registries.insert(name.to_string(), config.clone());
write_manifest(manifest_path, &manifest)?;
match prior {
None => status("registered registry:", name),
Some(ref old) if old == &config => {
notice(&format!("registry already configured: {name}"));
}
Some(old) => {
status("updated registry:", name);
print_registry_diff(&old, &config);
}
}
Ok(())
}
fn registry_kind_label(kind: RegistryKind) -> &'static str {
match kind {
RegistryKind::GitHost => "git-host",
RegistryKind::Http => "http",
}
}
fn unknown_registry_hint(
registries: &std::collections::BTreeMap<String, RegistryConfig>,
) -> String {
if registries.is_empty() {
"no registries are configured; register one with \
`knack registry add <url> [<name>]`"
.to_string()
} else {
let known: Vec<&str> = registries.keys().map(String::as_str).collect();
format!(
"known aliases: {}; run `knack registry list` for details",
known.join(", "),
)
}
}
fn print_registry_diff(old: &RegistryConfig, new: &RegistryConfig) {
if old.kind != new.kind {
anstream::println!(
" kind: {} -> {}",
registry_kind_label(old.kind),
registry_kind_label(new.kind),
);
}
if old.url != new.url {
anstream::println!(" url: {} -> {}", old.url, new.url);
}
if old.default_ref != new.default_ref {
anstream::println!(" default-ref: {} -> {}", old.default_ref, new.default_ref,);
}
}
fn registry_list(explicit_manifest: Option<&Path>) -> Result<()> {
let (manifest, manifest_path) = read_manifest_for_read(explicit_manifest)?;
for (name, registry) in effective_registries(&manifest, &manifest_path)? {
let name_style = accent_style();
let label_style = label_style();
anstream::println!(
"{name_style}{name}{name_style:#}\t{label_style}{}{label_style:#}\t{}",
registry_kind_label(registry.kind),
registry.url
);
}
Ok(())
}
fn registry_remove(manifest_path: &Path, name: &str) -> Result<()> {
let mut manifest = read_manifest(manifest_path)?;
if manifest.registries.remove(name).is_none() {
bail!("registry not found: {name}");
}
write_manifest(manifest_path, &manifest)?;
status("removed registry:", name);
Ok(())
}
fn validate_registry_name(name: &str) -> Result<()> {
validate_skill_name(name).context("registry aliases use the same naming rules as skills")
}
struct FindMatch {
skill_name: String,
namespace: Option<String>,
description: String,
registry_name: String,
score: Option<f64>,
}
fn find_registry_skills(explicit_manifest: Option<&Path>, query: &str, limit: usize) -> Result<()> {
let query = query.trim();
if query.is_empty() {
bail!("find query must not be empty");
}
let (manifest, manifest_path) = read_manifest_for_read(explicit_manifest)?;
let registries = effective_registries(&manifest, &manifest_path)?;
let show_registry = registries
.values()
.filter(|registry| matches!(registry.kind, RegistryKind::Http))
.count()
> 1;
let mut matches = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut failed_registries = Vec::new();
for (name, registry) in ®istries {
if !matches!(registry.kind, RegistryKind::Http) {
continue;
}
let results = match search_http_registry(®istry.url, query) {
Ok(results) => results,
Err(err) => {
warn(&format!("registry {name} unavailable: {err:#}"));
failed_registries.push(name.clone());
continue;
}
};
for skill in results {
let qualified = skill.qualified_name();
if !seen.insert((name.clone(), qualified)) {
continue;
}
matches.push(FindMatch {
skill_name: skill.name,
namespace: skill.namespace,
description: skill.description,
registry_name: name.clone(),
score: skill.score,
});
}
}
if matches.is_empty() {
notice("no matching skills found");
if !failed_registries.is_empty() {
warn(&format!(
"registries unreachable: {}",
failed_registries.join(", ")
));
}
return Ok(());
}
matches.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.skill_name.cmp(&b.skill_name))
});
let total = matches.len();
matches.truncate(limit);
let count_style = accent_style();
let label_style_hint = label_style();
anstream::println!(
"{count_style}{}{count_style:#} skill{} found {label_style_hint}— install with `knack add <name>`{label_style_hint:#}\n",
total,
if total == 1 { "" } else { "s" }
);
let wrap_width = terminal_width();
for (
i,
FindMatch {
skill_name,
namespace,
description,
registry_name,
score: _,
},
) in matches.into_iter().enumerate()
{
if i > 0 {
anstream::println!();
}
let qualified = match &namespace {
Some(ns) => format!("{ns}/{skill_name}"),
None => skill_name,
};
let skill_style = accent_style();
if show_registry {
let label_style = label_style();
anstream::println!(
"{label_style}{registry_name}:{label_style:#}{skill_style}{qualified}{skill_style:#}"
);
} else {
anstream::println!("{skill_style}{qualified}{skill_style:#}");
}
print_wrapped(&description, wrap_width, 2);
}
if total > limit {
anstream::println!();
notice(&format!(
"showing {limit} of {total} matches — pass --limit N to see more"
));
}
if !failed_registries.is_empty() {
warn(&format!(
"registries unreachable: {}",
failed_registries.join(", ")
));
}
Ok(())
}
fn publish_skill(
manifest_path: &Path,
skill_path: &Path,
registry_name: &str,
repo: &str,
skills_dir: &Path,
no_push: bool,
) -> Result<()> {
let skill = read_skill(skill_path)?;
validate_skill(&skill)?;
let manifest = read_manifest(manifest_path)?;
let registries = effective_registries(&manifest, manifest_path)?;
let registry = match registries.get(registry_name) {
Some(registry) => registry,
None => bail!(
"no registry registered as `{registry_name}`; {}",
unknown_registry_hint(®istries),
),
};
match registry.kind {
RegistryKind::GitHost => {}
RegistryKind::Http => bail!(
"registry `{registry_name}` is configured as `http`, but \
`knack publish` only supports git-host registries; register \
a git-host registry with `knack registry add git+ssh://... <name>` \
and pass that alias to `--registry`"
),
}
let repo_url = git_host_repo_url(®istry.url, repo)?;
let temp_dir = tempfile::tempdir().context("failed to create temporary directory")?;
let checkout = temp_dir.path().join("repo");
run_git(
["clone", &repo_url, checkout.to_str().unwrap_or_default()],
None,
"clone publish repository",
)?;
let destination = checkout.join(skills_dir).join(&skill.name);
if destination.exists() {
fs::remove_dir_all(&destination)
.with_context(|| format!("failed to replace {}", destination.display()))?;
}
copy_dir(skill_path, &destination)?;
let source_prefix = format!(
"{}:{}/{}",
registry_name,
repo.trim_matches('/'),
skills_dir.to_string_lossy().replace('\\', "/")
);
generate_index(
&checkout.join(skills_dir),
&source_prefix,
&checkout.join("knack.index.toml"),
)?;
run_git(["add", "."], Some(&checkout), "stage published skill")?;
let status_output = ProcessCommand::new("git")
.arg("status")
.arg("--porcelain")
.current_dir(&checkout)
.output()
.context("failed to inspect publish repository status")?;
if status_output.stdout.is_empty() {
status("nothing to publish:", skill.name);
return Ok(());
}
let message = format!("Publish skill {}", skill.name);
run_git(
["commit", "-m", &message],
Some(&checkout),
"commit published skill",
)?;
if no_push {
let persisted = temp_dir.keep();
let persisted_checkout = persisted.join("repo");
status("prepared publish for:", &skill.name);
notice(&format!(
"commit left unpushed; inspect or push from: {}",
persisted_checkout.display(),
));
} else {
run_git(["push"], Some(&checkout), "push published skill")?;
status("published skill:", skill.name);
}
Ok(())
}
fn git_host_repo_url(base_url: &str, repo: &str) -> Result<String> {
let repo = repo.trim_matches('/');
if repo.split('/').count() != 2 {
bail!("--repo must be in owner/repo form");
}
let base_url = base_url.trim_end_matches('/');
let base_url = base_url.strip_prefix("git+").unwrap_or(base_url);
Ok(format!("{base_url}/{repo}.git"))
}
fn run_git<'a>(
args: impl IntoIterator<Item = &'a str>,
cwd: Option<&Path>,
action: &str,
) -> Result<()> {
let mut command = ProcessCommand::new("git");
command.args(args);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let output = command
.output()
.with_context(|| format!("failed to run git for {action}; is git installed?"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = stderr.trim();
if detail.is_empty() {
bail!("git failed to {action}");
}
bail!("git failed to {action}: {detail}");
}
Ok(())
}
fn search_http_registry(base_url: &str, query: &str) -> Result<Vec<IndexedSkill>> {
let base_url = base_url.trim_end_matches('/');
let response = reqwest::blocking::Client::new()
.get(format!("{base_url}/search"))
.query(&[("q", query)])
.header(reqwest::header::USER_AGENT, "knack")
.send()
.with_context(|| format!("failed to query {base_url}/search"))?
.error_for_status()
.with_context(|| format!("registry returned an error for {base_url}/search"))?;
response
.json()
.context("failed to decode registry search results")
}
fn init_manifest(manifest_path: &Path, target: &Path, bootstrap_public: bool) -> Result<()> {
if manifest_path.exists() {
bail!("manifest already exists: {}", manifest_path.display());
}
if let Some(parent) = manifest_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let mut manifest = Manifest::new(target.to_path_buf());
if bootstrap_public {
manifest.registries.insert(
PUBLIC_REGISTRY_NAME.to_string(),
RegistryConfig {
kind: RegistryKind::Http,
url: PUBLIC_REGISTRY_URL.to_string(),
default_ref: "main".to_string(),
},
);
manifest.install.default_registry = Some(PUBLIC_REGISTRY_NAME.to_string());
}
write_manifest(manifest_path, &manifest)?;
status("created manifest:", manifest_path.display());
if bootstrap_public {
status(
"seeded registry:",
format!("{PUBLIC_REGISTRY_NAME} ({PUBLIC_REGISTRY_URL})"),
);
status(
"default registry:",
format!("{PUBLIC_REGISTRY_NAME} (bare `knack add ns/name` resolves via this)"),
);
}
Ok(())
}
fn ensure_manifest_exists(manifest_path: &Path, default_target: &Path) -> Result<()> {
if manifest_path.exists() {
return Ok(());
}
let manifest = Manifest::new(default_target.to_path_buf());
write_manifest(manifest_path, &manifest)?;
status("created manifest:", manifest_path.display());
Ok(())
}
fn read_manifest(manifest_path: &Path) -> Result<Manifest> {
let contents = fs::read_to_string(manifest_path)
.with_context(|| format!("failed to read {}", manifest_path.display()))?;
toml::from_str(&contents)
.with_context(|| format!("failed to parse {}", manifest_path.display()))
}
fn read_optional_manifest(manifest_path: &Path) -> Result<Option<Manifest>> {
if !manifest_path.exists() {
return Ok(None);
}
read_manifest(manifest_path).map(Some)
}
fn read_manifest_for_read(explicit_path: Option<&Path>) -> Result<(Manifest, PathBuf)> {
match explicit_path {
Some(path) => Ok((read_manifest(path)?, path.to_path_buf())),
None => {
let default = Scope::Project.manifest_path()?;
let manifest =
read_optional_manifest(&default)?.unwrap_or_else(|| Manifest::new(PathBuf::new()));
Ok((manifest, default))
}
}
}
fn write_manifest(manifest_path: &Path, manifest: &Manifest) -> Result<()> {
if let Some(parent) = manifest_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let contents = toml::to_string_pretty(manifest).context("failed to serialize manifest")?;
fs::write(manifest_path, contents)
.with_context(|| format!("failed to write {}", manifest_path.display()))
}
fn lockfile_path_for(manifest_path: &Path) -> PathBuf {
manifest_path.with_file_name("knack.lock")
}
fn read_lockfile(lockfile_path: &Path) -> Result<Lockfile> {
if !lockfile_path.exists() {
return Ok(Lockfile::default());
}
let contents = fs::read_to_string(lockfile_path)
.with_context(|| format!("failed to read {}", lockfile_path.display()))?;
let lockfile: Lockfile = toml::from_str(&contents)
.with_context(|| format!("failed to parse {}", lockfile_path.display()))?;
lockfile
.ensure_supported_version()
.map_err(|message| anyhow::anyhow!("{} in {}", message, lockfile_path.display()))?;
Ok(lockfile)
}
fn write_lockfile(lockfile_path: &Path, lockfile: &Lockfile) -> Result<()> {
if let Some(parent) = lockfile_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let contents = toml::to_string_pretty(lockfile).context("failed to serialize lockfile")?;
fs::write(lockfile_path, contents)
.with_context(|| format!("failed to write {}", lockfile_path.display()))
}
fn upsert_lock(lockfile: &mut Lockfile, locked_skill: LockedSkill) {
if let Some(existing) = lockfile
.skill
.iter_mut()
.find(|skill| skill.name == locked_skill.name)
{
*existing = locked_skill;
} else {
lockfile.skill.push(locked_skill);
}
lockfile
.skill
.sort_by(|left, right| left.name.cmp(&right.name));
}
fn add_skill(manifest_path: &Path, source: &str, default_target: &Path) -> Result<()> {
ensure_manifest_exists(manifest_path, default_target)?;
let mut manifest = read_manifest(manifest_path)?;
let lockfile_path = lockfile_path_for(manifest_path);
let mut lockfile = read_lockfile(&lockfile_path)?;
let resolved_source = resolve_source_alias(source, &manifest, manifest_path)?;
let installed = install_skill(&resolved_source, &manifest.install.target)?;
manifest
.skills
.insert(installed.name.clone(), source.to_string());
let locked_resolved = installed
.resolved_sha
.as_deref()
.and_then(|sha| pin_resolved_with_sha(&resolved_source, sha))
.unwrap_or(resolved_source);
upsert_lock(
&mut lockfile,
LockedSkill {
name: installed.name.clone(),
namespace: installed.namespace.clone(),
source: source.to_string(),
resolved: locked_resolved,
checksum: checksum_dir(&installed.path)?,
},
);
write_manifest(manifest_path, &manifest)?;
write_lockfile(&lockfile_path, &lockfile)?;
let action_style = success_style();
let value_style = accent_style();
let label_style = label_style();
anstream::println!(
"{action_style}added skill:{action_style:#} {value_style}{}{value_style:#} {label_style}from{label_style:#} {value_style}{}{value_style:#}",
installed.name,
source
);
Ok(())
}
fn check_skills(manifest_path: &Path) -> Result<()> {
let manifest = read_manifest(manifest_path)?;
let lockfile_path = lockfile_path_for(manifest_path);
let lockfile = read_lockfile(&lockfile_path)?;
let mut problems: Vec<String> = Vec::new();
for (name, source) in &manifest.skills {
let lock_entry = lockfile
.skill
.iter()
.find(|skill| skill.name == *name && skill.source == *source);
let Some(lock_entry) = lock_entry else {
problems.push(format!(
"skill `{name}` is in the manifest but has no lockfile entry; \
run `knack sync` to regenerate"
));
continue;
};
let install_dir = manifest.install.target.join(name);
if !install_dir.join("SKILL.md").is_file() {
problems.push(format!(
"skill `{name}` is locked but not installed at {}; \
run `knack sync`",
install_dir.display()
));
continue;
}
let actual_checksum = checksum_dir(&install_dir).with_context(|| {
format!(
"failed to checksum installed skill at {}",
install_dir.display()
)
})?;
if actual_checksum != lock_entry.checksum {
problems.push(format!(
"skill `{name}` checksum drifted: lockfile says {} but install has {}",
lock_entry.checksum, actual_checksum
));
continue;
}
status("ok:", name);
}
if !problems.is_empty() {
for problem in &problems {
anstream::eprintln!("knack sync --check: {problem}");
}
bail!(
"{} skill(s) failed sync --check; run `knack sync` to repair",
problems.len()
);
}
Ok(())
}
fn sync_skills(manifest_path: &Path) -> Result<()> {
let manifest = read_manifest(manifest_path)?;
let lockfile_path = lockfile_path_for(manifest_path);
let mut lockfile = read_lockfile(&lockfile_path)?;
fs::create_dir_all(&manifest.install.target)
.with_context(|| format!("failed to create {}", manifest.install.target.display()))?;
for (name, source) in &manifest.skills {
if is_skill_installed(name, &manifest.install.target) {
status("already installed:", name);
continue;
}
let from_lockfile = lockfile
.skill
.iter()
.find(|skill| skill.name == *name && skill.source == *source)
.map(|skill| skill.resolved.clone());
let from_manifest = resolve_source_alias(source, &manifest, manifest_path)?;
let primary = from_lockfile.unwrap_or_else(|| from_manifest.clone());
let (installed, source_used) = install_skill_with_sha_fallback(
&primary,
&from_manifest,
&manifest.install.target,
name,
)?;
let locked_resolved = installed
.resolved_sha
.as_deref()
.and_then(|sha| pin_resolved_with_sha(source_used, sha))
.unwrap_or_else(|| source_used.to_string());
upsert_lock(
&mut lockfile,
LockedSkill {
name: installed.name.clone(),
namespace: installed.namespace.clone(),
source: source.clone(),
resolved: locked_resolved,
checksum: checksum_dir(&installed.path)?,
},
);
status("synced skill:", installed.name);
}
write_lockfile(&lockfile_path, &lockfile)?;
Ok(())
}
fn update_skills(
manifest_path: &Path,
force: bool,
dry_run: bool,
skill_filter: &[String],
) -> Result<()> {
let manifest = read_manifest(manifest_path)?;
let lockfile_path = lockfile_path_for(manifest_path);
let mut lockfile = read_lockfile(&lockfile_path)?;
fs::create_dir_all(&manifest.install.target)
.with_context(|| format!("failed to create {}", manifest.install.target.display()))?;
if !skill_filter.is_empty() {
let unknown: Vec<&str> = skill_filter
.iter()
.filter(|name| !manifest.skills.contains_key(name.as_str()))
.map(String::as_str)
.collect();
if !unknown.is_empty() {
let known: Vec<&str> = manifest.skills.keys().map(String::as_str).collect();
bail!(
"skill(s) not in manifest: {}; manifest declares: {}",
unknown.join(", "),
if known.is_empty() {
"(no skills)".to_string()
} else {
known.join(", ")
},
);
}
}
for (name, source) in &manifest.skills {
if !skill_filter.is_empty() && !skill_filter.iter().any(|wanted| wanted == name) {
continue;
}
let installed_locally = is_skill_installed(name, &manifest.install.target);
let resolved = resolve_source_alias(source, &manifest, manifest_path)?;
if installed_locally && !force && is_pinned_source(&resolved) {
status("pinned skill:", name);
continue;
}
let prior_checksum = lockfile
.skill
.iter()
.find(|skill| skill.name == *name)
.map(|skill| skill.checksum.clone());
if dry_run {
let scratch = tempfile::tempdir()
.context("failed to create temporary directory for --dry-run")?;
let scratch_target = scratch.path().to_path_buf();
let installed = install_skill(&resolved, &scratch_target)?;
let new_checksum = checksum_dir(&installed.path)?;
if !installed_locally {
status("would install skill:", name);
} else if prior_checksum.as_ref() == Some(&new_checksum) {
status("unchanged skill:", name);
} else {
status("would update skill:", name);
}
continue;
}
if installed_locally {
let existing = manifest.install.target.join(name);
fs::remove_dir_all(&existing)
.with_context(|| format!("failed to remove {}", existing.display()))?;
}
let installed = install_skill(&resolved, &manifest.install.target)?;
let new_checksum = checksum_dir(&installed.path)?;
let locked_resolved = installed
.resolved_sha
.as_deref()
.and_then(|sha| pin_resolved_with_sha(&resolved, sha))
.unwrap_or(resolved);
upsert_lock(
&mut lockfile,
LockedSkill {
name: installed.name.clone(),
namespace: installed.namespace.clone(),
source: source.clone(),
resolved: locked_resolved,
checksum: new_checksum.clone(),
},
);
if !installed_locally {
status("synced skill:", installed.name);
} else if prior_checksum.as_ref() == Some(&new_checksum) {
status("unchanged skill:", installed.name);
} else {
status("updated skill:", installed.name);
}
}
if !dry_run {
write_lockfile(&lockfile_path, &lockfile)?;
}
Ok(())
}
fn looks_like_sha(s: &str) -> bool {
matches!(s.len(), 7..=40) && s.chars().all(|c| c.is_ascii_hexdigit())
}
fn is_pinned_source(resolved: &str) -> bool {
extract_source_ref(resolved)
.as_deref()
.is_some_and(looks_like_sha)
}
fn extract_source_ref(source: &str) -> Option<String> {
if let Some(spec) = source.strip_prefix("gh:") {
return parse_github_spec(spec).ok().map(|s| s.reference);
}
if source.starts_with("git+") {
return parse_git_source(source).ok().map(|s| s.reference);
}
None
}
fn pin_resolved_with_sha(resolved: &str, sha: &str) -> Option<String> {
if let Some(spec_str) = resolved.strip_prefix("gh:") {
let spec = parse_github_spec(spec_str).ok()?;
let path = spec.skill_path.to_string_lossy().replace('\\', "/");
return Some(format!("gh:{}/{}@{sha}/{path}", spec.owner, spec.repo));
}
if resolved.starts_with("git+") {
let spec = parse_git_source(resolved).ok()?;
let path = spec.skill_path.to_string_lossy().replace('\\', "/");
return Some(format!("git+{}@{sha}//{path}", spec.repo_url));
}
if let Some(url) = resolved.strip_prefix("http+knack:") {
let base = url.split_once('#').map(|(b, _)| b).unwrap_or(url);
return Some(format!("http+knack:{base}#sha={sha}"));
}
None
}
fn resolve_source_alias(source: &str, manifest: &Manifest, manifest_path: &Path) -> Result<String> {
if source.starts_with("gh:") || source.starts_with("git+") || Path::new(source).exists() {
return Ok(source.to_string());
}
let registries = effective_registries(manifest, manifest_path)?;
if let Some((alias, rest)) = source.split_once(':') {
let Some(registry) = registries.get(alias) else {
if validate_registry_name(alias).is_ok() {
bail!(
"no registry registered as `{alias}` (resolving source `{source}`); {}",
unknown_registry_hint(®istries),
);
}
return Ok(source.to_string());
};
return match registry.kind {
RegistryKind::GitHost => resolve_git_host_alias(registry, rest),
RegistryKind::Http => resolve_http_alias(registry, rest),
};
}
if let Some(default_alias) = effective_default_registry(manifest, manifest_path, ®istries)? {
let Some(registry) = registries.get(&default_alias) else {
bail!(
"default_registry `{default_alias}` is set but no registry \
with that alias is configured; fix `install.default_registry` \
in {} or run `knack registry add`",
manifest_path.display(),
);
};
return match registry.kind {
RegistryKind::GitHost => resolve_git_host_alias(registry, source),
RegistryKind::Http => resolve_http_alias(registry, source),
};
}
Ok(source.to_string())
}
fn effective_default_registry(
manifest: &Manifest,
manifest_path: &Path,
registries: &std::collections::BTreeMap<String, RegistryConfig>,
) -> Result<Option<String>> {
if let Some(alias) = &manifest.install.default_registry {
return Ok(Some(alias.clone()));
}
for scope in [Scope::Project, Scope::Global, Scope::System] {
let scope_path = scope.manifest_path()?;
if scope_path == manifest_path {
continue;
}
if let Ok(scoped) = read_manifest(&scope_path) {
if let Some(alias) = scoped.install.default_registry {
return Ok(Some(alias));
}
}
}
if registries.len() == 1 {
return Ok(Some(registries.keys().next().unwrap().clone()));
}
Ok(None)
}
fn resolve_http_alias(registry: &RegistryConfig, rest: &str) -> Result<String> {
let path = if let Some((namespace, name)) = rest.split_once('/') {
if name.contains('/') {
bail!("invalid install source `{rest}`: namespace/name must contain exactly one `/`");
}
validate_skill_name(namespace)
.map_err(|err| anyhow!("invalid namespace `{namespace}`: {err}"))?;
validate_skill_name(name)?;
format!("{namespace}/{name}")
} else {
validate_skill_name(rest)?;
rest.to_string()
};
Ok(format!(
"http+knack:{}/skills/{}/archive",
registry.url.trim_end_matches('/'),
path
))
}
fn effective_registries(
manifest: &Manifest,
manifest_path: &Path,
) -> Result<std::collections::BTreeMap<String, RegistryConfig>> {
let mut registries = std::collections::BTreeMap::new();
let system_path = Scope::System.manifest_path()?;
let global_path = Scope::Global.manifest_path()?;
let project_path = Scope::Project.manifest_path()?;
for path in [&system_path, &global_path, &project_path] {
if let Some(m) = read_optional_manifest(path)? {
registries.extend(m.registries);
}
}
let passed_canon = manifest_path.canonicalize().ok();
let already_layered = passed_canon.as_ref().is_some_and(|p| {
[&system_path, &global_path, &project_path]
.iter()
.any(|s| s.canonicalize().ok().as_ref() == Some(p))
});
if !already_layered {
registries.extend(manifest.registries.clone());
}
Ok(registries)
}
fn resolve_git_host_alias(registry: &RegistryConfig, rest: &str) -> Result<String> {
let mut parts = rest.splitn(3, '/');
let owner = parts
.next()
.filter(|part| !part.is_empty())
.ok_or_else(|| anyhow!("registry source must be alias:owner/repo[@ref]/path/to/skill"))?;
let repo_with_ref = parts
.next()
.filter(|part| !part.is_empty())
.ok_or_else(|| anyhow!("registry source must include a repository"))?;
let skill_path = parts
.next()
.filter(|part| !part.is_empty())
.ok_or_else(|| anyhow!("registry source must include a skill path"))?;
let (repo, reference) = split_repo_ref(repo_with_ref, ®istry.default_ref)?;
let base_url = registry.url.trim_end_matches('/');
let repo_url = if base_url.starts_with("git+") {
format!("{}/{owner}/{repo}.git", base_url.trim_start_matches("git+"))
} else {
format!("{base_url}/{owner}/{repo}.git")
};
Ok(format!("git+{repo_url}@{reference}//{skill_path}"))
}
fn is_skill_installed(name: &str, target: &Path) -> bool {
target.join(name).join("SKILL.md").is_file()
}
#[derive(Debug)]
struct InstalledSkill {
name: String,
path: PathBuf,
resolved_sha: Option<String>,
namespace: Option<String>,
}
fn install_skill_with_sha_fallback<'a>(
primary: &'a str,
fallback_source: &'a str,
target: &PathBuf,
skill_name: &str,
) -> Result<(InstalledSkill, &'a str)> {
match install_skill(primary, target) {
Ok(installed) => Ok((installed, primary)),
Err(err) if is_pinned_source(primary) && primary != fallback_source => {
anstream::eprintln!(
"knack sync: lockfile pin for `{skill_name}` is no longer reachable; \
falling back to manifest source `{fallback_source}` ({err:#})"
);
let installed = install_skill(fallback_source, target).with_context(|| {
format!(
"fallback install of `{skill_name}` from manifest source also failed \
(the pinned lockfile ref was unreachable too)"
)
})?;
Ok((installed, fallback_source))
}
Err(err) => Err(err),
}
}
fn install_skill(source: &str, target: &PathBuf) -> Result<InstalledSkill> {
fs::create_dir_all(target).with_context(|| format!("failed to create {}", target.display()))?;
if let Some(spec) = source.strip_prefix("gh:") {
let fetched = fetch_github_skill(spec)?;
let mut installed = install_skill_dir(&fetched.path, target)?;
installed.resolved_sha = fetched.resolved_sha;
return Ok(installed);
}
if source.starts_with("git+") {
let spec = parse_git_source(source)?;
let fetched = fetch_git_skill(&spec)?;
let mut installed = install_skill_dir(&fetched.path, target)?;
installed.resolved_sha = fetched.resolved_sha;
return Ok(installed);
}
if let Some(url) = source.strip_prefix("http+knack:") {
return install_http_skill_archive(url, target);
}
let source = PathBuf::from(source);
install_local_skill(&source, target)
}
fn install_http_skill_archive(url: &str, target: &Path) -> Result<InstalledSkill> {
fs::create_dir_all(target).with_context(|| format!("failed to create {}", target.display()))?;
let request_url = url.split_once('#').map(|(base, _)| base).unwrap_or(url);
let response = reqwest::blocking::Client::new()
.get(request_url)
.header(reqwest::header::USER_AGENT, "knack")
.send()
.with_context(|| format!("failed to download {request_url}"))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
let hint = match extract_archive_skill_name(request_url) {
Some(name) => format!(
"skill `{name}` not found on the registry; \
try `knack find {name}` to look for related skills, \
or if you meant a local directory prefix it with `./`"
),
None => "the registry has no skill at that URL; \
try `knack find <query>` to discover skills"
.to_string(),
};
bail!("{hint} ({request_url})");
}
if response.status() == reqwest::StatusCode::CONFLICT {
let body = response
.text()
.unwrap_or_else(|_| "(unable to read registry response body)".to_string());
bail!("{body} ({request_url})");
}
let response = response
.error_for_status()
.with_context(|| format!("registry returned an error for {request_url}"))?;
let resolved_sha = response
.headers()
.get("x-knack-resolved-sha")
.and_then(|value| value.to_str().ok())
.filter(|s| looks_like_sha(s))
.map(String::from);
let namespace = response
.headers()
.get("x-knack-namespace")
.and_then(|value| value.to_str().ok())
.filter(|s| validate_skill_name(s).is_ok())
.map(String::from);
let bytes = response.bytes().context("failed to read skill archive")?;
let mut installed =
install_archive_reader(flate2::read::GzDecoder::new(Cursor::new(bytes)), target)?;
installed.resolved_sha = resolved_sha;
installed.namespace = namespace;
Ok(installed)
}
fn extract_archive_skill_name(url: &str) -> Option<&str> {
let trimmed = url.strip_suffix("/archive")?;
let after_skills = {
let needle = "/skills/";
let start = trimmed.rfind(needle)? + needle.len();
let candidate = &trimmed[start..];
if candidate.is_empty() || candidate.contains("//") {
return None;
}
candidate
};
Some(after_skills)
}
fn install_archive_reader<R: std::io::Read>(reader: R, target: &Path) -> Result<InstalledSkill> {
let mut archive = tar::Archive::new(reader);
let temp_dir = tempfile::tempdir().context("failed to create temporary directory")?;
archive
.unpack(temp_dir.path())
.context("failed to unpack skill archive")?;
let root = single_child_dir(temp_dir.path())?;
install_skill_dir(&root, target)
}
fn install_local_skill(source: &Path, target: &Path) -> Result<InstalledSkill> {
if source.is_dir() {
return install_skill_dir(source, target);
}
if source.is_file() {
let file =
File::open(source).with_context(|| format!("failed to open {}", source.display()))?;
return install_archive_reader(flate2::read::GzDecoder::new(file), target);
}
bail!("install source does not exist: {}", source.display());
}
#[derive(Debug)]
struct GithubSpec {
owner: String,
repo: String,
reference: String,
skill_path: PathBuf,
}
#[derive(Debug)]
struct FetchedSkill {
path: PathBuf,
_temp_dir: TempDir,
resolved_sha: Option<String>,
}
fn fetch_github_skill(spec: &str) -> Result<FetchedSkill> {
let spec = parse_github_spec(spec)?;
let archive_url = format!(
"https://github.com/{}/{}/archive/{}.tar.gz",
spec.owner, spec.repo, spec.reference
);
let response = reqwest::blocking::Client::new()
.get(&archive_url)
.header(reqwest::header::USER_AGENT, "knack")
.send()
.with_context(|| format!("failed to fetch {archive_url}"))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
bail!(
"GitHub returned 404 for {}/{} at ref `{}`; \
check that the owner, repo, and ref exist and are public, \
or that the skill path within the repo is correct ({})",
spec.owner,
spec.repo,
spec.reference,
archive_url,
);
}
let response = response
.error_for_status()
.with_context(|| format!("GitHub returned an error for {archive_url}"))?;
let bytes = response
.bytes()
.with_context(|| format!("failed to read {archive_url}"))?;
let temp_dir = tempfile::tempdir().context("failed to create temporary directory")?;
let decoder = flate2::read::GzDecoder::new(Cursor::new(bytes));
let mut archive = tar::Archive::new(decoder);
archive
.unpack(temp_dir.path())
.context("failed to unpack GitHub archive")?;
let repo_root = single_child_dir(temp_dir.path())?;
let resolved_sha = repo_root
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_prefix(&format!("{}-", spec.repo)))
.filter(|suffix| looks_like_sha(suffix))
.map(String::from);
let skill_dir = repo_root.join(&spec.skill_path);
let skill = read_skill(&skill_dir).with_context(|| {
format!(
"GitHub source did not resolve to a valid skill directory: {}",
skill_dir.display()
)
})?;
validate_skill(&skill)?;
Ok(FetchedSkill {
path: skill_dir,
_temp_dir: temp_dir,
resolved_sha,
})
}
#[derive(Debug)]
struct GitSourceSpec {
repo_url: String,
reference: String,
skill_path: PathBuf,
}
fn fetch_git_skill(spec: &GitSourceSpec) -> Result<FetchedSkill> {
let temp_dir = tempfile::tempdir().context("failed to create temporary directory")?;
let repo_dir = temp_dir.path().join("repo");
let repo_dir_str = repo_dir.to_str().unwrap_or_default();
if looks_like_sha(&spec.reference) {
let clone_action = format!("clone {} for SHA ref {}", spec.repo_url, spec.reference);
run_git(["clone", &spec.repo_url, repo_dir_str], None, &clone_action)?;
let checkout_action = format!("check out {} in {}", spec.reference, spec.repo_url);
run_git(
["checkout", "--quiet", &spec.reference],
Some(&repo_dir),
&checkout_action,
)?;
} else {
let action = format!("clone {} at ref {}", spec.repo_url, spec.reference);
run_git(
[
"clone",
"--depth",
"1",
"--branch",
&spec.reference,
&spec.repo_url,
repo_dir_str,
],
None,
&action,
)?;
}
let resolved_sha = capture_git_head_sha(&repo_dir).ok();
let skill_dir = repo_dir.join(&spec.skill_path);
let skill = read_skill(&skill_dir).with_context(|| {
format!(
"Git source did not resolve to a valid skill directory: {}",
skill_dir.display()
)
})?;
validate_skill(&skill)?;
Ok(FetchedSkill {
path: skill_dir,
_temp_dir: temp_dir,
resolved_sha,
})
}
fn capture_git_head_sha(repo_dir: &Path) -> Result<String> {
let output = ProcessCommand::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo_dir)
.output()
.context("failed to invoke git rev-parse HEAD")?;
if !output.status.success() {
bail!("git rev-parse HEAD failed");
}
let sha = String::from_utf8(output.stdout)
.context("git rev-parse HEAD returned non-UTF-8")?
.trim()
.to_string();
if !looks_like_sha(&sha) {
bail!("git rev-parse HEAD returned a non-SHA-shaped value: {sha}");
}
Ok(sha)
}
fn parse_git_source(source: &str) -> Result<GitSourceSpec> {
let (repo_part, skill_path) = source
.rsplit_once("//")
.ok_or_else(|| anyhow!("git source must be git+<url>[@ref]//path/to/skill"))?;
let skill_path = skill_path.trim_matches('/');
if skill_path.is_empty() {
bail!("git source must include a skill path after //");
}
let without_scheme = repo_part
.strip_prefix("git+")
.ok_or_else(|| anyhow!("git source must start with git+"))?;
let (repo_url, reference) = split_repo_ref(without_scheme, "main")?;
Ok(GitSourceSpec {
repo_url: repo_url.to_string(),
reference: reference.to_string(),
skill_path: PathBuf::from(skill_path),
})
}
fn split_repo_ref<'a>(repo_with_ref: &'a str, default_ref: &'a str) -> Result<(&'a str, &'a str)> {
let at_position = repo_with_ref.rfind('@');
let Some(position) = at_position else {
return Ok((repo_with_ref, default_ref));
};
let scheme_position = repo_with_ref.find("://");
if scheme_position.is_some_and(|scheme| position < scheme) {
return Ok((repo_with_ref, default_ref));
}
let (repo, reference_with_at) = repo_with_ref.split_at(position);
let reference = &reference_with_at[1..];
if repo.is_empty() || reference.is_empty() {
bail!("git source repository and ref must not be empty");
}
Ok((repo, reference))
}
fn parse_github_spec(spec: &str) -> Result<GithubSpec> {
let mut parts = spec.splitn(3, '/');
let owner = parts
.next()
.filter(|part| !part.is_empty())
.ok_or_else(|| anyhow!("GitHub source must be gh:owner/repo[@ref]/path/to/skill"))?;
let repo_with_ref = parts
.next()
.filter(|part| !part.is_empty())
.ok_or_else(|| anyhow!("GitHub source must include a repository"))?;
let skill_path = parts
.next()
.filter(|part| !part.is_empty())
.ok_or_else(|| anyhow!("GitHub source must include a skill path"))?;
let (repo, reference) = repo_with_ref
.split_once('@')
.unwrap_or((repo_with_ref, "main"));
if repo.is_empty() || reference.is_empty() {
bail!("GitHub source repository and ref must not be empty");
}
Ok(GithubSpec {
owner: owner.to_string(),
repo: repo.to_string(),
reference: reference.to_string(),
skill_path: PathBuf::from(skill_path),
})
}
fn single_child_dir(path: &Path) -> Result<PathBuf> {
let mut children = fs::read_dir(path)
.with_context(|| format!("failed to read {}", path.display()))?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.file_type()
.map(|file_type| file_type.is_dir())
.unwrap_or(false)
})
.map(|entry| entry.path());
let child = children
.next()
.ok_or_else(|| anyhow!("GitHub archive did not contain a repository directory"))?;
if children.next().is_some() {
bail!("GitHub archive contained multiple repository directories");
}
Ok(child)
}
fn install_skill_dir(source: &Path, target: &Path) -> Result<InstalledSkill> {
let skill = read_skill(source)?;
validate_skill(&skill)?;
let destination = target.join(&skill.name);
if destination.exists() {
bail!("skill already installed: {}", destination.display());
}
copy_dir(source, &destination)?;
Ok(InstalledSkill {
name: skill.name,
path: destination,
resolved_sha: None,
namespace: None,
})
}
fn list_skills(explicit_target: Option<&Path>) -> Result<()> {
if let Some(target) = explicit_target {
let skills = collect_installed_skills(target)?;
print_skill_names(&skills);
return Ok(());
}
let project_target = Scope::Project.install_target()?;
let global_target = Scope::Global.install_target()?;
let project_skills = collect_installed_skills(&project_target)?;
let global_skills = collect_installed_skills(&global_target)?;
if project_skills.is_empty() && global_skills.is_empty() {
notice("no skills installed");
return Ok(());
}
let mut printed_section = false;
if !project_skills.is_empty() {
print_skills_section("project", &project_target, &project_skills);
printed_section = true;
}
if !global_skills.is_empty() {
if printed_section {
anstream::println!();
}
print_skills_section("global", &global_target, &global_skills);
}
Ok(())
}
fn collect_installed_skills(target: &Path) -> Result<Vec<String>> {
if !target.exists() {
return Ok(Vec::new());
}
let mut skills = Vec::new();
for entry in
fs::read_dir(target).with_context(|| format!("failed to read {}", target.display()))?
{
let entry = entry?;
let path = entry.path();
if path.is_dir() && path.join("SKILL.md").is_file() {
let skill = read_skill(&path)?;
validate_skill(&skill)?;
skills.push(skill.name);
}
}
skills.sort();
Ok(skills)
}
fn print_skill_names(skills: &[String]) {
let skill_style = accent_style();
for skill in skills {
anstream::println!("{skill_style}{skill}{skill_style:#}");
}
}
fn print_skills_section(label: &str, target: &Path, skills: &[String]) {
let heading_style = label_style();
let path_style = label_style();
let skill_style = accent_style();
anstream::println!(
"{heading_style}{label}{heading_style:#} {path_style}({}){path_style:#}",
target.display()
);
for name in skills {
anstream::println!(" {skill_style}{name}{skill_style:#}");
}
}
fn copy_dir(source: &Path, destination: &Path) -> Result<()> {
fs::create_dir_all(destination)
.with_context(|| format!("failed to create {}", destination.display()))?;
for entry in
fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))?
{
let entry = entry?;
let source_path = entry.path();
let destination_path = destination.join(entry.file_name());
let file_type = entry.file_type()?;
if file_type.is_dir() {
copy_dir(&source_path, &destination_path)?;
} else if file_type.is_file() {
fs::copy(&source_path, &destination_path).with_context(|| {
format!(
"failed to copy {} to {}",
source_path.display(),
destination_path.display()
)
})?;
}
}
Ok(())
}
fn pack_skill(path: &PathBuf, output: &PathBuf) -> Result<PathBuf> {
let skill = read_skill(path)?;
validate_skill(&skill)?;
fs::create_dir_all(output).with_context(|| format!("failed to create {}", output.display()))?;
let archive_path = output.join(format!("{}.skill.tar.gz", skill.name));
let archive_file = File::create(&archive_path)
.with_context(|| format!("failed to create {}", archive_path.display()))?;
let encoder = GzEncoder::new(archive_file, Compression::default());
let mut archive = Builder::new(encoder);
let files = collect_files(path)?;
for file in files {
let relative_path = file.strip_prefix(path).with_context(|| {
format!(
"failed to make {} relative to {}",
file.display(),
path.display()
)
})?;
let archive_name = Path::new(&skill.name).join(relative_path);
append_file(&mut archive, &file, &archive_name)?;
}
archive.finish()?;
Ok(archive_path)
}
fn append_file(
archive: &mut Builder<GzEncoder<File>>,
source: &Path,
archive_name: &Path,
) -> Result<()> {
let mut file =
File::open(source).with_context(|| format!("failed to open {}", source.display()))?;
let metadata = file
.metadata()
.with_context(|| format!("failed to stat {}", source.display()))?;
let mut header = Header::new_gnu();
header.set_size(metadata.len());
header.set_mode(0o644);
header.set_mtime(0);
header.set_uid(0);
header.set_gid(0);
header.set_cksum();
archive
.append_data(&mut header, archive_name, &mut file)
.with_context(|| format!("failed to archive {}", source.display()))?;
Ok(())
}
fn new_skill(name: &str, dir: PathBuf) -> Result<()> {
validate_skill_name(name)?;
let skill_dir = dir.join(name);
if skill_dir.exists() {
bail!("skill directory already exists: {}", skill_dir.display());
}
fs::create_dir_all(&skill_dir)
.with_context(|| format!("failed to create {}", skill_dir.display()))?;
let skill_file = skill_dir.join("SKILL.md");
let content = format!(
"---\nname: {name}\ndescription: \"TODO: Describe what this skill does and when to use it.\"\n---\n\n# {name}\n\nWrite concise instructions for the agent here.\n"
);
fs::write(&skill_file, content)
.with_context(|| format!("failed to write {}", skill_file.display()))?;
status("created skill:", skill_dir.display());
Ok(())
}