From 0362ba3993c62988f5b81abbf789dc1e38b7b006 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Tue, 2 Jun 2026 09:41:50 +0800 Subject: [PATCH] fix(cli): add skill install command parity --- crates/op-cli/src/main.rs | 19 +- crates/op-cli/src/skill_install_cli.rs | 406 +++++++++++++++++++++++++ crates/op-cli/src/tests.rs | 79 +++++ crates/op-cli/src/usage.txt | 2 + 4 files changed, 502 insertions(+), 4 deletions(-) create mode 100644 crates/op-cli/src/skill_install_cli.rs diff --git a/crates/op-cli/src/main.rs b/crates/op-cli/src/main.rs index 905d7413e..c17a0b7f7 100644 --- a/crates/op-cli/src/main.rs +++ b/crates/op-cli/src/main.rs @@ -15,6 +15,7 @@ mod app_control_cli; mod codegen_cli; mod figma_cli; mod mcp_http_cli; +mod skill_install_cli; use mcp_http_cli::{ args_to_json, json_escape, post, pretty_json, status_json, tool_call_body, tools_list_body, @@ -59,6 +60,8 @@ fn run(args: &[String]) -> Result { app_control_cli::run_start(port, document_path.as_deref())? } Command::StopMcp => app_control_cli::run_stop()?, + Command::InstallSkill { target } => skill_install_cli::run_install(target.as_deref())?, + Command::UninstallSkill { target } => skill_install_cli::run_uninstall(target.as_deref())?, Command::ToolsList => post(port, &tools_list_body())?, Command::ImportFigma { fig_path, out_path } => { figma_cli::run_import_figma(&fig_path, &out_path)? @@ -89,6 +92,12 @@ enum Command { document_path: Option, }, StopMcp, + InstallSkill { + target: Option, + }, + UninstallSkill { + target: Option, + }, ToolsList, ImportFigma { fig_path: String, @@ -204,6 +213,12 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result Ok(Command::StopMcp), + "install" => Ok(Command::InstallSkill { + target: flag_value(flags, "target"), + }), + "uninstall" => Ok(Command::UninstallSkill { + target: flag_value(flags, "target"), + }), "open" => { let args = flag_value(flags, "file") .or_else(|| positionals.get(1).cloned()) @@ -246,10 +261,6 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result { codegen_cli::map_codegen(positionals, flags) } - "install" | "uninstall" => Err(format!( - "TS command {:?} is not implemented by the Rust HTTP MCP CLI yet", - positionals[0] - )), tool => generic_tool_call(tool, &positionals[1..], flags), } } diff --git a/crates/op-cli/src/skill_install_cli.rs b/crates/op-cli/src/skill_install_cli.rs new file mode 100644 index 000000000..042626935 --- /dev/null +++ b/crates/op-cli/src/skill_install_cli.rs @@ -0,0 +1,406 @@ +use serde_json::{json, Map, Value}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +const BUNDLE_JSON: &str = include_str!("../../../apps/cli/src/commands/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() || 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())) + } +} + +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()) +} diff --git a/crates/op-cli/src/tests.rs b/crates/op-cli/src/tests.rs index 96466d0fe..daaea6f45 100644 --- a/crates/op-cli/src/tests.rs +++ b/crates/op-cli/src/tests.rs @@ -74,6 +74,35 @@ fn parse_args_maps_stop_to_rust_mcp_stop() { assert_eq!(p.command, Command::StopMcp); } +#[test] +fn parse_args_maps_install_and_uninstall_targets() { + let install = parse_args(&[ + "install".to_string(), + "--target".to_string(), + "codex".to_string(), + ]) + .expect("parse install"); + assert_eq!( + install.command, + Command::InstallSkill { + target: Some("codex".to_string()), + } + ); + + let uninstall = parse_args(&[ + "uninstall".to_string(), + "--target".to_string(), + "opencode".to_string(), + ]) + .expect("parse uninstall"); + assert_eq!( + uninstall.command, + Command::UninstallSkill { + target: Some("opencode".to_string()), + } + ); +} + #[test] fn start_document_file_is_minimal_op_when_missing() { let dir = std::env::temp_dir().join(format!("op-cli-start-doc-{}", std::process::id())); @@ -89,6 +118,56 @@ fn start_document_file_is_minimal_op_when_missing() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn install_codex_writes_bundle_and_uninstall_removes_it() { + let home = std::env::temp_dir().join(format!("op-cli-skill-codex-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).expect("temp home"); + + skill_install_cli::install_target_at_home("codex", &home).expect("install codex"); + + assert!(home + .join(".codex/openpencil-skill/skills/openpencil-design/SKILL.md") + .is_file()); + assert!(home.join(".agents/skills/openpencil-skill").exists()); + + skill_install_cli::uninstall_target_at_home("codex", &home).expect("uninstall codex"); + + assert!(!home.join(".codex/openpencil-skill").exists()); + assert!(!home.join(".agents/skills/openpencil-skill").exists()); + let _ = std::fs::remove_dir_all(&home); +} + +#[test] +fn install_opencode_updates_plugin_array_and_preserves_other_plugins() { + let home = std::env::temp_dir().join(format!("op-cli-skill-opencode-{}", std::process::id())); + let config = home.join(".config/opencode/opencode.json"); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(config.parent().unwrap()).expect("config dir"); + std::fs::write(&config, r#"{"plugin":["other@file"]}"#).expect("seed config"); + + skill_install_cli::install_target_at_home("opencode", &home).expect("install opencode"); + skill_install_cli::install_target_at_home("opencode", &home).expect("install is idempotent"); + + let installed: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&config).expect("read config")) + .expect("json config"); + let plugins = installed["plugin"].as_array().expect("plugin array"); + assert_eq!(plugins.len(), 2); + assert!(plugins.iter().any(|p| p == "other@file")); + assert!(plugins + .iter() + .any(|p| p.as_str().is_some_and(|s| s.contains("openpencil-skill")))); + + skill_install_cli::uninstall_target_at_home("opencode", &home).expect("uninstall opencode"); + + let uninstalled: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&config).expect("read config")) + .expect("json config"); + assert_eq!(uninstalled["plugin"], serde_json::json!(["other@file"])); + let _ = std::fs::remove_dir_all(&home); +} + #[test] fn status_json_matches_ts_running_shape_without_requiring_server() { assert_eq!( diff --git a/crates/op-cli/src/usage.txt b/crates/op-cli/src/usage.txt index 90f5a2c1d..3b7c7cdba 100644 --- a/crates/op-cli/src/usage.txt +++ b/crates/op-cli/src/usage.txt @@ -9,6 +9,8 @@ USAGE: COMMON COMMANDS: op start [--file path.op] [--port N] start Rust MCP HTTP server op stop stop Rust MCP HTTP server + op install [--target T] install openpencil-skill for AI agents + op uninstall [--target T] uninstall openpencil-skill op status check whether MCP HTTP is reachable op get [--type T] [--name N] [--id ID] [--depth N] [--parent P] op selection get current selection