use std::{
collections::{BTreeMap, BTreeSet, HashMap},
fs::File,
net::SocketAddr,
path::{Path, PathBuf},
process::Command as ProcessCommand,
sync::Arc,
time::Duration,
};
use anyhow::{Context, Result, bail};
use axum::{
Json, Router,
body::Body,
extract::{Path as AxumPath, Query, State},
http::{StatusCode, header},
response::{IntoResponse, Response},
routing::get,
};
use clap::builder::styling::{AnsiColor, Effects, Styles};
use clap::{Args, 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());
use flate2::{Compression, write::GzEncoder};
use knack_core::{
IndexedSkill, RegistryIndex, collect_files, read_skill, validate_skill_metadata,
validate_skill_name,
};
use serde::Deserialize;
use tar::{Builder, Header};
use tokio::sync::RwLock;
#[derive(Debug, Parser)]
#[command(name = "knack-registry")]
#[command(version, about = "Serve and search a knack registry index")]
#[command(styles = HELP_STYLES)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[command(flatten)]
serve: ServeArgs,
}
#[derive(Debug, Subcommand)]
enum Command {
BuildStatic(BuildStaticArgs),
}
#[derive(Debug, Args)]
struct ServeArgs {
#[arg(long, default_value = "knack.index.toml")]
index: PathBuf,
#[arg(long, default_value = "127.0.0.1:7349")]
bind: SocketAddr,
#[arg(long)]
skills_root: Option<PathBuf>,
#[arg(long)]
name: Option<String>,
#[arg(long = "source-alias")]
source_aliases: Vec<String>,
#[arg(long, default_value_t = 300)]
refresh_interval_seconds: u64,
#[arg(long)]
cache_dir: Option<PathBuf>,
}
#[derive(Debug, Args)]
struct BuildStaticArgs {
#[arg(long, default_value = "knack.index.toml")]
index: PathBuf,
#[arg(long)]
output: PathBuf,
#[arg(long)]
name: Option<String>,
#[arg(long = "source-alias")]
source_aliases: Vec<String>,
}
#[derive(Clone)]
struct AppState {
state: Arc<RwLock<IndexedState>>,
index_path: PathBuf,
skills_root: Option<PathBuf>,
name: Option<String>,
source_aliases: BTreeMap<String, String>,
}
#[derive(Debug, Default)]
struct IndexedState {
index: RegistryIndex,
locations: HashMap<String, SkillLocation>,
}
#[derive(Debug, Clone)]
struct SkillLocation {
cached: Arc<CachedSource>,
relative: PathBuf,
}
#[derive(Debug)]
struct CachedSource {
repo_dir: PathBuf,
sha: tokio::sync::RwLock<Option<String>>,
refresh_lock: tokio::sync::RwLock<()>,
}
#[derive(Debug)]
struct SourceCache {
base_dir: PathBuf,
entries: std::sync::RwLock<HashMap<String, Arc<CachedSource>>>,
_tempdir: Option<tempfile::TempDir>,
}
impl SourceCache {
fn new(base_dir: PathBuf, tempdir: Option<tempfile::TempDir>) -> Result<Self> {
std::fs::create_dir_all(&base_dir)
.with_context(|| format!("failed to create cache dir {}", base_dir.display()))?;
Ok(Self {
base_dir,
entries: std::sync::RwLock::new(HashMap::new()),
_tempdir: tempdir,
})
}
fn slot(&self, source: &str) -> Arc<CachedSource> {
if let Some(existing) = self.entries.read().unwrap().get(source) {
return existing.clone();
}
let mut guard = self.entries.write().unwrap();
if let Some(existing) = guard.get(source) {
return existing.clone();
}
let repo_dir = self.base_dir.join(cache_subdir_name(source));
let entry = Arc::new(CachedSource {
repo_dir,
sha: tokio::sync::RwLock::new(None),
refresh_lock: tokio::sync::RwLock::new(()),
});
guard.insert(source.to_string(), entry.clone());
entry
}
fn prune_stale(&self, active: &BTreeSet<String>) {
let active_subdirs: BTreeSet<String> =
active.iter().map(|s| cache_subdir_name(s)).collect();
let mut guard = self.entries.write().unwrap();
let stale_keys: Vec<String> = guard
.keys()
.filter(|key| !active.contains(*key))
.cloned()
.collect();
for key in stale_keys {
if let Some(entry) = guard.remove(&key) {
if let Err(err) = std::fs::remove_dir_all(&entry.repo_dir) {
eprintln!(
"failed to remove stale cache dir {}: {err:#}",
entry.repo_dir.display()
);
}
}
}
drop(guard);
match std::fs::read_dir(&self.base_dir) {
Ok(iter) => {
for entry in iter.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if active_subdirs.contains(name) {
continue;
}
if let Err(err) = std::fs::remove_dir_all(&path) {
eprintln!(
"failed to remove orphan cache dir {}: {err:#}",
path.display()
);
}
}
}
Err(err) => {
eprintln!(
"failed to scan cache dir {} for orphans: {err:#}",
self.base_dir.display()
);
}
}
}
}
fn cache_subdir_name(source: &str) -> String {
source
.chars()
.map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '.' => c,
_ => '_',
})
.collect()
}
#[derive(serde::Serialize)]
struct RegistryInfo {
name: Option<String>,
version: &'static str,
}
#[derive(Debug, Deserialize)]
struct SearchParams {
q: String,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Some(Command::BuildStatic(args)) => build_static(args).await,
None => serve(cli.serve).await,
}
}
async fn serve(args: ServeArgs) -> Result<()> {
let source_aliases = parse_source_aliases(&args.source_aliases)?;
let (cache_base, cache_tempdir) = match args.cache_dir.clone() {
Some(path) => (path, None),
None => {
let tempdir = tempfile::tempdir().context("failed to create cache tempdir")?;
(tempdir.path().to_path_buf(), Some(tempdir))
}
};
let source_cache = Arc::new(SourceCache::new(cache_base, cache_tempdir)?);
let initial = refresh_index_and_cache(&args.index, &source_aliases, &source_cache).await?;
let state = AppState {
state: Arc::new(RwLock::new(initial)),
index_path: args.index,
skills_root: args.skills_root,
name: args.name,
source_aliases,
};
if args.refresh_interval_seconds > 0 {
spawn_refresh_task(
state.state.clone(),
state.index_path.clone(),
state.source_aliases.clone(),
source_cache,
Duration::from_secs(args.refresh_interval_seconds),
);
}
let app = Router::new()
.route("/health", get(health))
.route("/info", get(info))
.route("/index", get(get_index))
.route("/search", get(search))
.route(
"/skills/{namespace}/{name}/archive",
get(skill_archive_namespaced),
)
.route("/skills/{name}/archive", get(skill_archive_legacy))
.with_state(state);
let listener = tokio::net::TcpListener::bind(args.bind)
.await
.with_context(|| format!("failed to bind {}", args.bind))?;
println!("knack-registry listening on http://{}", args.bind);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.context("registry server failed")?;
Ok(())
}
async fn build_static(args: BuildStaticArgs) -> Result<()> {
let source_aliases = parse_source_aliases(&args.source_aliases)?;
let cache_tempdir = tempfile::tempdir().context("failed to create cache tempdir")?;
let cache = Arc::new(SourceCache::new(
cache_tempdir.path().to_path_buf(),
Some(cache_tempdir),
)?);
eprintln!("materialising index from {}...", args.index.display());
let indexed = refresh_index_and_cache(&args.index, &source_aliases, &cache).await?;
eprintln!(
"materialised {} skill(s) from {} [[source]] entry(ies)",
indexed.locations.len(),
indexed.index.source.len(),
);
std::fs::create_dir_all(&args.output)
.with_context(|| format!("failed to create output dir {}", args.output.display()))?;
let skills_dir = args.output.join("skills");
if skills_dir.exists() {
std::fs::remove_dir_all(&skills_dir).with_context(|| {
format!("failed to clear stale skills dir {}", skills_dir.display())
})?;
}
std::fs::create_dir_all(&skills_dir)
.with_context(|| format!("failed to create {}", skills_dir.display()))?;
let info = RegistryInfo {
name: args.name.clone(),
version: env!("CARGO_PKG_VERSION"),
};
let info_path = args.output.join("info.json");
std::fs::write(&info_path, serde_json::to_string_pretty(&info)?)
.with_context(|| format!("failed to write {}", info_path.display()))?;
let mut index = indexed.index.clone();
if let Some(name) = &args.name {
for skill in &mut index.skill {
skill.source = format!("{}:{}", name, skill.qualified_name());
}
}
let index_path = args.output.join("index.json");
std::fs::write(&index_path, serde_json::to_string_pretty(&index)?)
.with_context(|| format!("failed to write {}", index_path.display()))?;
let mut sha_map: BTreeMap<String, String> = BTreeMap::new();
let mut archive_count = 0usize;
for (qualified, location) in &indexed.locations {
if let Some(sha) = location.cached.sha.read().await.clone() {
sha_map.insert(qualified.clone(), sha);
}
let skill_dir = location.cached.repo_dir.join(&location.relative);
let tarball = create_skill_archive_from_dir(&skill_dir)
.with_context(|| format!("failed to archive skill {qualified}"))?;
let out_path = skills_dir.join(format!("{qualified}.skill.tar.gz"));
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create namespace dir {}", parent.display()))?;
}
std::fs::write(&out_path, &tarball)
.with_context(|| format!("failed to write {}", out_path.display()))?;
archive_count += 1;
}
let sha_map_path = args.output.join("sha-map.json");
std::fs::write(&sha_map_path, serde_json::to_string_pretty(&sha_map)?)
.with_context(|| format!("failed to write {}", sha_map_path.display()))?;
eprintln!(
"wrote {} (info), {} (index), {} archives, {} (sha-map)",
info_path.display(),
index_path.display(),
archive_count,
sha_map_path.display()
);
eprintln!("static snapshot ready at {}", args.output.display());
Ok(())
}
fn qualified_key(namespace: &Option<String>, name: &str) -> String {
match namespace {
Some(ns) => format!("{ns}/{name}"),
None => name.to_string(),
}
}
fn read_index(path: &Path) -> Result<RegistryIndex> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let index: RegistryIndex =
toml::from_str(&contents).with_context(|| format!("failed to parse {}", path.display()))?;
index.validate()?;
Ok(index)
}
async fn refresh_index_and_cache(
path: &Path,
source_aliases: &BTreeMap<String, String>,
cache: &SourceCache,
) -> Result<IndexedState> {
let mut index = read_index(path)?;
let mut locations: HashMap<String, SkillLocation> = HashMap::new();
let mut active_sources: BTreeSet<String> = BTreeSet::new();
for i in 0..index.skill.len() {
let static_skill = index.skill[i].clone();
active_sources.insert(static_skill.source.clone());
let resolved_namespace = static_skill
.namespace
.clone()
.or_else(|| infer_namespace_from_source(&static_skill.source));
let qualified = qualified_key(&resolved_namespace, &static_skill.name);
let cached = cache.slot(&static_skill.source);
if let Err(err) = refresh_cached_source(&cached, &static_skill.source, source_aliases).await
{
eprintln!(
"failed to refresh static entry {} from {}: {err:#}",
qualified, static_skill.source
);
continue;
}
let relative = source_subpath(&static_skill.source, source_aliases).unwrap_or_default();
let skill_md = cached.repo_dir.join(&relative).join("SKILL.md");
if !skill_md.is_file() {
eprintln!(
"static skill {} has no SKILL.md at {}",
qualified,
skill_md.display()
);
continue;
}
index.skill[i].namespace = resolved_namespace;
locations.insert(
qualified,
SkillLocation {
cached: cached.clone(),
relative,
},
);
}
let dynamic_sources = index.source.clone();
for source in dynamic_sources {
active_sources.insert(source.source.clone());
let cached = cache.slot(&source.source);
if let Err(err) = refresh_cached_source(&cached, &source.source, source_aliases).await {
eprintln!(
"failed to refresh dynamic source {}: {err:#}",
source.source
);
continue;
}
let effective_namespace = source
.namespace
.clone()
.or_else(|| infer_namespace_from_source(&source.source));
let subpath = source_subpath(&source.source, source_aliases).unwrap_or_default();
let walk_root = cached.repo_dir.join(&subpath);
let skill_dirs = match collect_skill_dirs(&walk_root) {
Ok(dirs) => dirs,
Err(err) => {
eprintln!("failed to walk {} for skills: {err:#}", walk_root.display());
continue;
}
};
for skill_dir in skill_dirs {
let skill = match read_skill(&skill_dir) {
Ok(skill) => skill,
Err(err) => {
eprintln!(
"skipping {}: failed to read SKILL.md: {err:#}",
skill_dir.display()
);
continue;
}
};
if let Err(err) = validate_skill_metadata(&skill) {
eprintln!("skipping {}: {err:#}", skill_dir.display());
continue;
}
let qualified = qualified_key(&effective_namespace, &skill.name);
if locations.contains_key(&qualified) {
eprintln!(
"warn: skipped duplicate skill `{qualified}` from {} \
(already provided by an earlier source)",
source.source
);
continue;
}
let relative_to_walk = skill_dir.strip_prefix(&walk_root).with_context(|| {
format!(
"failed to make {} relative to {}",
skill_dir.display(),
walk_root.display()
)
})?;
let relative_for_url = relative_to_walk.to_string_lossy().replace('\\', "/");
let skill_source = if relative_for_url.is_empty() {
source.source.clone()
} else {
format!(
"{}/{}",
source.source.trim_end_matches('/'),
relative_for_url
)
};
let relative_to_repo = subpath.join(relative_to_walk);
locations.insert(
qualified,
SkillLocation {
cached: cached.clone(),
relative: relative_to_repo,
},
);
index.skill.push(IndexedSkill {
name: skill.name,
namespace: effective_namespace.clone(),
description: skill.description,
source: skill_source,
tags: source.tags.clone(),
score: None,
});
}
}
index.skill.sort_by_key(|skill| skill.qualified_name());
index.validate()?;
cache.prune_stale(&active_sources);
Ok(IndexedState { index, locations })
}
fn spawn_refresh_task(
state: Arc<RwLock<IndexedState>>,
index_path: PathBuf,
source_aliases: BTreeMap<String, String>,
cache: Arc<SourceCache>,
interval: Duration,
) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
ticker.tick().await;
loop {
ticker.tick().await;
match refresh_index_and_cache(&index_path, &source_aliases, &cache).await {
Ok(refreshed) => {
let mut guard = state.write().await;
*guard = refreshed;
eprintln!("refreshed knack registry index");
}
Err(error) => {
eprintln!("failed to refresh knack registry index: {error:#}");
}
}
}
});
}
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
std::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"))
}
async fn health() -> &'static str {
"ok"
}
async fn info(State(state): State<AppState>) -> Json<RegistryInfo> {
Json(RegistryInfo {
name: state.name.clone(),
version: env!("CARGO_PKG_VERSION"),
})
}
async fn get_index(State(state): State<AppState>) -> Json<RegistryIndex> {
Json(state.state.read().await.index.clone())
}
async fn search(
State(state): State<AppState>,
Query(params): Query<SearchParams>,
) -> Json<Vec<IndexedSkill>> {
let guard = state.state.read().await;
let mut results: Vec<IndexedSkill> = guard
.index
.search(¶ms.q)
.into_iter()
.map(|(skill, score)| {
let mut skill = skill.clone();
skill.score = Some(score);
skill
})
.collect();
drop(guard);
if let Some(name) = &state.name {
for skill in &mut results {
skill.source = format!("{}:{}", name, skill.qualified_name());
}
}
Json(results)
}
async fn skill_archive_namespaced(
State(state): State<AppState>,
AxumPath((namespace, name)): AxumPath<(String, String)>,
) -> Response {
if validate_skill_name(&namespace).is_err() || validate_skill_name(&name).is_err() {
return (
StatusCode::BAD_REQUEST,
"namespace and name must be kebab-case identifiers",
)
.into_response();
}
let qualified = format!("{namespace}/{name}");
archive_response(&state, &qualified, Some(&namespace), &name).await
}
async fn skill_archive_legacy(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
) -> Response {
if validate_skill_name(&name).is_err() {
return (
StatusCode::BAD_REQUEST,
"skill name must be a kebab-case identifier",
)
.into_response();
}
let matches: Vec<(Option<String>, String)> = {
let guard = state.state.read().await;
guard
.index
.skill
.iter()
.filter(|skill| skill.name == name)
.map(|skill| (skill.namespace.clone(), skill.qualified_name()))
.collect()
};
match matches.len() {
0 => (StatusCode::NOT_FOUND, format!("skill not found: {name}")).into_response(),
1 => {
let (namespace, qualified) = matches.into_iter().next().expect("len checked");
archive_response(&state, &qualified, namespace.as_deref(), &name).await
}
_ => {
let qualifieds: Vec<String> = matches.into_iter().map(|(_, q)| q).collect();
let hint = format!(
"skill `{name}` is ambiguous across namespaces: [{}]; \
retry as one of the namespaced forms above",
qualifieds.join(", ")
);
(StatusCode::CONFLICT, hint).into_response()
}
}
}
async fn archive_response(
state: &AppState,
qualified: &str,
namespace: Option<&str>,
name: &str,
) -> Response {
match create_skill_archive(state, qualified, name).await {
Ok(archive) => {
let disposition = format!("attachment; filename=\"{name}.skill.tar.gz\"");
let mut headers = axum::http::HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/gzip"),
);
if let Ok(value) = axum::http::HeaderValue::from_str(&disposition) {
headers.insert(header::CONTENT_DISPOSITION, value);
}
if let Some(sha) = archive.resolved_sha {
if let Ok(value) = axum::http::HeaderValue::from_str(&sha) {
headers.insert(
axum::http::HeaderName::from_static("x-knack-resolved-sha"),
value,
);
}
}
if let Some(ns) = namespace {
if let Ok(value) = axum::http::HeaderValue::from_str(ns) {
headers.insert(
axum::http::HeaderName::from_static("x-knack-namespace"),
value,
);
}
}
(headers, Body::from(archive.bytes)).into_response()
}
Err(error) => (StatusCode::NOT_FOUND, error.to_string()).into_response(),
}
}
struct SkillArchive {
bytes: Vec<u8>,
resolved_sha: Option<String>,
}
async fn create_skill_archive(
state: &AppState,
qualified: &str,
bare_name: &str,
) -> Result<SkillArchive> {
if let Some(skills_root) = &state.skills_root {
let skill_dir = skills_root.join(bare_name);
if skill_dir.join("SKILL.md").is_file() {
return Ok(SkillArchive {
bytes: create_skill_archive_from_dir(&skill_dir)?,
resolved_sha: None,
});
}
}
let location = {
let guard = state.state.read().await;
guard
.locations
.get(qualified)
.cloned()
.with_context(|| format!("skill not found: {qualified}"))?
};
let _read_guard = location.cached.refresh_lock.read().await;
let resolved_sha = location.cached.sha.read().await.clone();
let skill_dir = location.cached.repo_dir.join(&location.relative);
Ok(SkillArchive {
bytes: create_skill_archive_from_dir(&skill_dir)?,
resolved_sha,
})
}
fn create_skill_archive_from_dir(skill_dir: &Path) -> Result<Vec<u8>> {
let skill = read_skill(skill_dir)?;
validate_skill_metadata(&skill)?;
let buffer = Vec::new();
let encoder = GzEncoder::new(buffer, Compression::default());
let mut archive = Builder::new(encoder);
for file in collect_files(skill_dir)? {
let relative = file.strip_prefix(skill_dir).with_context(|| {
format!(
"failed to make {} relative to {}",
file.display(),
skill_dir.display()
)
})?;
let archive_name = Path::new(&skill.name).join(relative);
append_file(&mut archive, &file, &archive_name)?;
}
archive.finish()?;
let encoder = archive.into_inner()?;
Ok(encoder.finish()?)
}
#[derive(Debug)]
struct ParsedSource {
repo_url: String,
reference: String,
subpath: PathBuf,
}
fn infer_namespace_from_source(source: &str) -> Option<String> {
let rest = if let Some(spec) = source.strip_prefix("gh:") {
spec
} else {
let (_alias, rest) = source.split_once(':')?;
rest
};
let owner = rest.split('/').next()?;
let owner = owner.split_once('@').map_or(owner, |(o, _)| o);
if owner.is_empty() {
return None;
}
validate_skill_name(owner).ok()?;
Some(owner.to_string())
}
fn parse_source(source: &str, source_aliases: &BTreeMap<String, String>) -> Result<ParsedSource> {
if let Some(spec) = source.strip_prefix("gh:") {
let github = parse_github_spec_for_registry(spec)?;
return Ok(ParsedSource {
repo_url: format!("https://github.com/{}/{}.git", github.owner, github.repo),
reference: github.reference,
subpath: github.skill_path,
});
}
let (alias, rest) = source
.split_once(':')
.ok_or_else(|| anyhow::anyhow!("backing source must be alias:owner/repo[@ref]/path"))?;
let base_url = source_aliases.get(alias).with_context(|| {
format!(
"source alias not configured on registry: {alias} \
(built-in `gh:` is also accepted for github.com)"
)
})?;
let git = parse_git_host_source(base_url, rest)?;
Ok(ParsedSource {
repo_url: git.repo_url,
reference: git.reference,
subpath: git.skill_path,
})
}
fn source_subpath(source: &str, source_aliases: &BTreeMap<String, String>) -> Result<PathBuf> {
Ok(parse_source(source, source_aliases)?.subpath)
}
async fn refresh_cached_source(
cached: &CachedSource,
source: &str,
source_aliases: &BTreeMap<String, String>,
) -> Result<()> {
let _write_guard = cached.refresh_lock.write().await;
let parsed = parse_source(source, source_aliases)?;
let has_git = cached.repo_dir.join(".git").is_dir();
if has_git {
match incremental_fetch(&cached.repo_dir, &parsed.reference) {
Ok(()) => {}
Err(err) => {
eprintln!(
"incremental refresh of {source} failed ({err:#}), \
rebuilding from scratch"
);
if cached.repo_dir.exists() {
std::fs::remove_dir_all(&cached.repo_dir).with_context(|| {
format!(
"failed to remove stale cache dir {}",
cached.repo_dir.display()
)
})?;
}
clone_into_cache_dir(&parsed, &cached.repo_dir)?;
}
}
} else {
if cached.repo_dir.exists() {
std::fs::remove_dir_all(&cached.repo_dir).with_context(|| {
format!(
"failed to remove partial cache dir {}",
cached.repo_dir.display()
)
})?;
}
clone_into_cache_dir(&parsed, &cached.repo_dir)?;
}
let sha = capture_git_head_sha(&cached.repo_dir).ok();
*cached.sha.write().await = sha;
Ok(())
}
fn incremental_fetch(repo_dir: &Path, reference: &str) -> Result<()> {
run_git(
["fetch", "--depth=1", "origin", reference],
Some(repo_dir),
"incremental fetch",
)?;
run_git(
["reset", "--hard", "FETCH_HEAD"],
Some(repo_dir),
"reset to fetched head",
)?;
let _ = run_git(
["gc", "--auto"],
Some(repo_dir),
"auto gc after incremental fetch",
);
Ok(())
}
fn clone_into_cache_dir(parsed: &ParsedSource, repo_dir: &Path) -> Result<()> {
let subpath = parsed.subpath.to_str().unwrap_or("");
if !subpath.is_empty() {
match sparse_clone(&parsed.repo_url, &parsed.reference, subpath, repo_dir) {
Ok(()) => return Ok(()),
Err(err) => {
eprintln!(
"sparse clone of {} at {} (subpath {subpath}) failed, \
falling back to full clone: {err:#}",
parsed.repo_url, parsed.reference
);
if repo_dir.exists() {
std::fs::remove_dir_all(repo_dir).with_context(|| {
format!(
"failed to remove partial clone at {} before fallback",
repo_dir.display()
)
})?;
}
}
}
}
full_clone(&parsed.repo_url, &parsed.reference, repo_dir)
}
fn sparse_clone(repo_url: &str, reference: &str, subpath: &str, repo_dir: &Path) -> Result<()> {
let repo_dir_str = repo_dir.to_str().unwrap_or_default();
let action = format!("sparse-clone {repo_url} at ref {reference}");
run_git(
[
"clone",
"--no-checkout",
"--filter=blob:none",
"--depth=1",
"--branch",
reference,
"--sparse",
repo_url,
repo_dir_str,
],
None,
&action,
)?;
run_git(
["sparse-checkout", "set", subpath],
Some(repo_dir),
"configure sparse-checkout pattern",
)?;
run_git(
["checkout", reference],
Some(repo_dir),
"materialize sparse working tree",
)
}
fn full_clone(repo_url: &str, reference: &str, repo_dir: &Path) -> Result<()> {
let repo_dir_str = repo_dir.to_str().unwrap_or_default();
let action = format!("clone {repo_url} at ref {reference}");
run_git(
[
"clone",
"--depth",
"1",
"--branch",
reference,
repo_url,
repo_dir_str,
],
None,
&action,
)
}
fn parse_github_spec_for_registry(spec: &str) -> Result<GithubSpecLite> {
let mut parts = spec.splitn(3, '/');
let owner = parts
.next()
.filter(|part| !part.is_empty())
.ok_or_else(|| anyhow::anyhow!("gh: 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::anyhow!("gh: source must include a repository"))?;
let skill_path = parts.next().unwrap_or("");
let (repo, reference) = repo_with_ref
.split_once('@')
.unwrap_or((repo_with_ref, "main"));
if repo.is_empty() || reference.is_empty() {
bail!("gh: source repository and ref must not be empty");
}
Ok(GithubSpecLite {
owner: owner.to_string(),
repo: repo.to_string(),
reference: reference.to_string(),
skill_path: PathBuf::from(skill_path),
})
}
#[derive(Debug)]
struct GithubSpecLite {
owner: String,
repo: String,
reference: String,
skill_path: PathBuf,
}
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 looks_like_sha(s: &str) -> bool {
matches!(s.len(), 7..=40) && s.chars().all(|c| c.is_ascii_hexdigit())
}
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(())
}
#[derive(Debug)]
struct GitBackingSource {
repo_url: String,
reference: String,
skill_path: PathBuf,
}
fn parse_git_host_source(base_url: &str, rest: &str) -> Result<GitBackingSource> {
let mut parts = rest.splitn(3, '/');
let owner = parts
.next()
.filter(|part| !part.is_empty())
.context("backing source must include owner")?;
let repo_with_ref = parts
.next()
.filter(|part| !part.is_empty())
.context("backing source must include repository")?;
let skill_path = parts.next().unwrap_or("");
let (repo, reference) = split_repo_ref(repo_with_ref, "main")?;
let base_url = base_url
.trim_end_matches('/')
.strip_prefix("git+")
.unwrap_or(base_url.trim_end_matches('/'));
Ok(GitBackingSource {
repo_url: format!("{base_url}/{owner}/{repo}.git"),
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 Some(position) = repo_with_ref.rfind('@') else {
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!("repository and ref must not be empty");
}
Ok((repo, reference))
}
fn parse_source_aliases(values: &[String]) -> Result<BTreeMap<String, String>> {
let mut aliases = BTreeMap::new();
for value in values {
let (name, url) = value
.split_once('=')
.with_context(|| format!("source alias must be name=url: {value}"))?;
if name.is_empty() || url.is_empty() {
bail!("source alias name and url must not be empty: {value}");
}
aliases.insert(name.to_string(), url.to_string());
}
Ok(aliases)
}
fn append_file(
archive: &mut Builder<GzEncoder<Vec<u8>>>,
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()))?;
if !metadata.is_file() {
bail!("not a file: {}", 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(())
}
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
}