use serde_json::{json, Map, Value}; use std::env; use std::fs; use std::path::{Path, PathBuf}; const BUNDLE_JSON: &str = include_str!("../assets/skill-bundle.json"); const REPO: &str = "zseven-w/openpencil-skill"; const REPO_URL: &str = "https://github.com/zseven-w/openpencil-skill.git"; const SKILL_NAME: &str = "openpencil-skill"; #[derive(Debug, Clone)] struct SkillBundle { version: String, files: Vec<(String, String)>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Target { Claude, Codex, Cursor, Gemini, OpenCode, } impl Target { fn parse(raw: &str) -> Result { match raw.to_ascii_lowercase().as_str() { "claude" | "claude-code" | "claudecode" => Ok(Target::Claude), "codex" => Ok(Target::Codex), "cursor" => Ok(Target::Cursor), "gemini" | "gemini-cli" => Ok(Target::Gemini), "opencode" | "open-code" => Ok(Target::OpenCode), _ => Err(format!( "unknown target {raw:?}; available: claude, codex, cursor, gemini, opencode" )), } } fn key(self) -> &'static str { match self { Target::Claude => "claude", Target::Codex => "codex", Target::Cursor => "cursor", Target::Gemini => "gemini", Target::OpenCode => "opencode", } } } pub(crate) fn run_install(target: Option<&str>) -> Result { run_for_home(Action::Install, target, &home_dir()?) } pub(crate) fn run_uninstall(target: Option<&str>) -> Result { run_for_home(Action::Uninstall, target, &home_dir()?) } #[cfg(test)] pub(crate) fn install_target_at_home(target: &str, home: &Path) -> Result<(), String> { let bundle = load_bundle()?; install_target(Target::parse(target)?, home, &bundle) } #[cfg(test)] pub(crate) fn uninstall_target_at_home(target: &str, home: &Path) -> Result<(), String> { uninstall_target(Target::parse(target)?, home) } #[derive(Debug, Clone, Copy)] enum Action { Install, Uninstall, } fn run_for_home(action: Action, target: Option<&str>, home: &Path) -> Result { let targets = resolve_targets(target, action, home)?; let bundle = load_bundle()?; let mut results = Vec::new(); for target in targets { let result = match action { Action::Install => install_target(target, home, &bundle), Action::Uninstall => uninstall_target(target, home), }; results.push(match result { Ok(()) => json!({ "target": target.key(), "ok": true }), Err(error) => json!({ "target": target.key(), "ok": false, "error": error }), }); } Ok(json!({ "ok": results.iter().all(|r| r["ok"].as_bool() == Some(true)), "action": match action { Action::Install => "install", Action::Uninstall => "uninstall" }, "skill": SKILL_NAME, "version": bundle.version, "targets": results, }) .to_string()) } fn resolve_targets( target: Option<&str>, action: Action, home: &Path, ) -> Result, String> { if let Some(target) = target { return Ok(vec![Target::parse(target)?]); } let detected = detect_targets(home); if detected.is_empty() && matches!(action, Action::Install) { return Err( "no supported AI coding agents detected; pass --target claude|codex|cursor|gemini|opencode" .into(), ); } Ok(detected) } fn detect_targets(home: &Path) -> Vec { let mut targets = Vec::new(); if command_exists("claude") { targets.push(Target::Claude); } if command_exists("codex") { targets.push(Target::Codex); } if home.join(".cursor").exists() { targets.push(Target::Cursor); } if command_exists("gemini") { targets.push(Target::Gemini); } if command_exists("opencode") { targets.push(Target::OpenCode); } targets } fn command_exists(name: &str) -> bool { let Some(path_var) = env::var_os("PATH") else { return false; }; env::split_paths(&path_var).any(|dir| { let candidate = dir.join(name); candidate.is_file() || candidate.with_extension("exe").is_file() }) } fn install_target(target: Target, home: &Path, bundle: &SkillBundle) -> Result<(), String> { match target { Target::Claude => install_claude(home, bundle), Target::Codex => install_codex(home, bundle), Target::Cursor => write_bundle_to(&home.join(".cursor/plugins").join(SKILL_NAME), bundle), Target::Gemini => { write_bundle_to(&home.join(".gemini/extensions").join(SKILL_NAME), bundle) } Target::OpenCode => install_opencode(home), } } fn uninstall_target(target: Target, home: &Path) -> Result<(), String> { match target { Target::Claude => uninstall_claude(home), Target::Codex => uninstall_codex(home), Target::Cursor => remove_path(&home.join(".cursor/plugins").join(SKILL_NAME)), Target::Gemini => remove_path(&home.join(".gemini/extensions").join(SKILL_NAME)), Target::OpenCode => uninstall_opencode(home), } } fn install_claude(home: &Path, bundle: &SkillBundle) -> Result<(), String> { let cache_dir = home .join(".claude/plugins/cache") .join(SKILL_NAME) .join(SKILL_NAME) .join(&bundle.version); write_bundle_to(&cache_dir, bundle)?; let registry_path = home.join(".claude/plugins/installed_plugins.json"); let mut registry = read_json_object(®istry_path)?; registry .entry("version") .or_insert_with(|| Value::Number(2.into())); let plugins = object_entry(&mut registry, "plugins")?; plugins.insert( format!("{SKILL_NAME}@{SKILL_NAME}"), json!([{ "scope": "user", "installPath": cache_dir.display().to_string(), "version": bundle.version, "installedAt": timestamp_string(), "lastUpdated": timestamp_string(), }]), ); write_json_object(®istry_path, ®istry)?; let marketplace_path = home.join(".claude/plugins/known_marketplaces.json"); let mut marketplaces = read_json_object(&marketplace_path)?; marketplaces.entry(SKILL_NAME).or_insert_with(|| { json!({ "source": { "source": "github", "repo": REPO }, "installLocation": home.join(".claude/plugins/marketplaces").join(SKILL_NAME).display().to_string(), "lastUpdated": timestamp_string(), }) }); write_json_object(&marketplace_path, &marketplaces) } fn uninstall_claude(home: &Path) -> Result<(), String> { remove_path(&home.join(".claude/plugins/cache").join(SKILL_NAME))?; let registry_path = home.join(".claude/plugins/installed_plugins.json"); if registry_path.exists() { let mut registry = read_json_object(®istry_path)?; if let Some(plugins) = registry.get_mut("plugins").and_then(Value::as_object_mut) { plugins.remove(&format!("{SKILL_NAME}@{SKILL_NAME}")); } write_json_object(®istry_path, ®istry)?; } Ok(()) } fn install_codex(home: &Path, bundle: &SkillBundle) -> Result<(), String> { let clone_dir = home.join(".codex").join(SKILL_NAME); write_bundle_to(&clone_dir, bundle)?; let skills_dir = home.join(".agents/skills"); fs::create_dir_all(&skills_dir).map_err(|e| format!("create {}: {e}", skills_dir.display()))?; let link_path = skills_dir.join(SKILL_NAME); let link_target = clone_dir.join("skills"); if fs::symlink_metadata(&link_path).is_err() { link_or_copy_dir(&link_target, &link_path)?; } Ok(()) } fn uninstall_codex(home: &Path) -> Result<(), String> { remove_path(&home.join(".agents/skills").join(SKILL_NAME))?; remove_path(&home.join(".codex").join(SKILL_NAME)) } fn install_opencode(home: &Path) -> Result<(), String> { let config_path = home.join(".config/opencode/opencode.json"); let mut config = read_json_object(&config_path)?; let plugin_entry = format!("{SKILL_NAME}@git+{REPO_URL}"); let plugins = array_entry(&mut config, "plugin"); if !plugins .iter() .any(|value| value.as_str().is_some_and(|p| p.contains(SKILL_NAME))) { plugins.push(Value::String(plugin_entry)); } write_json_object(&config_path, &config) } fn uninstall_opencode(home: &Path) -> Result<(), String> { let config_path = home.join(".config/opencode/opencode.json"); if !config_path.exists() { return Ok(()); } let mut config = read_json_object(&config_path)?; let plugins = array_entry(&mut config, "plugin"); plugins.retain(|value| !value.as_str().is_some_and(|p| p.contains(SKILL_NAME))); write_json_object(&config_path, &config) } fn load_bundle() -> Result { let value: Value = serde_json::from_str(BUNDLE_JSON).map_err(|e| format!("parse skill bundle: {e}"))?; let version = value .get("version") .and_then(Value::as_str) .ok_or("skill bundle missing version")? .to_string(); let files_obj = value .get("files") .and_then(Value::as_object) .ok_or("skill bundle missing files")?; if files_obj.is_empty() { return Err("embedded skill bundle is empty".into()); } let mut files = Vec::new(); for (path, content) in files_obj { let content = content .as_str() .ok_or_else(|| format!("bundle file {path:?} is not a string"))?; files.push((path.clone(), content.to_string())); } Ok(SkillBundle { version, files }) } fn write_bundle_to(dest: &Path, bundle: &SkillBundle) -> Result<(), String> { fs::create_dir_all(dest).map_err(|e| format!("create {}: {e}", dest.display()))?; for (relative, content) in &bundle.files { let path = dest.join(relative); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; } fs::write(&path, content).map_err(|e| format!("write {}: {e}", path.display()))?; } Ok(()) } fn link_or_copy_dir(target: &Path, link_path: &Path) -> Result<(), String> { #[cfg(unix)] { std::os::unix::fs::symlink(target, link_path) .or_else(|_| copy_dir_recursive(target, link_path)) .map_err(|e| format!("link {} -> {}: {e}", link_path.display(), target.display())) } #[cfg(windows)] { std::os::windows::fs::symlink_dir(target, link_path) .or_else(|_| copy_dir_recursive(target, link_path)) .map_err(|e| format!("link {} -> {}: {e}", link_path.display(), target.display())) } } fn copy_dir_recursive(src: &Path, dest: &Path) -> std::io::Result<()> { fs::create_dir_all(dest)?; for entry in fs::read_dir(src)? { let entry = entry?; let src_path = entry.path(); let dest_path = dest.join(entry.file_name()); if src_path.is_dir() { copy_dir_recursive(&src_path, &dest_path)?; } else { fs::copy(&src_path, &dest_path)?; } } Ok(()) } fn remove_path(path: &Path) -> Result<(), String> { let Ok(metadata) = fs::symlink_metadata(path) else { return Ok(()); }; if metadata.file_type().is_symlink() { remove_symlink_path(path) } else if metadata.is_file() { fs::remove_file(path).map_err(|e| format!("remove {}: {e}", path.display())) } else { fs::remove_dir_all(path).map_err(|e| format!("remove {}: {e}", path.display())) } } #[cfg(windows)] fn remove_symlink_path(path: &Path) -> Result<(), String> { if path.is_dir() { fs::remove_dir(path).map_err(|e| format!("remove {}: {e}", path.display())) } else { fs::remove_file(path).map_err(|e| format!("remove {}: {e}", path.display())) } } #[cfg(not(windows))] fn remove_symlink_path(path: &Path) -> Result<(), String> { fs::remove_file(path).map_err(|e| format!("remove {}: {e}", path.display())) } fn read_json_object(path: &Path) -> Result, String> { let text = match fs::read_to_string(path) { Ok(text) => text, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Map::new()), Err(e) => return Err(format!("read {}: {e}", path.display())), }; if text.trim().is_empty() { return Ok(Map::new()); } let value: Value = serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))?; value .as_object() .cloned() .ok_or_else(|| format!("{} must contain a JSON object", path.display())) } fn write_json_object(path: &Path, root: &Map) -> Result<(), String> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; } let text = serde_json::to_string_pretty(root) .map_err(|e| format!("serialize {}: {e}", path.display()))?; fs::write(path, format!("{text}\n")).map_err(|e| format!("write {}: {e}", path.display())) } fn object_entry<'a>( root: &'a mut Map, key: &str, ) -> Result<&'a mut Map, String> { let entry = root .entry(key.to_string()) .or_insert_with(|| Value::Object(Map::new())); if !entry.is_object() { *entry = Value::Object(Map::new()); } entry .as_object_mut() .ok_or_else(|| format!("{key} is not an object")) } fn array_entry<'a>(root: &'a mut Map, key: &str) -> &'a mut Vec { let entry = root .entry(key.to_string()) .or_insert_with(|| Value::Array(Vec::new())); if !entry.is_array() { *entry = Value::Array(Vec::new()); } entry.as_array_mut().expect("array value") } fn home_dir() -> Result { env::var_os("HOME") .or_else(|| env::var_os("USERPROFILE")) .map(PathBuf::from) .ok_or_else(|| "home directory not available".to_string()) } fn timestamp_string() -> String { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs().to_string()) .unwrap_or_else(|_| "0".to_string()) }