Initial commit: Claude Code Skill Marketplace manager
HTTP-based CLI tool to discover and install Claude Code skills from marketplace repositories. Downloads skills via HTTP archive endpoints (zip/tar.gz) and deploys via copy or symlink into ~/.claude/skills/.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+2123
File diff suppressed because it is too large
Load Diff
+18
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user