From e8f851083297059a0e42f3d735367ef7badc8efb Mon Sep 17 00:00:00 2001 From: PiX <69745008+pixincreate@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:31:11 +0530 Subject: [PATCH 1/6] feat: add skill install service --- src/install.rs | 450 ++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + tests/install_test.rs | 276 ++++++++++++++++++++++++++ 3 files changed, 727 insertions(+) create mode 100644 src/install.rs create mode 100644 tests/install_test.rs diff --git a/src/install.rs b/src/install.rs new file mode 100644 index 0000000..bb981a0 --- /dev/null +++ b/src/install.rs @@ -0,0 +1,450 @@ +use crate::clone::clone_to_path; +use crate::config::Config; +use anyhow::{Context, Result, anyhow}; +use git2::Repository; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallOptions { + pub reference: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallResult { + pub skill_slug: String, + pub installed_path: PathBuf, + pub replaced_existing: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SkillSelector { + Slug(String), + Path(PathBuf), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedInstallRef { + pub repo_url: String, + pub branch: Option, + pub selector: SkillSelector, +} + +pub fn resolve_install_ref(input: &str) -> Result { + let trimmed_input = input.trim(); + + if trimmed_input.is_empty() { + return Err(anyhow!( + "Install reference cannot be empty. Use a skills.sh URL, GitHub tree URL, or owner/repo/skill reference." + )); + } + + if let Some(path) = trimmed_input + .strip_prefix("https://skills.sh/") + .or_else(|| trimmed_input.strip_prefix("http://skills.sh/")) + { + let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); + if parts.len() == 3 { + let owner = parts[0]; + let repository_name = parts[1]; + let skill_slug = parts[2]; + return Ok(ResolvedInstallRef { + repo_url: format!("https://github.com/{owner}/{repository_name}.git"), + branch: None, + selector: SkillSelector::Slug(skill_slug.to_string()), + }); + } + + return Err(anyhow!( + "skills.sh references must point to a concrete skill, like https://skills.sh/owner/repo/skill-slug" + )); + } + + if let Some(tree_ref) = resolve_github_tree_ref(trimmed_input)? { + return Ok(tree_ref); + } + + if !trimmed_input.contains("://") && !trimmed_input.starts_with("git@") { + let parts: Vec<&str> = trimmed_input + .trim_end_matches('/') + .split('/') + .filter(|part| !part.is_empty()) + .collect(); + + return match parts.as_slice() { + [owner, repository_name, skill_slug] => Ok(ResolvedInstallRef { + repo_url: format!("https://github.com/{owner}/{repository_name}.git"), + branch: None, + selector: SkillSelector::Slug((*skill_slug).to_string()), + }), + [owner, repository_name, remaining_path @ ..] if remaining_path.len() > 1 => { + Ok(ResolvedInstallRef { + repo_url: format!("https://github.com/{owner}/{repository_name}.git"), + branch: None, + selector: SkillSelector::Path(PathBuf::from(remaining_path.join("/"))), + }) + } + [_, _] => Err(anyhow!( + "Install requires a concrete skill reference, like owner/repo/skill-slug or owner/repo/path/to/skill" + )), + _ => Err(anyhow!( + "Unsupported install reference. Use a skills.sh URL, GitHub tree URL, or owner/repo/skill reference." + )), + }; + } + + Err(anyhow!( + "Unsupported install reference. Use a skills.sh URL, GitHub tree URL, or owner/repo/skill reference." + )) +} + +fn resolve_github_tree_ref(input: &str) -> Result> { + let normalized_input = input.trim_end_matches('/'); + let prefix = "https://github.com/"; + + if !normalized_input.starts_with(prefix) { + return Ok(None); + } + + let path = &normalized_input[prefix.len()..]; + let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); + + if parts.len() < 5 { + return Ok(None); + } + + if parts[2] != "tree" { + return Ok(None); + } + + let owner = parts[0]; + let repository_name = parts[1]; + let branch_name = parts[3]; + let skill_path = PathBuf::from(parts[4..].join("/")); + + if skill_path.as_os_str().is_empty() { + return Err(anyhow!( + "GitHub tree URLs must point to a concrete skill directory" + )); + } + + Ok(Some(ResolvedInstallRef { + repo_url: format!("https://github.com/{owner}/{repository_name}.git"), + branch: Some(branch_name.to_string()), + selector: SkillSelector::Path(skill_path), + })) +} + +pub fn install_skill(options: &InstallOptions, config: &Config) -> Result { + ensure_install_root_ready(&config.skills_source)?; + + let resolved_reference = resolve_install_ref(&options.reference)?; + let temp_dir = tempfile::tempdir().context("Failed to create temporary directory")?; + + println!("Fetching skill source..."); + clone_to_path( + &resolved_reference.repo_url, + resolved_reference.branch.as_deref().unwrap_or(""), + temp_dir.path(), + )?; + + install_skill_from_checkout(temp_dir.path(), &resolved_reference, &config.skills_source) +} + +pub fn install_skill_from_checkout( + checkout_root: &Path, + resolved_reference: &ResolvedInstallRef, + target_root: &Path, +) -> Result { + let skill_source = resolve_skill_source(checkout_root, resolved_reference)?; + let skill_slug = derive_skill_slug(&skill_source)?; + let target_dir = target_root.join(&skill_slug); + + fs::create_dir_all(target_root).with_context(|| { + format!( + "Failed to create skills source directory at {}", + target_root.display() + ) + })?; + + let replaced_existing = if target_dir.exists() { + prompt_replace_existing_skill(&skill_slug, &target_dir)?; + remove_existing_path(&target_dir)?; + true + } else { + false + }; + + copy_directory_recursive(&skill_source, &target_dir)?; + + Ok(InstallResult { + skill_slug, + installed_path: target_dir, + replaced_existing, + }) +} + +fn ensure_install_root_ready(skills_source: &Path) -> Result<()> { + if skills_source.exists() && !skills_source.is_dir() { + return Err(anyhow!( + "Skills source path is not a directory: {}", + skills_source.display() + )); + } + + if skills_source.exists() && Repository::open(skills_source).is_ok() { + return Err(anyhow!( + "Skills source is currently a git repository at {}. Install requires a managed skills directory, so use a different skills_source or continue using 'capsync clone'.", + skills_source.display() + )); + } + + Ok(()) +} + +fn resolve_skill_source( + checkout_root: &Path, + resolved_reference: &ResolvedInstallRef, +) -> Result { + match &resolved_reference.selector { + SkillSelector::Path(skill_path) => { + let skill_root = checkout_root.join(skill_path); + validate_skill_directory(&skill_root)?; + Ok(skill_root) + } + SkillSelector::Slug(skill_slug) => find_skill_directory_by_slug(checkout_root, skill_slug), + } +} + +fn validate_skill_directory(path: &Path) -> Result<()> { + if !path.exists() { + return Err(anyhow!("Skill path does not exist: {}", path.display())); + } + + if !path.is_dir() { + return Err(anyhow!("Skill path is not a directory: {}", path.display())); + } + + let skill_markdown = path.join("SKILL.md"); + if !skill_markdown.exists() { + return Err(anyhow!( + "Expected SKILL.md in skill directory: {}", + path.display() + )); + } + + Ok(()) +} + +fn find_skill_directory_by_slug(checkout_root: &Path, requested_slug: &str) -> Result { + let normalized_requested_slug = normalize_skill_slug(requested_slug); + let candidates = collect_skill_directories(checkout_root)?; + let mut matches = Vec::new(); + + for candidate in candidates { + let mut candidate_slugs = Vec::new(); + + if let Some(directory_name) = candidate.file_name().and_then(|name| name.to_str()) { + candidate_slugs.push(normalize_skill_slug(directory_name)); + } + + if let Some(skill_name) = read_skill_name(&candidate.join("SKILL.md"))? { + candidate_slugs.push(normalize_skill_slug(&skill_name)); + } + + if candidate_slugs + .iter() + .any(|candidate_slug| candidate_slug == &normalized_requested_slug) + { + matches.push(candidate); + } + } + + match matches.len() { + 1 => Ok(matches.remove(0)), + 0 => Err(anyhow!( + "Could not find a skill matching '{}' in the resolved repository", + requested_slug + )), + _ => Err(anyhow!( + "Found multiple skills matching '{}'. Use an explicit path like owner/repo/path/to/skill or a GitHub tree URL.", + requested_slug + )), + } +} + +fn collect_skill_directories(root: &Path) -> Result> { + let mut skill_directories = Vec::new(); + collect_skill_directories_recursive(root, &mut skill_directories)?; + Ok(skill_directories) +} + +fn collect_skill_directories_recursive( + root: &Path, + skill_directories: &mut Vec, +) -> Result<()> { + for entry in fs::read_dir(root) + .with_context(|| format!("Failed to read directory {}", root.display()))? + { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + + if file_type.is_dir() { + if entry.file_name() == ".git" { + continue; + } + + let skill_markdown = path.join("SKILL.md"); + if skill_markdown.exists() { + skill_directories.push(path); + continue; + } + + collect_skill_directories_recursive(&path, skill_directories)?; + } + } + + Ok(()) +} + +fn read_skill_name(skill_markdown_path: &Path) -> Result> { + let content = fs::read_to_string(skill_markdown_path) + .with_context(|| format!("Failed to read {}", skill_markdown_path.display()))?; + + let mut lines = content.lines(); + if lines.next() != Some("---") { + return Ok(None); + } + + for line in lines { + if line == "---" { + break; + } + + if let Some(value) = line.strip_prefix("name:") { + return Ok(Some( + value + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(), + )); + } + } + + Ok(None) +} + +pub fn normalize_skill_slug(input: &str) -> String { + let mut slug = String::new(); + let mut last_was_dash = false; + + for character in input.chars() { + if character.is_ascii_alphanumeric() { + slug.push(character.to_ascii_lowercase()); + last_was_dash = false; + } else if !last_was_dash { + slug.push('-'); + last_was_dash = true; + } + } + + slug.trim_matches('-').to_string() +} + +fn derive_skill_slug(skill_source: &Path) -> Result { + let skill_markdown_path = skill_source.join("SKILL.md"); + + if let Some(skill_name) = read_skill_name(&skill_markdown_path)? { + let normalized_name = normalize_skill_slug(&skill_name); + if !normalized_name.is_empty() { + return Ok(normalized_name); + } + } + + let directory_name = skill_source + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("Cannot derive skill name from {}", skill_source.display()))?; + let normalized_name = normalize_skill_slug(directory_name); + + if normalized_name.is_empty() { + return Err(anyhow!( + "Cannot derive a valid skill slug from {}", + skill_source.display() + )); + } + + Ok(normalized_name) +} + +fn prompt_replace_existing_skill(skill_slug: &str, target_dir: &Path) -> Result<()> { + loop { + print!( + "Skill '{}' already exists at {}. Replace it? [y/N]: ", + skill_slug, + target_dir.display() + ); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let normalized_input = input.trim().to_lowercase(); + + if normalized_input == "y" { + return Ok(()); + } + + if normalized_input.is_empty() || normalized_input == "n" { + return Err(anyhow!("Aborted.")); + } + + println!("Please enter y or n."); + } +} + +fn remove_existing_path(path: &Path) -> Result<()> { + if path.is_symlink() || path.is_file() { + fs::remove_file(path) + .with_context(|| format!("Failed to remove existing file at {}", path.display()))?; + } else if path.is_dir() { + fs::remove_dir_all(path).with_context(|| { + format!( + "Failed to remove existing skill directory at {}", + path.display() + ) + })?; + } + + Ok(()) +} + +fn copy_directory_recursive(source: &Path, destination: &Path) -> Result<()> { + fs::create_dir_all(destination) + .with_context(|| format!("Failed to create directory {}", destination.display()))?; + + for entry in fs::read_dir(source) + .with_context(|| format!("Failed to read directory {}", 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_directory_recursive(&source_path, &destination_path)?; + } else { + fs::copy(&source_path, &destination_path).with_context(|| { + format!( + "Failed to copy {} to {}", + source_path.display(), + destination_path.display() + ) + })?; + } + } + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index c2a116f..431febb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,5 +2,6 @@ pub mod cli; pub mod clone; pub mod config; pub mod detect; +pub mod install; pub mod sync; pub mod tools; diff --git a/tests/install_test.rs b/tests/install_test.rs new file mode 100644 index 0000000..981d6ef --- /dev/null +++ b/tests/install_test.rs @@ -0,0 +1,276 @@ +use capsync::config::Config; +use capsync::install::{ + InstallOptions, InstallResult, ResolvedInstallRef, SkillSelector, install_skill, + install_skill_from_checkout, normalize_skill_slug, resolve_install_ref, +}; +use std::fs; +use std::path::PathBuf; +use tempfile::tempdir; + +fn write_skill(skill_dir: &std::path::Path, name: &str, description: &str) { + fs::create_dir_all(skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + format!( + "---\nname: \"{}\"\ndescription: \"{}\"\n---\n\n# {}\n", + name, description, name + ), + ) + .unwrap(); +} + +#[test] +fn test_resolve_install_ref_skills_sh_url() { + let resolved = resolve_install_ref("https://skills.sh/vercel-labs/skills/find-skills").unwrap(); + + assert_eq!( + resolved.repo_url, + "https://github.com/vercel-labs/skills.git" + ); + assert_eq!(resolved.branch, None); + assert_eq!( + resolved.selector, + SkillSelector::Slug("find-skills".to_string()) + ); +} + +#[test] +fn test_resolve_install_ref_http_skills_sh_url() { + let resolved = resolve_install_ref("http://skills.sh/vercel-labs/skills/find-skills").unwrap(); + + assert_eq!( + resolved.repo_url, + "https://github.com/vercel-labs/skills.git" + ); + assert_eq!(resolved.branch, None); + assert_eq!( + resolved.selector, + SkillSelector::Slug("find-skills".to_string()) + ); +} + +#[test] +fn test_resolve_install_ref_github_tree_url() { + let resolved = + resolve_install_ref("https://github.com/vercel-labs/skills/tree/main/skills/find-skills") + .unwrap(); + + assert_eq!( + resolved.repo_url, + "https://github.com/vercel-labs/skills.git" + ); + assert_eq!(resolved.branch, Some("main".to_string())); + assert_eq!( + resolved.selector, + SkillSelector::Path(PathBuf::from("skills/find-skills")) + ); +} + +#[test] +fn test_resolve_install_ref_owner_repo_slug() { + let resolved = resolve_install_ref("vercel-labs/skills/find-skills").unwrap(); + + assert_eq!( + resolved.repo_url, + "https://github.com/vercel-labs/skills.git" + ); + assert_eq!(resolved.branch, None); + assert_eq!( + resolved.selector, + SkillSelector::Slug("find-skills".to_string()) + ); +} + +#[test] +fn test_resolve_install_ref_owner_repo_path() { + let resolved = resolve_install_ref("vercel-labs/skills/skills/find-skills").unwrap(); + + assert_eq!( + resolved.repo_url, + "https://github.com/vercel-labs/skills.git" + ); + assert_eq!(resolved.branch, None); + assert_eq!( + resolved.selector, + SkillSelector::Path(PathBuf::from("skills/find-skills")) + ); +} + +#[test] +fn test_resolve_install_ref_rejects_repo_only_reference() { + let error = resolve_install_ref("vercel-labs/skills").unwrap_err(); + assert!( + error + .to_string() + .contains("Install requires a concrete skill reference") + ); +} + +#[test] +fn test_normalize_skill_slug() { + assert_eq!(normalize_skill_slug("Find Skills"), "find-skills"); + assert_eq!(normalize_skill_slug("frontend_design"), "frontend-design"); + assert_eq!(normalize_skill_slug(" !!! "), ""); +} + +#[test] +fn test_install_skill_from_checkout_by_slug_uses_skill_name_slug() { + let checkout_dir = tempdir().unwrap(); + let target_dir = tempdir().unwrap(); + + let skill_dir = checkout_dir.path().join("skills").join("find-skills"); + write_skill(&skill_dir, "Find Skills", "Locate useful skills"); + fs::write(skill_dir.join("notes.txt"), "extra file").unwrap(); + + let resolved = ResolvedInstallRef { + repo_url: "https://github.com/vercel-labs/skills.git".to_string(), + branch: None, + selector: SkillSelector::Slug("find-skills".to_string()), + }; + + let result = + install_skill_from_checkout(checkout_dir.path(), &resolved, target_dir.path()).unwrap(); + + assert_eq!(result.skill_slug, "find-skills"); + assert!(!result.replaced_existing); + assert!(result.installed_path.join("SKILL.md").exists()); + assert!(result.installed_path.join("notes.txt").exists()); +} + +#[test] +fn test_install_skill_from_checkout_prefers_skill_name_for_slug() { + let checkout_dir = tempdir().unwrap(); + let target_dir = tempdir().unwrap(); + + let skill_dir = checkout_dir.path().join("skills").join("find-skill-files"); + write_skill(&skill_dir, "Find Skills", "Locate useful skills"); + + let resolved = ResolvedInstallRef { + repo_url: "https://github.com/vercel-labs/skills.git".to_string(), + branch: None, + selector: SkillSelector::Path(PathBuf::from("skills/find-skill-files")), + }; + + let result = + install_skill_from_checkout(checkout_dir.path(), &resolved, target_dir.path()).unwrap(); + + assert_eq!(result.skill_slug, "find-skills"); + assert_eq!(result.installed_path, target_dir.path().join("find-skills")); +} + +#[test] +fn test_install_skill_from_checkout_by_explicit_path() { + let checkout_dir = tempdir().unwrap(); + let target_dir = tempdir().unwrap(); + + let skill_dir = checkout_dir.path().join("packages").join("frontend-design"); + write_skill(&skill_dir, "Frontend Design", "Build polished interfaces"); + + let resolved = ResolvedInstallRef { + repo_url: "https://github.com/vercel-labs/skills.git".to_string(), + branch: Some("main".to_string()), + selector: SkillSelector::Path(PathBuf::from("packages/frontend-design")), + }; + + let result = + install_skill_from_checkout(checkout_dir.path(), &resolved, target_dir.path()).unwrap(); + + assert_eq!(result.skill_slug, "frontend-design"); + assert!(result.installed_path.join("SKILL.md").exists()); +} + +#[test] +fn test_install_skill_from_checkout_errors_on_missing_skill() { + let checkout_dir = tempdir().unwrap(); + let target_dir = tempdir().unwrap(); + + let resolved = ResolvedInstallRef { + repo_url: "https://github.com/vercel-labs/skills.git".to_string(), + branch: None, + selector: SkillSelector::Slug("missing-skill".to_string()), + }; + + let error = + install_skill_from_checkout(checkout_dir.path(), &resolved, target_dir.path()).unwrap_err(); + assert!( + error + .to_string() + .contains("Could not find a skill matching") + ); +} + +#[test] +fn test_install_skill_from_checkout_errors_on_ambiguous_slug() { + let checkout_dir = tempdir().unwrap(); + let target_dir = tempdir().unwrap(); + + write_skill( + &checkout_dir.path().join("skills").join("frontend-design"), + "Frontend Design", + "Primary skill", + ); + write_skill( + &checkout_dir.path().join("alt").join("frontend-design-copy"), + "Frontend Design", + "Duplicate by name", + ); + + let resolved = ResolvedInstallRef { + repo_url: "https://github.com/vercel-labs/skills.git".to_string(), + branch: None, + selector: SkillSelector::Slug("frontend-design".to_string()), + }; + + let error = + install_skill_from_checkout(checkout_dir.path(), &resolved, target_dir.path()).unwrap_err(); + assert!(error.to_string().contains("Found multiple skills matching")); +} + +#[test] +fn test_install_result_reports_installed_path() { + let checkout_dir = tempdir().unwrap(); + let target_dir = tempdir().unwrap(); + + let skill_dir = checkout_dir.path().join("skills").join("find-skills"); + write_skill(&skill_dir, "Find Skills", "Locate useful skills"); + + let options = InstallOptions { + reference: "vercel-labs/skills/find-skills".to_string(), + }; + let resolved = ResolvedInstallRef { + repo_url: "https://github.com/vercel-labs/skills.git".to_string(), + branch: None, + selector: SkillSelector::Slug("find-skills".to_string()), + }; + + let result: InstallResult = + install_skill_from_checkout(checkout_dir.path(), &resolved, target_dir.path()).unwrap(); + + assert_eq!(options.reference, "vercel-labs/skills/find-skills"); + assert_eq!(result.skill_slug, "find-skills"); + assert_eq!(result.installed_path, target_dir.path().join("find-skills")); + assert!(!result.replaced_existing); +} + +#[test] +fn test_install_skill_rejects_git_repo_skills_source() { + let repository_dir = tempdir().unwrap(); + git2::Repository::init(repository_dir.path()).unwrap(); + + let config = Config { + skills_source: repository_dir.path().to_path_buf(), + commands_source: None, + destinations: Config::default().destinations, + }; + + let options = InstallOptions { + reference: "vercel-labs/skills/find-skills".to_string(), + }; + + let error = install_skill(&options, &config).unwrap_err(); + assert!( + error + .to_string() + .contains("Skills source is currently a git repository") + ); +} From d5a6047859cd803513664e0cd1e3c19d7d7bd613 Mon Sep 17 00:00:00 2001 From: PiX <69745008+pixincreate@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:33:06 +0530 Subject: [PATCH 2/6] feat: wire install command into CLI --- src/cli.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 2 files changed, 56 insertions(+) diff --git a/src/cli.rs b/src/cli.rs index cfb696f..1f12ad7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,7 @@ use crate::clone::{CloneAction, CloneOptions, clone_skills}; use crate::config::{self, Config, DestinationConfig}; use crate::detect::ToolDetector; +use crate::install::{InstallOptions, install_skill}; use crate::sync::SyncManager; use crate::tools::{all_tools, get_tool}; use anyhow::{Context, Result, anyhow}; @@ -56,6 +57,14 @@ pub enum Commands { #[arg(long)] no_sync: bool, }, + #[command(about = "Install a skill from an explicit reference")] + Install { + #[arg(help = "Skill reference (skills.sh URL, GitHub tree URL, or owner/repo/skill)")] + reference: String, + #[arg(long)] + #[arg(help = "Skip syncing after install")] + no_sync: bool, + }, } pub fn run() -> Result<()> { @@ -81,6 +90,7 @@ pub fn run() -> Result<()> { branch, no_sync, } => clone_repo(&repo, branch, no_sync), + Commands::Install { reference, no_sync } => install_from_reference(&reference, no_sync), Commands::Status => show_status(), } } @@ -374,6 +384,51 @@ fn clone_repo(repo: &str, branch: Option, no_sync: bool) -> Result<()> { Ok(()) } +fn install_from_reference(reference: &str, no_sync: bool) -> Result<()> { + let config = match config::load_config() { + Ok(c) => c, + Err(e) => { + let config_path = config::get_config_path(); + if !config_path.exists() { + println!("No configuration found. Running init first..."); + init_config()?; + config::load_config()? + } else { + return Err(e).context("Failed to load config"); + } + } + }; + + let options = InstallOptions { + reference: reference.to_string(), + }; + + let result = install_skill(&options, &config)?; + + if result.replaced_existing { + println!( + "\nReplaced installed skill '{}' at {}", + result.skill_slug, + result.installed_path.display() + ); + } else { + println!( + "\nInstalled skill '{}' to {}", + result.skill_slug, + result.installed_path.display() + ); + } + + if !no_sync { + println!("\nRunning sync..."); + sync_all()?; + } else { + println!("\nSkipped sync (--no-sync passed). Run 'capsync sync' manually to sync."); + } + + Ok(()) +} + fn show_status() -> Result<()> { let config = config::load_config()?; diff --git a/src/main.rs b/src/main.rs index facf1b4..b4d9889 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod cli; mod clone; mod config; mod detect; +mod install; mod sync; mod tools; From 61624ab8d9c4c69721dfd8327e95bab6eac95fc9 Mon Sep 17 00:00:00 2001 From: PiX <69745008+pixincreate@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:33:15 +0530 Subject: [PATCH 3/6] docs: clarify clone and install workflows --- README.md | 47 ++++++++++++++++++++--- documentation/agents.md | 52 ++++++++++++++++++++++++-- documentation/how-it-works.md | 70 +++++++++++++++++++++++++++++++---- 3 files changed, 153 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ad6ff8b..549de44 100644 --- a/README.md +++ b/README.md @@ -31,9 +31,9 @@ CapSync solves this by creating a single source of truth for your skills and com **What CapSync Is Not:** -- A skill or command discovery tool. CapSync does not search registries or catalogs for skills/commands on your behalf -- A skill or command installer. You must already have skills/commands in your source directories -- A skill or command creator. CapSync only syncs what you already have +- A registry browser or catalog search tool. CapSync does not rank, search, or recommend skills for you +- A general-purpose package manager for commands. Commands still need to come from your own source directory +- A skill or command creator. CapSync only syncs and installs content you explicitly point it at **Prerequisites:** You need to have skills (and optionally commands) already installed in local directories before using CapSync. CapSync assumes you have: @@ -42,7 +42,7 @@ You need to have skills (and optionally commands) already installed in local dir - Optionally, a directory containing your commands (e.g., `~/dev/scripts/commands`) - Skills and commands formatted for your AI tools -If you already know which Git repository you want, `capsync clone` can download it into your configured skills source. CapSync still does not browse, rank, or discover skills for you. +If you already know which Git repository or skill reference you want, `capsync clone` and `capsync install` can materialize it into your configured skills source. CapSync still does not browse, rank, or discover skills for you. ## Installation @@ -311,7 +311,7 @@ Create or update symlinks for all enabled tools. ### `capsync clone ` -Clone a remote Git repository into your configured skills source. +Clone a whole remote Git repository into your configured skills source. Supported inputs: @@ -325,7 +325,42 @@ Options: - `--branch `: Clone a specific branch instead of auto-detecting the remote default branch - `--no-sync`: Skip running `capsync sync` after the clone finishes -If the skills source already exists, CapSync prompts before replacing it. During override, it offers a backup when local changes would otherwise be lost. +Behavior: + +- Treats `skills_source` as the checkout for one whole repository +- If the same repo already exists, prompts to update in place or override with a fresh clone +- Update fetches remote changes and hard-resets the local branch to its upstream +- If the requested branch differs from the current local branch, CapSync asks for explicit confirmation before re-cloning instead of silently replacing the checkout +- If the existing source is a different repo, a git repo without `origin`, or a plain directory, CapSync asks before replacing it +- During override, it offers a backup when local changes would otherwise be lost + +### `capsync install ` + +Install a single skill into your configured skills source from an explicit reference. + +Supported inputs in v1: + +- `https://skills.sh/owner/repo/skill-slug` +- `https://github.com/owner/repo/tree//path/to/skill` +- `owner/repo/skill-slug` +- `owner/repo/path/to/skill` + +Options: + +- `--no-sync`: Skip running `capsync sync` after the install finishes + +Behavior: + +- Installs exactly one skill into `skills_source/` +- Uses a temporary git checkout to resolve and copy the skill directory +- Refuses to install into a `skills_source` that is itself a git repository managed by `capsync clone` +- Prompts before replacing an already-installed skill with the same slug +- Leaves `commands_source` unchanged in v1 + +Mental model: + +- `capsync clone ...` makes `skills_source` be a checkout of one whole repository +- `capsync install ...` copies one selected skill into `skills_source/` ### `capsync add ` diff --git a/documentation/agents.md b/documentation/agents.md index d18eac3..f03489f 100644 --- a/documentation/agents.md +++ b/documentation/agents.md @@ -46,7 +46,10 @@ A target tool's skills/commands location. Each destination has: A symbolic link. CapSync doesn't copy files—it creates symlinks from destination directories pointing to your source directory. ### Clone -The `clone` command fetches a remote Git repository into your skills source, enabling CapSync to sync remote skills to local tools. +The `clone` command makes your `skills_source` a checkout of one whole remote Git repository. + +### Install +The `install` command copies one explicit skill into `skills_source/` without turning the whole source directory into a repo checkout. --- @@ -154,12 +157,40 @@ capsync clone --no-sync # Clone without syncing - If `skills_source` exists but is not a git repository: Prompt before overriding - If `skills_source` is a git repository without an `origin` remote: Prompt before overriding - If override would discard local changes: Warn and offer to back up first -- If update is selected while the current branch differs from `--branch`: Re-clone the requested branch instead of updating in place +- If update is selected while the current branch differs from `--branch`: Explain the mismatch and require explicit confirmation before re-cloning the requested branch **Branch Detection:** - Auto-detects the remote's default branch from remote HEAD - Falls back to fetched branch names only if the remote does not report a default branch +### `capsync install ` + +Install one skill into your skills source from an explicit reference. + +```bash +capsync install # Install and sync +capsync install --no-sync # Install without syncing +``` + +**Arguments:** +- ``: Explicit skill reference in one of these forms: + - `https://skills.sh/owner/repo/skill-slug` + - `https://github.com/owner/repo/tree//path/to/skill` + - `owner/repo/skill-slug` + - `owner/repo/path/to/skill` + +**Behavior:** +- Resolves exactly one skill from the provided reference +- Uses a temporary git checkout and copies the selected skill into `skills_source/` +- Prompts before replacing an already-installed skill with the same slug +- Runs `capsync sync` automatically unless `--no-sync` is passed +- Leaves `commands_source` unchanged in v1 + +**Constraints:** +- v1 does not browse or search the public skills catalog +- v1 does not install commands +- v1 refuses to install into a `skills_source` that is itself a git repository managed by `capsync clone` + --- ## Configuration @@ -299,13 +330,25 @@ After (with CapSync): 1. Parse repo URL (`owner/repo` or full URL) 2. Determine default branch (fetch remote refs) 3. Handle existing source: - - Same repo → offer update (git pull) or override + - Same repo → offer update (fetch + hard reset to upstream) or override - Different repo → offer override - Non-git directory or no `origin` remote → require explicit confirmation before override + - Requested branch differs from current local branch during update → require explicit confirmation before re-cloning - Local changes during override → warn and offer backup 4. Clone to `skills_source` 5. Optionally sync to all enabled tools +### Install Workflow + +1. Parse an explicit skill reference +2. Resolve it to a repo URL plus a concrete skill selector +3. Clone the repo to a temporary checkout +4. Find exactly one skill directory containing `SKILL.md` +5. Copy that skill into `skills_source/` +6. Optionally sync to all enabled tools + +Unlike `clone`, this does not make `skills_source` a repo checkout. It only installs one selected skill into the managed source directory. + --- ## Common Use Cases @@ -378,6 +421,7 @@ capsync/ │ ├── config.rs # Config loading/saving │ ├── clone.rs # Git clone functionality │ ├── detect.rs # Tool detection +│ ├── install.rs # Explicit skill install service │ ├── sync.rs # Symlink management │ └── tools.rs # Supported tools list ├── Cargo.toml @@ -397,6 +441,8 @@ capsync/ | "Skills source not found" | Source path doesn't exist | Check config or run `capsync clone` | | "Repository not found" | Invalid repo URL | Check URL format: `owner/repo` | | "Failed to clone" | Network/auth error | Check internet connection | +| "Install requires a concrete skill reference" | Repo-only ref provided | Use `owner/repo/skill-slug` or a GitHub tree URL | +| "Skills source is currently a git repository" | Tried to install into clone-managed source | Use a different `skills_source` or continue using `capsync clone` | --- diff --git a/documentation/how-it-works.md b/documentation/how-it-works.md index 3056d3f..09598bc 100644 --- a/documentation/how-it-works.md +++ b/documentation/how-it-works.md @@ -42,6 +42,12 @@ Reads and writes your settings to `~/.config/capsync/config.toml`. It's just a T **`detect.rs`** - The Finder Scans your computer for installed AI tools. Just checks if directories exist. Fast, simple, non-invasive. +**`clone.rs`** - The Repo Materializer +Handles whole-repository cloning into `skills_source`, including update vs override prompts, branch selection, and safety checks around replacing an existing checkout. + +**`install.rs`** - The Skill Materializer +Handles installing one explicit skill reference into `skills_source/` by cloning to a temporary checkout, selecting a skill directory, and copying it into the managed source tree. + **`sync.rs`** - The Worker Actually creates and removes symlinks. Handles the messy platform differences (Unix vs Windows). Reports what worked and what didn't. @@ -54,14 +60,13 @@ A big list of all supported tools and where they keep their stuff. Currently 40+ CapSync doesn't: -- Download skills from the internet - Create skills for you - Validate your skill format - Have a GUI - Run as a daemon - Do anything fancy -It just syncs. That's it. That's the feature. +It does sync, and it can materialize remote content when you explicitly point it at a repository or a concrete skill reference. It still does not browse, rank, or discover skills for you. ### Why Symlinks? @@ -123,6 +128,52 @@ Synced successfully: If something fails, it tells you. But keeps going with the others. +### `capsync clone ` - Make `skills_source` a Repo Checkout + +Use clone when your `skills_source` should be one whole Git repository. + +```bash +$ capsync clone vercel-labs/skills +Fetching remote branch info... +Using branch: main +Cloning into /Users/you/my-skills... +Successfully cloned vercel-labs/skills (branch: main) +Running sync... +``` + +Key behavior: + +- Supports `owner/repo`, `owner/repo.git`, `https://...`, `http://...`, and `git@...` +- Auto-detects the remote default branch unless `--branch` is provided +- If the same repo is already present, offers update or override +- Update is implemented as fetch + hard reset to upstream, not a merge-based pull +- If the requested branch differs from the current local branch, it requires explicit confirmation before re-cloning + +### `capsync install ` - Copy One Skill Into `skills_source` + +Use install when you want one skill from a repo, not the whole repo. + +```bash +$ capsync install vercel-labs/skills/find-skills +Fetching skill source... +Installed skill 'find-skills' to /Users/you/my-skills/find-skills +Running sync... +``` + +Supported explicit references in v1: + +- `https://skills.sh/owner/repo/skill-slug` +- `https://github.com/owner/repo/tree//path/to/skill` +- `owner/repo/skill-slug` +- `owner/repo/path/to/skill` + +Key behavior: + +- Rejects repo-only refs like `owner/repo` +- Clones to a temporary checkout, finds exactly one skill directory, then copies it into `skills_source/` +- Refuses to install into a `skills_source` that is itself a git repo managed by `capsync clone` +- Leaves `commands_source` untouched in v1 + ### `capsync add ` - Add New Tools Got a new AI tool? Add it anytime. @@ -219,17 +270,22 @@ $ capsync remove claude Located at `~/.config/capsync/config.toml`. Looks like this: ```toml -source = "/Users/you/my-skills" +skills_source = "/Users/you/my-skills" +commands_source = "/Users/you/my-skills/commands" [destinations.claude] enabled = true -path = "/Users/you/.claude/skills" +skills_path = "/Users/you/.claude/skills" +commands_path = "/Users/you/.claude/commands" [destinations.opencode] enabled = true -path = "/Users/you/.config/opencode/skill" +skills_path = "/Users/you/.config/opencode/skill" +commands_path = "/Users/you/.config/opencode/commands" ``` +The legacy keys `source` and destination `path` are still accepted for backward compatibility, but the canonical config fields are `skills_source`, `commands_source`, `skills_path`, and `commands_path`. + You can edit this by hand. It's just TOML. Add tools, remove them, change paths. CapSync will respect whatever's there. ## Design Decisions (The "Why") @@ -248,7 +304,7 @@ They break in some terminals. They're distracting. Plain text works everywhere. ### Why No Skill Discovery? -CapSync doesn't download skills from the internet. That's a different problem. Maybe someday, but for now we solve the sync problem really well. +CapSync still does not browse or rank a public catalog for you. But it can now materialize remote content when you provide an explicit repo (`capsync clone`) or an explicit skill reference (`capsync install`). ### Why TOML for Config? @@ -308,7 +364,7 @@ Simple. Fast. Works. **What It Won't Do:** -- Download skills from a registry +- Browse or search a public skills registry for you - Validate your skill format - Sync to remote machines (SSH, etc.) - Run as a background service From 7025175c113c33cb7844f56e9849249dcadc5725 Mon Sep 17 00:00:00 2001 From: PiX <69745008+pixincreate@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:30:22 +0530 Subject: [PATCH 4/6] fix: harden install path handling --- src/install.rs | 185 +++++++++++++++++++++++++++++++++++++----- tests/install_test.rs | 152 ++++++++++++++++++++++++++++++++-- 2 files changed, 309 insertions(+), 28 deletions(-) diff --git a/src/install.rs b/src/install.rs index bb981a0..84e5d4e 100644 --- a/src/install.rs +++ b/src/install.rs @@ -4,7 +4,8 @@ use anyhow::{Context, Result, anyhow}; use git2::Repository; use std::fs; use std::io::{self, Write}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct InstallOptions { @@ -36,14 +37,17 @@ pub fn resolve_install_ref(input: &str) -> Result { if trimmed_input.is_empty() { return Err(anyhow!( - "Install reference cannot be empty. Use a skills.sh URL, GitHub tree URL, or owner/repo/skill reference." + "Install reference cannot be empty. Use an HTTPS skills.sh URL, GitHub tree URL, or owner/repo/skill reference." )); } - if let Some(path) = trimmed_input - .strip_prefix("https://skills.sh/") - .or_else(|| trimmed_input.strip_prefix("http://skills.sh/")) - { + if trimmed_input.starts_with("http://skills.sh/") { + return Err(anyhow!( + "HTTP skills.sh references are not supported. Use https://skills.sh/owner/repo/skill-slug" + )); + } + + if let Some(path) = trimmed_input.strip_prefix("https://skills.sh/") { let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); if parts.len() == 3 { let owner = parts[0]; @@ -89,13 +93,13 @@ pub fn resolve_install_ref(input: &str) -> Result { "Install requires a concrete skill reference, like owner/repo/skill-slug or owner/repo/path/to/skill" )), _ => Err(anyhow!( - "Unsupported install reference. Use a skills.sh URL, GitHub tree URL, or owner/repo/skill reference." + "Unsupported install reference. Use an HTTPS skills.sh URL, GitHub tree URL, or owner/repo/skill reference." )), }; } Err(anyhow!( - "Unsupported install reference. Use a skills.sh URL, GitHub tree URL, or owner/repo/skill reference." + "Unsupported install reference. Use an HTTPS skills.sh URL, GitHub tree URL, or owner/repo/skill reference." )) } @@ -110,7 +114,7 @@ fn resolve_github_tree_ref(input: &str) -> Result> { let path = &normalized_input[prefix.len()..]; let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); - if parts.len() < 5 { + if parts.len() < 3 { return Ok(None); } @@ -118,10 +122,25 @@ fn resolve_github_tree_ref(input: &str) -> Result> { return Ok(None); } + if parts.len() == 4 { + return Err(anyhow!( + "GitHub tree URLs must point to a concrete skill directory" + )); + } + + if parts.len() < 5 { + return Ok(None); + } + let owner = parts[0]; let repository_name = parts[1]; - let branch_name = parts[3]; - let skill_path = PathBuf::from(parts[4..].join("/")); + let branch_name = decode_url_path_component(parts[3])?; + let skill_path = parts[4..] + .iter() + .map(|segment| decode_url_path_component(segment)) + .collect::>>()? + .join("/"); + let skill_path = PathBuf::from(skill_path); if skill_path.as_os_str().is_empty() { return Err(anyhow!( @@ -131,11 +150,44 @@ fn resolve_github_tree_ref(input: &str) -> Result> { Ok(Some(ResolvedInstallRef { repo_url: format!("https://github.com/{owner}/{repository_name}.git"), - branch: Some(branch_name.to_string()), + branch: Some(branch_name), selector: SkillSelector::Path(skill_path), })) } +fn decode_url_path_component(component: &str) -> Result { + let bytes = component.as_bytes(); + let mut decoded = String::with_capacity(component.len()); + let mut index = 0; + + while index < bytes.len() { + if bytes[index] == b'%' { + if index + 2 >= bytes.len() { + return Err(anyhow!( + "Invalid percent-encoding in GitHub tree URL component: {}", + component + )); + } + + let hex = &component[index + 1..index + 3]; + let value = u8::from_str_radix(hex, 16).map_err(|_| { + anyhow!( + "Invalid percent-encoding in GitHub tree URL component: {}", + component + ) + })?; + decoded.push(value as char); + index += 3; + continue; + } + + decoded.push(bytes[index] as char); + index += 1; + } + + Ok(decoded) +} + pub fn install_skill(options: &InstallOptions, config: &Config) -> Result { ensure_install_root_ready(&config.skills_source)?; @@ -170,13 +222,66 @@ pub fn install_skill_from_checkout( let replaced_existing = if target_dir.exists() { prompt_replace_existing_skill(&skill_slug, &target_dir)?; - remove_existing_path(&target_dir)?; true } else { false }; - copy_directory_recursive(&skill_source, &target_dir)?; + let unique_suffix = format!( + "{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("System clock is before UNIX_EPOCH")? + .as_nanos() + ); + let staging_dir = target_root.join(format!(".{}.tmp-{}", skill_slug, unique_suffix)); + let backup_dir = target_root.join(format!(".{}.bak-{}", skill_slug, unique_suffix)); + + if staging_dir.exists() { + remove_existing_path(&staging_dir)?; + } + + if backup_dir.exists() { + remove_existing_path(&backup_dir)?; + } + + if let Err(error) = copy_directory_recursive(&skill_source, &staging_dir) { + let _ = remove_existing_path(&staging_dir); + return Err(error); + } + + if replaced_existing { + fs::rename(&target_dir, &backup_dir).with_context(|| { + format!( + "Failed to move existing skill out of the way from {} to {}", + target_dir.display(), + backup_dir.display() + ) + })?; + + if let Err(error) = fs::rename(&staging_dir, &target_dir).with_context(|| { + format!( + "Failed to move staged skill into place from {} to {}", + staging_dir.display(), + target_dir.display() + ) + }) { + let _ = fs::rename(&backup_dir, &target_dir); + let _ = remove_existing_path(&staging_dir); + return Err(error); + } + + remove_existing_path(&backup_dir)?; + } else { + fs::rename(&staging_dir, &target_dir).with_context(|| { + format!( + "Failed to move staged skill into place from {} to {}", + staging_dir.display(), + target_dir.display() + ) + })?; + } Ok(InstallResult { skill_slug, @@ -208,15 +313,52 @@ fn resolve_skill_source( resolved_reference: &ResolvedInstallRef, ) -> Result { match &resolved_reference.selector { - SkillSelector::Path(skill_path) => { - let skill_root = checkout_root.join(skill_path); - validate_skill_directory(&skill_root)?; - Ok(skill_root) - } + SkillSelector::Path(skill_path) => resolve_checked_skill_path(checkout_root, skill_path), SkillSelector::Slug(skill_slug) => find_skill_directory_by_slug(checkout_root, skill_slug), } } +fn resolve_checked_skill_path(checkout_root: &Path, skill_path: &Path) -> Result { + if skill_path.is_absolute() + || skill_path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(anyhow!( + "Skill path must be a relative path within the checkout: {}", + skill_path.display() + )); + } + + let skill_root = checkout_root.join(skill_path); + validate_skill_directory(&skill_root)?; + + let canonical_checkout_root = fs::canonicalize(checkout_root).with_context(|| { + format!( + "Failed to canonicalize checkout root: {}", + checkout_root.display() + ) + })?; + let canonical_skill_root = fs::canonicalize(&skill_root).with_context(|| { + format!( + "Failed to canonicalize skill path: {}", + skill_root.display() + ) + })?; + + if !canonical_skill_root.starts_with(&canonical_checkout_root) { + return Err(anyhow!( + "Skill path escapes the checkout root: {}", + skill_path.display() + )); + } + + Ok(skill_root) +} + fn validate_skill_directory(path: &Path) -> Result<()> { if !path.exists() { return Err(anyhow!("Skill path does not exist: {}", path.display())); @@ -435,6 +577,11 @@ fn copy_directory_recursive(source: &Path, destination: &Path) -> Result<()> { if file_type.is_dir() { copy_directory_recursive(&source_path, &destination_path)?; + } else if file_type.is_symlink() { + return Err(anyhow!( + "Refusing to install skills containing symlinks: {}", + source_path.display() + )); } else { fs::copy(&source_path, &destination_path).with_context(|| { format!( diff --git a/tests/install_test.rs b/tests/install_test.rs index 981d6ef..4440e29 100644 --- a/tests/install_test.rs +++ b/tests/install_test.rs @@ -4,6 +4,8 @@ use capsync::install::{ install_skill_from_checkout, normalize_skill_slug, resolve_install_ref, }; use std::fs; +#[cfg(unix)] +use std::os::unix::fs::symlink; use std::path::PathBuf; use tempfile::tempdir; @@ -35,17 +37,12 @@ fn test_resolve_install_ref_skills_sh_url() { } #[test] -fn test_resolve_install_ref_http_skills_sh_url() { - let resolved = resolve_install_ref("http://skills.sh/vercel-labs/skills/find-skills").unwrap(); +fn test_resolve_install_ref_rejects_http_skills_sh_url() { + let error = resolve_install_ref("http://skills.sh/vercel-labs/skills/find-skills").unwrap_err(); assert_eq!( - resolved.repo_url, - "https://github.com/vercel-labs/skills.git" - ); - assert_eq!(resolved.branch, None); - assert_eq!( - resolved.selector, - SkillSelector::Slug("find-skills".to_string()) + error.to_string(), + "HTTP skills.sh references are not supported. Use https://skills.sh/owner/repo/skill-slug" ); } @@ -66,6 +63,48 @@ fn test_resolve_install_ref_github_tree_url() { ); } +#[test] +fn test_resolve_install_ref_github_tree_url_requires_skill_path() { + let error = resolve_install_ref("https://github.com/vercel-labs/skills/tree/main").unwrap_err(); + + assert_eq!( + error.to_string(), + "GitHub tree URLs must point to a concrete skill directory" + ); +} + +#[test] +fn test_resolve_install_ref_github_tree_url_decodes_encoded_branch_segment() { + let resolved = resolve_install_ref( + "https://github.com/vercel-labs/skills/tree/feature%2Ffind-skills/skills/find-skills", + ) + .unwrap(); + + assert_eq!( + resolved.repo_url, + "https://github.com/vercel-labs/skills.git" + ); + assert_eq!(resolved.branch, Some("feature/find-skills".to_string())); + assert_eq!( + resolved.selector, + SkillSelector::Path(PathBuf::from("skills/find-skills")) + ); +} + +#[test] +fn test_resolve_install_ref_github_tree_url_rejects_invalid_percent_encoding() { + let error = resolve_install_ref( + "https://github.com/vercel-labs/skills/tree/feature%ZZ/skills/find-skills", + ) + .unwrap_err(); + + assert!( + error + .to_string() + .contains("Invalid percent-encoding in GitHub tree URL component") + ); +} + #[test] fn test_resolve_install_ref_owner_repo_slug() { let resolved = resolve_install_ref("vercel-labs/skills/find-skills").unwrap(); @@ -179,6 +218,39 @@ fn test_install_skill_from_checkout_by_explicit_path() { assert!(result.installed_path.join("SKILL.md").exists()); } +#[test] +fn test_install_skill_from_checkout_rejects_parent_dir_path() { + let checkout_dir = tempdir().unwrap(); + let target_dir = tempdir().unwrap(); + + let escaped_skill_dir = checkout_dir + .path() + .parent() + .unwrap() + .join("escaped-skill-for-test"); + write_skill( + &escaped_skill_dir, + "Escaped Skill", + "Should not be reachable", + ); + + let resolved = ResolvedInstallRef { + repo_url: "https://github.com/vercel-labs/skills.git".to_string(), + branch: None, + selector: SkillSelector::Path(PathBuf::from("../escaped-skill-for-test")), + }; + + let error = + install_skill_from_checkout(checkout_dir.path(), &resolved, target_dir.path()).unwrap_err(); + assert!( + error + .to_string() + .contains("Skill path must be a relative path within the checkout") + ); + + fs::remove_dir_all(escaped_skill_dir).unwrap(); +} + #[test] fn test_install_skill_from_checkout_errors_on_missing_skill() { let checkout_dir = tempdir().unwrap(); @@ -274,3 +346,65 @@ fn test_install_skill_rejects_git_repo_skills_source() { .contains("Skills source is currently a git repository") ); } + +#[cfg(unix)] +#[test] +fn test_install_skill_from_checkout_rejects_symlinks() { + let checkout_dir = tempdir().unwrap(); + let target_dir = tempdir().unwrap(); + + let skill_dir = checkout_dir.path().join("skills").join("find-skills"); + write_skill(&skill_dir, "Find Skills", "Locate useful skills"); + fs::write(checkout_dir.path().join("outside.txt"), "outside").unwrap(); + symlink( + checkout_dir.path().join("outside.txt"), + skill_dir.join("linked.txt"), + ) + .unwrap(); + + let resolved = ResolvedInstallRef { + repo_url: "https://github.com/vercel-labs/skills.git".to_string(), + branch: None, + selector: SkillSelector::Slug("find-skills".to_string()), + }; + + let error = + install_skill_from_checkout(checkout_dir.path(), &resolved, target_dir.path()).unwrap_err(); + assert!( + error + .to_string() + .contains("Refusing to install skills containing symlinks") + ); +} + +#[cfg(unix)] +#[test] +fn test_install_skill_from_checkout_cleans_staging_dir_on_copy_failure() { + let checkout_dir = tempdir().unwrap(); + let target_dir = tempdir().unwrap(); + + let skill_dir = checkout_dir.path().join("skills").join("find-skills"); + write_skill(&skill_dir, "Find Skills", "Locate useful skills"); + fs::write(checkout_dir.path().join("outside.txt"), "outside").unwrap(); + symlink( + checkout_dir.path().join("outside.txt"), + skill_dir.join("linked.txt"), + ) + .unwrap(); + + let resolved = ResolvedInstallRef { + repo_url: "https://github.com/vercel-labs/skills.git".to_string(), + branch: None, + selector: SkillSelector::Slug("find-skills".to_string()), + }; + + let error = + install_skill_from_checkout(checkout_dir.path(), &resolved, target_dir.path()).unwrap_err(); + assert!( + error + .to_string() + .contains("Refusing to install skills containing symlinks") + ); + assert!(!target_dir.path().join("find-skills").exists()); + assert_eq!(fs::read_dir(target_dir.path()).unwrap().count(), 0); +} From 098b75861b7d0ffa10617d7869dc9897141429be Mon Sep 17 00:00:00 2001 From: PiX <69745008+pixincreate@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:30:29 +0530 Subject: [PATCH 5/6] docs: align install safety guidance --- README.md | 2 ++ documentation/agents.md | 3 +++ documentation/how-it-works.md | 2 ++ 3 files changed, 7 insertions(+) diff --git a/README.md b/README.md index 549de44..2c72d49 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,8 @@ Behavior: - Installs exactly one skill into `skills_source/` - Uses a temporary git checkout to resolve and copy the skill directory +- Rejects `http://skills.sh/...`; use HTTPS only +- For GitHub tree URLs, branch names containing `/` must be URL-encoded in the branch segment (for example `feature%2Fmy-branch`) - Refuses to install into a `skills_source` that is itself a git repository managed by `capsync clone` - Prompts before replacing an already-installed skill with the same slug - Leaves `commands_source` unchanged in v1 diff --git a/documentation/agents.md b/documentation/agents.md index f03489f..223bf46 100644 --- a/documentation/agents.md +++ b/documentation/agents.md @@ -182,6 +182,8 @@ capsync install --no-sync # Install without syncing **Behavior:** - Resolves exactly one skill from the provided reference - Uses a temporary git checkout and copies the selected skill into `skills_source/` +- Rejects `http://skills.sh/...`; HTTPS is required for `skills.sh` references +- For GitHub tree URLs, branch names containing `/` must be URL-encoded in the branch segment (for example `feature%2Fmy-branch`) - Prompts before replacing an already-installed skill with the same slug - Runs `capsync sync` automatically unless `--no-sync` is passed - Leaves `commands_source` unchanged in v1 @@ -442,6 +444,7 @@ capsync/ | "Repository not found" | Invalid repo URL | Check URL format: `owner/repo` | | "Failed to clone" | Network/auth error | Check internet connection | | "Install requires a concrete skill reference" | Repo-only ref provided | Use `owner/repo/skill-slug` or a GitHub tree URL | +| "HTTP skills.sh references are not supported" | Tried to use `http://skills.sh/...` | Switch to `https://skills.sh/...` | | "Skills source is currently a git repository" | Tried to install into clone-managed source | Use a different `skills_source` or continue using `capsync clone` | --- diff --git a/documentation/how-it-works.md b/documentation/how-it-works.md index 09598bc..0096ee9 100644 --- a/documentation/how-it-works.md +++ b/documentation/how-it-works.md @@ -170,6 +170,8 @@ Supported explicit references in v1: Key behavior: - Rejects repo-only refs like `owner/repo` +- Rejects `http://skills.sh/...`; HTTPS is required for `skills.sh` references +- For GitHub tree URLs, branch names containing `/` must be URL-encoded in the branch segment (for example `feature%2Fmy-branch`) - Clones to a temporary checkout, finds exactly one skill directory, then copies it into `skills_source/` - Refuses to install into a `skills_source` that is itself a git repo managed by `capsync clone` - Leaves `commands_source` untouched in v1 From 5a2315358e32304400d766f7a336f36f042fc5fc Mon Sep 17 00:00:00 2001 From: PiX <69745008+pixincreate@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:30:37 +0530 Subject: [PATCH 6/6] docs: clarify install command help --- src/cli.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli.rs b/src/cli.rs index 1f12ad7..3aed46b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -59,7 +59,9 @@ pub enum Commands { }, #[command(about = "Install a skill from an explicit reference")] Install { - #[arg(help = "Skill reference (skills.sh URL, GitHub tree URL, or owner/repo/skill)")] + #[arg( + help = "Skill reference (HTTPS skills.sh URL, GitHub tree URL, or owner/repo/skill)" + )] reference: String, #[arg(long)] #[arg(help = "Skip syncing after install")]