feat: add Claude Code Skill Marketplace manager

This commit is contained in:
2026-06-18 17:30:52 +08:00
commit aef7d973ec
9 changed files with 3340 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+2123
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "claude-code-skill-manager"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "ccsm"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
dirs = "5"
reqwest = { version = "0.12", features = ["blocking", "rustls-tls"], default-features = false }
zip = "2"
flate2 = "1"
tar = "0.4"
+103
View File
@@ -0,0 +1,103 @@
use clap::{Args, Parser, Subcommand};
#[derive(Parser)]
#[command(
name = "ccsm",
about = "Claude Code Skill Marketplace — discover and install skills from Git marketplaces",
version
)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
/// Manage skill marketplaces
#[command(subcommand)]
Marketplace(MarketplaceCommand),
/// Search for skills across all marketplaces
Search(SearchArgs),
/// Install a skill from a marketplace
Install(InstallArgs),
/// List installed skills in the local store
List,
/// Remove an installed skill
Remove(RemoveArgs),
/// Update installed skills
Update(UpdateArgs),
/// Show details about an installed skill
Info(InfoArgs),
}
#[derive(Subcommand)]
pub enum MarketplaceCommand {
/// Add a new skill marketplace
Add(MarketplaceAddArgs),
/// Remove a marketplace
Remove(MarketplaceRemoveArgs),
/// List configured marketplaces
List,
/// Update marketplace catalogues
Update(MarketplaceUpdateArgs),
}
#[derive(Args)]
pub struct MarketplaceAddArgs {
/// Git repository URL of the marketplace
pub url: String,
/// Custom name for this marketplace (defaults to repo name)
#[arg(short, long)]
pub name: Option<String>,
}
#[derive(Args)]
pub struct MarketplaceRemoveArgs {
/// Name of the marketplace to remove
pub name: String,
}
#[derive(Args)]
pub struct MarketplaceUpdateArgs {
/// Name of the marketplace to update (updates all if omitted)
pub name: Option<String>,
}
#[derive(Args)]
pub struct SearchArgs {
/// Filter skills by keyword
pub query: Option<String>,
}
#[derive(Args)]
pub struct InstallArgs {
/// Name of the skill to install
pub name: String,
/// Specify which marketplace to use (required when skill exists in multiple)
#[arg(short, long)]
pub marketplace: Option<String>,
/// Copy skill files into Claude skills directory (default)
#[arg(long)]
pub copy: bool,
/// Create a symlink into Claude skills directory instead of copying
#[arg(long)]
pub link: bool,
}
#[derive(Args)]
pub struct RemoveArgs {
/// Name of the skill to remove
pub name: String,
}
#[derive(Args)]
pub struct UpdateArgs {
/// Name of the skill to update (updates all if omitted)
pub name: Option<String>,
}
#[derive(Args)]
pub struct InfoArgs {
/// Name of the skill
pub name: String,
}
+95
View File
@@ -0,0 +1,95 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::io;
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum InstallMode {
Copy,
Link,
}
impl std::fmt::Display for InstallMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
InstallMode::Copy => write!(f, "copy"),
InstallMode::Link => write!(f, "link"),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MarketplaceEntry {
pub name: String,
pub url: String,
#[serde(default)]
pub branch: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub marketplaces: Vec<MarketplaceEntry>,
#[serde(default = "default_mode")]
pub default_mode: InstallMode,
}
fn default_mode() -> InstallMode {
InstallMode::Copy
}
impl Config {
pub fn load() -> io::Result<Self> {
let path = config_path();
if path.exists() {
let data = fs::read_to_string(&path)?;
Ok(serde_json::from_str(&data).unwrap_or_default())
} else {
Ok(Config::default())
}
}
pub fn save(&self) -> io::Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let data = serde_json::to_string_pretty(self)?;
fs::write(path, data)
}
}
impl Default for Config {
fn default() -> Self {
Config {
marketplaces: Vec::new(),
default_mode: default_mode(),
}
}
}
pub fn ccsm_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("ccsm")
}
fn config_path() -> PathBuf {
ccsm_dir().join("config.json")
}
pub fn marketplaces_cache_dir() -> PathBuf {
ccsm_dir().join("marketplaces")
}
pub fn skills_store_dir() -> PathBuf {
ccsm_dir().join("skills")
}
pub fn claude_skills_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".claude")
.join("skills")
}
+208
View File
@@ -0,0 +1,208 @@
use std::fs;
use std::io::{self, Cursor, Read};
use std::path::Path;
pub fn get_json(url: &str) -> Result<String, String> {
let resp = reqwest::blocking::get(url)
.map_err(|e| format!("HTTP request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
return Err(format!("HTTP {status} from {url}"));
}
resp.text()
.map_err(|e| format!("Cannot read response body: {e}"))
}
pub fn fetch_marketplace_manifest(repo_url: &str) -> Result<(String, String), String> {
let base = repo_url.trim_end_matches('/').trim_end_matches(".git");
for branch in &["main", "master"] {
let url = format!("{base}/raw/branch/{branch}/skills.json");
match get_json(&url) {
Ok(json) => return Ok((json, branch.to_string())),
Err(_) => continue,
}
}
Err(format!(
"Cannot fetch skills.json from {base}. Tried branches 'main' and 'master'.\n\
Make sure the repository is public and contains skills.json at its root."
))
}
pub fn download_skill(repo_url: &str, branch: &str, dest: &Path) -> Result<(), String> {
let base = repo_url.trim_end_matches('/').trim_end_matches(".git");
let mut last_err = String::new();
for ext in &["zip", "tar.gz"] {
let url = format!("{base}/archive/{branch}.{ext}");
match download_and_extract(&url, dest) {
Ok(()) => return Ok(()),
Err(e) => {
last_err = e;
continue;
}
}
}
Err(format!("Cannot download skill from {base}: {last_err}"))
}
fn download_and_extract(url: &str, dest: &Path) -> Result<(), String> {
let resp = reqwest::blocking::get(url)
.map_err(|e| format!("HTTP request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
return Err(format!("HTTP {status}"));
}
let bytes = resp
.bytes()
.map_err(|e| format!("Cannot read response body: {e}"))?;
if url.ends_with(".zip") {
extract_zip(&bytes, dest)
} else if url.ends_with(".tar.gz") {
extract_tar_gz(&bytes, dest)
} else if bytes.len() >= 2 && &bytes[..2] == b"PK" {
extract_zip(&bytes, dest)
} else if bytes.len() >= 2 && &bytes[..2] == b"\x1f\x8b" {
extract_tar_gz(&bytes, dest)
} else {
Err("Unknown archive format".to_string())
}
}
fn extract_zip(data: &[u8], dest: &Path) -> Result<(), String> {
let cursor = Cursor::new(data);
let mut archive =
zip::ZipArchive::new(cursor).map_err(|e| format!("Cannot open zip: {e}"))?;
let prefix = detect_strip_prefix_zip(&mut archive);
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("Cannot read zip entry: {e}"))?;
let name = file.name().to_string();
let entry_path = match &prefix {
Some(p) => match name.strip_prefix(p) {
Some(rest) if !rest.is_empty() => rest,
_ => continue,
},
None => &name,
};
let out_path = dest.join(entry_path);
if file.is_dir() {
fs::create_dir_all(&out_path)
.map_err(|e| format!("Cannot create dir {}: {e}", out_path.display()))?;
} else {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?;
}
let mut out = fs::File::create(&out_path)
.map_err(|e| format!("Cannot create file {}: {e}", out_path.display()))?;
io::copy(&mut file, &mut out)
.map_err(|e| format!("Cannot write file {}: {e}", out_path.display()))?;
}
}
Ok(())
}
fn extract_tar_gz(data: &[u8], dest: &Path) -> Result<(), String> {
let decoder = flate2::read::GzDecoder::new(data);
let mut archive = tar::Archive::new(decoder);
let entries: Vec<_> = archive
.entries()
.map_err(|e| format!("Cannot read tar: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Cannot read tar entry: {e}"))?;
let prefix = detect_strip_prefix_tar(&entries);
for mut entry in entries {
let path = entry
.path()
.map_err(|e| format!("Cannot read entry path: {e}"))?;
let path_str = path.to_string_lossy().to_string();
let entry_path = match &prefix {
Some(p) => match path_str.strip_prefix(p) {
Some(rest) if !rest.is_empty() => rest,
_ => continue,
},
None => &path_str,
};
let out_path = dest.join(entry_path);
if entry.header().entry_type().is_dir() {
fs::create_dir_all(&out_path)
.map_err(|e| format!("Cannot create dir {}: {e}", out_path.display()))?;
} else {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?;
}
entry
.unpack(&out_path)
.map_err(|e| format!("Cannot unpack {}: {e}", out_path.display()))?;
}
}
Ok(())
}
fn detect_strip_prefix_zip(archive: &mut zip::ZipArchive<Cursor<&[u8]>>) -> Option<String> {
let mut top_dirs: Vec<String> = Vec::new();
for i in 0..archive.len() {
if let Ok(file) = archive.by_index(i) {
let name = file.name();
if let Some(slash_pos) = name.find('/') {
let top = &name[..=slash_pos];
if top_dirs.is_empty() || top_dirs[0] == top {
top_dirs.push(top.to_string());
} else {
return None;
}
} else if !name.is_empty() {
return None;
}
}
}
if top_dirs.len() >= 2 {
Some(top_dirs[0].clone())
} else {
None
}
}
fn detect_strip_prefix_tar(entries: &[tar::Entry<impl Read>]) -> Option<String> {
let mut prefix: Option<String> = None;
for entry in entries {
if let Ok(path) = entry.path() {
let path_str = path.to_string_lossy().to_string();
if let Some(slash_pos) = path_str.find('/') {
let top = &path_str[..=slash_pos];
match &prefix {
Some(p) if p != top => return None,
None => prefix = Some(top.to_string()),
_ => {}
}
} else if !path_str.is_empty() {
return None;
}
}
}
prefix
}
+210
View File
@@ -0,0 +1,210 @@
mod cli;
mod config;
mod download;
mod marketplace;
mod skill;
use clap::Parser;
use cli::{Cli, Command, MarketplaceCommand};
use config::InstallMode;
fn main() {
let cli = Cli::parse();
if let Err(e) = run(cli) {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
fn run(cli: Cli) -> Result<(), String> {
match cli.command {
Command::Marketplace(cmd) => run_marketplace(cmd),
Command::Search(args) => {
let results = marketplace::search(args.query.as_deref())?;
if results.is_empty() {
println!("No skills found.");
if args.query.is_none() {
println!(
"No marketplaces configured, or all marketplaces are empty."
);
println!("Use `ccsm marketplace add <url>` to add a marketplace.");
}
} else {
println!("Found {} skill(s):\n", results.len());
for (market, skill) in &results {
println!(" {} ({})", skill.name, market);
println!(" {}\n", skill.description);
}
}
Ok(())
}
Command::Install(args) => {
let mode = if args.link {
Some(InstallMode::Link)
} else if args.copy {
Some(InstallMode::Copy)
} else {
None
};
let mode_label = match &mode {
Some(InstallMode::Link) => " (link)",
Some(InstallMode::Copy) => " (copy)",
None => "",
};
let s = skill::install(&args.name, args.marketplace.as_deref(), mode)?;
println!(
"Installed skill '{}'{}",
s.name, mode_label
);
println!(" Store: {}", s.path.display());
println!(" Claude: {}", config::claude_skills_dir().join(&s.name).display());
println!(" Remote: {}", s.remote_url);
println!(" Branch: {}", s.branch);
Ok(())
}
Command::List => {
let skills = skill::list()?;
if skills.is_empty() {
println!("No skills installed.");
println!("Store: {}", config::skills_store_dir().display());
println!(
"Use `ccsm search` to browse available skills, then `ccsm install <name>`."
);
} else {
println!("Installed skills ({})\n", skills.len());
for s in &skills {
let has_manifest = has_skill_manifest(&s.path);
let marker = if has_manifest { " " } else { "?" };
let mode_str = match &s.mode {
Some(InstallMode::Link) => "[link]".to_string(),
Some(InstallMode::Copy) => "[copy]".to_string(),
None => String::new(),
};
println!(" {marker} {} {mode_str}", s.name);
}
if skills
.iter()
.any(|s| !has_skill_manifest(&s.path))
{
println!(
"\n (?) No SKILL.md or CLAUDE.md found — these may not be valid skill repos."
);
}
}
Ok(())
}
Command::Remove(args) => {
skill::remove(&args.name)?;
println!("Removed skill '{}'", args.name);
Ok(())
}
Command::Update(args) => {
let results = skill::update(args.name.as_deref())?;
if results.is_empty() {
println!("No skills to update.");
}
for (name, msg) in &results {
println!("{name}: {msg}");
}
Ok(())
}
Command::Info(args) => {
let s = skill::info(&args.name)?;
println!("Skill: {}", s.name);
println!(" Store: {}", s.path.display());
println!(
" Claude: {}",
config::claude_skills_dir().join(&s.name).display()
);
let deploy_status = match &s.mode {
Some(InstallMode::Link) => "linked",
Some(InstallMode::Copy) => "copied",
None => "not deployed",
};
println!(" Status: {deploy_status}");
let skill_md = s.path.join("SKILL.md");
let claude_md = s.path.join("CLAUDE.md");
if skill_md.exists() {
println!(" Manifest: SKILL.md");
} else if claude_md.exists() {
println!(" Manifest: CLAUDE.md");
} else {
println!(" Manifest: none found");
}
let entries = std::fs::read_dir(&s.path)
.map_err(|e| format!("Cannot read skill directory: {e}"))?;
let mut files = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| format!("I/O error: {e}"))?;
let fname = entry.file_name().to_string_lossy().to_string();
if !fname.starts_with('.') && fname != "target" {
let ft = if entry.path().is_dir() { "/" } else { "" };
files.push(format!("{fname}{ft}"));
}
}
if !files.is_empty() {
files.sort();
println!(" Files:");
for f in &files {
println!(" - {f}");
}
}
Ok(())
}
}
}
fn run_marketplace(cmd: MarketplaceCommand) -> Result<(), String> {
match cmd {
MarketplaceCommand::Add(args) => {
marketplace::add(&args.url, args.name.as_deref())?;
Ok(())
}
MarketplaceCommand::Remove(args) => {
marketplace::remove(&args.name)?;
println!("Removed marketplace '{}'", args.name);
Ok(())
}
MarketplaceCommand::List => {
let markets = marketplace::list()?;
if markets.is_empty() {
println!("No marketplaces configured.");
println!(
"Use `ccsm marketplace add <url>` to add a skill marketplace."
);
} else {
println!("Marketplaces ({})\n", markets.len());
for (entry, count) in &markets {
let branch = entry.branch.as_deref().unwrap_or("?");
println!(
" {} ({} skills, branch: {})",
entry.name, count, branch
);
println!(" {}\n", entry.url);
}
}
Ok(())
}
MarketplaceCommand::Update(args) => {
let results = marketplace::update(args.name.as_deref())?;
for (name, msg) in &results {
println!("{name}: {msg}");
}
Ok(())
}
}
}
fn has_skill_manifest(path: &std::path::Path) -> bool {
path.join("SKILL.md").exists()
|| path.join("skill.md").exists()
|| path.join("CLAUDE.md").exists()
|| path.join("claude.md").exists()
}
+273
View File
@@ -0,0 +1,273 @@
use crate::config::{self, Config, MarketplaceEntry};
use crate::download;
use serde::{Deserialize, Serialize};
use std::fs;
#[derive(Debug, Serialize, Deserialize)]
pub struct Manifest {
pub name: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub skills: Vec<SkillEntry>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SkillEntry {
pub name: String,
pub description: String,
pub repository: String,
#[serde(default)]
pub branch: Option<String>,
}
pub fn add(url: &str, name: Option<&str>) -> Result<MarketplaceEntry, String> {
let market_name = name
.map(|n| n.to_string())
.unwrap_or_else(|| repo_name_from_url(url));
let mut config = Config::load().map_err(|e| format!("Cannot read config: {e}"))?;
if config.marketplaces.iter().any(|m| m.name == market_name) {
return Err(format!(
"Marketplace '{market_name}' is already registered. Use `ccsm marketplace update {market_name}` to refresh."
));
}
let (json, branch) = download::fetch_marketplace_manifest(url)?;
let manifest: Manifest =
serde_json::from_str(&json).map_err(|e| format!("Invalid skills.json: {e}"))?;
// Cache the manifest locally
let cache_dir = config::marketplaces_cache_dir();
fs::create_dir_all(&cache_dir)
.map_err(|e| format!("Cannot create cache dir: {e}"))?;
fs::write(cache_dir.join(format!("{market_name}.json")), &json)
.map_err(|e| format!("Cannot cache manifest: {e}"))?;
let entry = MarketplaceEntry {
name: market_name.clone(),
url: url.to_string(),
branch: Some(branch),
};
config.marketplaces.push(entry.clone());
config.save().map_err(|e| format!("Cannot save config: {e}"))?;
println!(
"Marketplace '{}' added ({} skills from \"{}\")",
market_name,
manifest.skills.len(),
manifest.name
);
Ok(entry)
}
pub fn remove(name: &str) -> Result<(), String> {
let mut config = Config::load().map_err(|e| format!("Cannot read config: {e}"))?;
let pos = config
.marketplaces
.iter()
.position(|m| m.name == name)
.ok_or_else(|| format!("Marketplace '{name}' not found."))?;
config.marketplaces.remove(pos);
config.save().map_err(|e| format!("Cannot save config: {e}"))?;
let cache_path = config::marketplaces_cache_dir().join(format!("{name}.json"));
if cache_path.exists() {
fs::remove_file(&cache_path).ok();
}
Ok(())
}
pub fn list() -> Result<Vec<(MarketplaceEntry, usize)>, String> {
let config = Config::load().map_err(|e| format!("Cannot read config: {e}"))?;
let mut result = Vec::new();
for entry in &config.marketplaces {
let manifest = load_cached_manifest(&entry.name);
let count = manifest.map(|m| m.skills.len()).unwrap_or(0);
result.push((entry.clone(), count));
}
Ok(result)
}
pub fn update(name: Option<&str>) -> Result<Vec<(String, String)>, String> {
let config = Config::load().map_err(|e| format!("Cannot read config: {e}"))?;
let targets: Vec<MarketplaceEntry> = match name {
Some(n) => {
let entry = config
.marketplaces
.iter()
.find(|m| m.name == n)
.ok_or_else(|| format!("Marketplace '{n}' not found."))?
.clone();
vec![entry]
}
None => config.marketplaces.clone(),
};
if targets.is_empty() {
return Err("No marketplaces configured. Use `ccsm marketplace add <url>` first."
.to_string());
}
let mut results = Vec::new();
for entry in &targets {
let name = entry.name.clone();
match download::fetch_marketplace_manifest(&entry.url) {
Ok((json, _branch)) => {
let manifest: Manifest = match serde_json::from_str(&json) {
Ok(m) => m,
Err(e) => {
results.push((name, format!("Error: invalid skills.json — {e}")));
continue;
}
};
let cache_dir = config::marketplaces_cache_dir();
fs::create_dir_all(&cache_dir).ok();
fs::write(cache_dir.join(format!("{name}.json")), &json).ok();
results.push((
name,
format!("Updated — {} skills available", manifest.skills.len()),
));
}
Err(e) => results.push((name, format!("Error: {e}"))),
}
}
Ok(results)
}
pub fn search(query: Option<&str>) -> Result<Vec<(String, SkillEntry)>, String> {
let config = Config::load().map_err(|e| format!("Cannot read config: {e}"))?;
if config.marketplaces.is_empty() {
return Err("No marketplaces configured. Use `ccsm marketplace add <url>` first."
.to_string());
}
let mut results = Vec::new();
let query_lower = query.map(|q| q.to_lowercase());
for entry in &config.marketplaces {
let manifest = match load_cached_manifest(&entry.name) {
Ok(m) => m,
Err(_) => continue,
};
for skill in manifest.skills {
let matches = match &query_lower {
Some(q) => {
skill.name.to_lowercase().contains(q)
|| skill.description.to_lowercase().contains(q)
}
None => true,
};
if matches {
results.push((entry.name.clone(), skill));
}
}
}
results.sort_by(|a, b| a.1.name.cmp(&b.1.name));
Ok(results)
}
pub fn resolve_skill(
name: &str,
marketplace: Option<&str>,
) -> Result<(MarketplaceEntry, SkillEntry), String> {
let config = Config::load().map_err(|e| format!("Cannot read config: {e}"))?;
let mut matches = Vec::new();
for entry in &config.marketplaces {
if let Some(ref mkt) = marketplace {
if entry.name != *mkt {
continue;
}
}
let manifest = match load_cached_manifest(&entry.name) {
Ok(m) => m,
Err(_) => continue,
};
for skill in &manifest.skills {
if skill.name == name {
matches.push((entry.clone(), skill.clone()));
}
}
}
match matches.len() {
0 => Err(format!(
"Skill '{name}' not found in any marketplace. Use `ccsm search` to browse available skills."
)),
1 => Ok(matches.pop().unwrap()),
_ => {
let market_names: Vec<String> =
matches.iter().map(|(m, _)| m.name.clone()).collect();
Err(format!(
"Skill '{name}' found in multiple marketplaces: {}. Use --marketplace to specify which one.",
market_names.join(", ")
))
}
}
}
pub fn load_cached_manifest(name: &str) -> Result<Manifest, String> {
let path = config::marketplaces_cache_dir().join(format!("{name}.json"));
if !path.exists() {
return Err(format!("Marketplace '{name}' cache not found. Run `ccsm marketplace update {name}`."));
}
let data = fs::read_to_string(&path).map_err(|e| format!("Cannot read cache: {e}"))?;
serde_json::from_str(&data).map_err(|e| format!("Invalid cached manifest: {e}"))
}
fn repo_name_from_url(url: &str) -> String {
url.trim_end_matches('/')
.trim_end_matches(".git")
.rsplit('/')
.next()
.unwrap_or("marketplace")
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_repo_name_from_url_https() {
assert_eq!(
repo_name_from_url("https://github.com/user/my-skill.git"),
"my-skill"
);
}
#[test]
fn test_repo_name_from_url_no_git_suffix() {
assert_eq!(
repo_name_from_url("https://github.com/user/my-skill"),
"my-skill"
);
}
#[test]
fn test_repo_name_from_url_trailing_slash() {
assert_eq!(
repo_name_from_url("https://github.com/user/my-skill/"),
"my-skill"
);
}
}
+309
View File
@@ -0,0 +1,309 @@
use crate::config::{self, Config, InstallMode};
use crate::download;
use crate::marketplace;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io;
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize)]
pub struct Skill {
pub name: String,
pub path: PathBuf,
pub remote_url: String,
pub branch: String,
#[serde(default)]
pub mode: Option<InstallMode>,
}
pub fn install(
name: &str,
marketplace_name: Option<&str>,
mode: Option<InstallMode>,
) -> Result<Skill, String> {
let (_, entry) = marketplace::resolve_skill(name, marketplace_name)?;
let store_dir = config::skills_store_dir();
fs::create_dir_all(&store_dir)
.map_err(|e| format!("Cannot create skills store: {e}"))?;
let target = store_dir.join(name);
if target.exists() {
return Err(format!(
"Skill '{name}' is already in the store. Use `ccsm update {name}` to refresh, or `ccsm remove {name}` first."
));
}
let branch = entry.branch.as_deref().unwrap_or("main");
download::download_skill(&entry.repository, branch, &target)?;
// Deploy to Claude skills directory (copy or link)
let config = Config::load().map_err(|e| format!("Cannot read config: {e}"))?;
let deploy_mode = mode.unwrap_or_else(|| config.default_mode.clone());
deploy_skill(name, &target, &deploy_mode)?;
Ok(Skill {
name: name.to_string(),
path: target,
remote_url: entry.repository,
branch: branch.to_string(),
mode: Some(deploy_mode),
})
}
fn deploy_skill(name: &str, source: &PathBuf, mode: &InstallMode) -> Result<(), String> {
let claude_dir = config::claude_skills_dir();
fs::create_dir_all(&claude_dir)
.map_err(|e| format!("Cannot create Claude skills dir: {e}"))?;
let dest = claude_dir.join(name);
match mode {
InstallMode::Copy => {
if dest.exists() {
fs::remove_dir_all(&dest).ok();
}
copy_dir_all(source, &dest)
.map_err(|e| format!("Cannot copy skill to {}: {e}", dest.display()))?;
}
InstallMode::Link => {
if dest.exists() {
// Remove existing link or directory
if dest.is_symlink() || dest.is_dir() {
fs::remove_dir_all(&dest).ok();
#[cfg(windows)]
{
// On Windows, directory symlinks may need junction handling
fs::remove_dir(&dest).ok();
}
}
}
symlink_dir(source, &dest)?;
}
}
Ok(())
}
fn copy_dir_all(src: &PathBuf, dst: &PathBuf) -> io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if ty.is_dir() {
copy_dir_all(&src_path, &dst_path)?;
} else {
fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
#[cfg(unix)]
fn symlink_dir(src: &PathBuf, dst: &PathBuf) -> Result<(), String> {
std::os::unix::fs::symlink(src, dst)
.map_err(|e| format!("Cannot create symlink: {e}"))
}
#[cfg(windows)]
fn symlink_dir(src: &PathBuf, dst: &PathBuf) -> Result<(), String> {
std::os::windows::fs::symlink_dir(src, dst)
.map_err(|e| format!("Cannot create symlink: {e}. Directory symlinks require Windows 10+ with Developer Mode enabled, or administrator privileges."))
}
pub fn list() -> Result<Vec<Skill>, String> {
let dir = config::skills_store_dir();
if !dir.exists() {
return Ok(Vec::new());
}
let entries = fs::read_dir(&dir).map_err(|e| format!("Cannot read skills store: {e}"))?;
let mut skills = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| format!("I/O error: {e}"))?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
// We don't have git remotes anymore, so omit remote_url and branch for now
let mode = detect_deploy_mode(&path);
skills.push(Skill {
name,
path,
remote_url: String::new(),
branch: String::new(),
mode,
});
}
skills.sort_by(|a, b| a.name.cmp(&b.name));
Ok(skills)
}
pub fn remove(name: &str) -> Result<(), String> {
let store_dir = config::skills_store_dir();
let target = store_dir.join(name);
// Remove from Claude skills if present (copy or link)
let claude_target = config::claude_skills_dir().join(name);
if claude_target.exists() {
fs::remove_dir_all(&claude_target)
.map_err(|e| format!("Cannot remove skill from Claude dir: {e}"))?;
}
// Remove from store
if target.exists() {
fs::remove_dir_all(&target)
.map_err(|e| format!("Cannot remove skill from store: {e}"))?;
} else {
return Err(format!("Skill '{name}' is not installed."));
}
Ok(())
}
pub fn update(name: Option<&str>) -> Result<Vec<(String, String)>, String> {
let store_dir = config::skills_store_dir();
if !store_dir.exists() {
return Err("No skills installed.".to_string());
}
let config = Config::load().map_err(|e| format!("Cannot read config: {e}"))?;
let targets: Vec<(String, PathBuf, Option<InstallMode>)> = match name {
Some(n) => {
let p = store_dir.join(n);
if !p.exists() {
return Err(format!("Skill '{n}' is not installed."));
}
let mode = detect_deploy_mode(&p);
vec![(n.to_string(), p, mode)]
}
None => {
let entries =
fs::read_dir(&store_dir).map_err(|e| format!("Cannot read skills store: {e}"))?;
let mut paths = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| format!("I/O error: {e}"))?;
let path = entry.path();
if path.is_dir() {
let skill_name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let mode = detect_deploy_mode(&path);
paths.push((skill_name, path, mode));
}
}
paths
}
};
let mut results = Vec::new();
for (skill_name, _path, deploy_mode) in &targets {
// Find the skill in a marketplace to get its repo URL
let manifest_skill = find_skill_in_marketplaces(skill_name, &config);
match manifest_skill {
Some((_mkt_entry, skill_entry)) => {
let branch = skill_entry.branch.as_deref().unwrap_or("main");
// Remove old version
let old_path = store_dir.join(skill_name);
if old_path.exists() {
fs::remove_dir_all(&old_path).ok();
}
match download::download_skill(&skill_entry.repository, branch, &old_path) {
Ok(()) => {
// Redeploy if previously deployed
if deploy_mode.is_some() || config.default_mode != InstallMode::Copy {
let mode = deploy_mode
.clone()
.unwrap_or_else(|| config.default_mode.clone());
match deploy_skill(skill_name, &old_path, &mode) {
Ok(()) => results.push((skill_name.clone(), "Updated".to_string())),
Err(e) => results.push((skill_name.clone(), format!("Downloaded but deploy failed: {e}"))),
}
} else {
results.push((skill_name.clone(), "Updated".to_string()));
}
}
Err(e) => results.push((skill_name.clone(), format!("Error: {e}"))),
}
}
None => {
results.push((
skill_name.clone(),
"Warning: skill not found in any marketplace; cannot update".to_string(),
));
}
}
}
Ok(results)
}
pub fn info(name: &str) -> Result<Skill, String> {
let target = config::skills_store_dir().join(name);
if !target.exists() {
return Err(format!("Skill '{name}' is not installed."));
}
let mode = detect_deploy_mode(&target);
Ok(Skill {
name: name.to_string(),
path: target,
remote_url: String::new(),
branch: String::new(),
mode,
})
}
fn detect_deploy_mode(path: &PathBuf) -> Option<InstallMode> {
let claude_target = config::claude_skills_dir().join(
path.file_name().unwrap_or_default(),
);
if !claude_target.exists() {
return None;
}
if claude_target.is_symlink() {
Some(InstallMode::Link)
} else {
Some(InstallMode::Copy)
}
}
fn find_skill_in_marketplaces(
name: &str,
config: &Config,
) -> Option<(config::MarketplaceEntry, marketplace::SkillEntry)> {
for entry in &config.marketplaces {
let manifest = marketplace::load_cached_manifest(&entry.name).ok()?;
for skill in &manifest.skills {
if skill.name == name {
return Some((entry.clone(), skill.clone()));
}
}
}
None
}