fix(cli): add skill install command parity
This commit is contained in:
parent
40d3371448
commit
0362ba3993
|
|
@ -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<String, String> {
|
|||
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<String>,
|
||||
},
|
||||
StopMcp,
|
||||
InstallSkill {
|
||||
target: Option<String>,
|
||||
},
|
||||
UninstallSkill {
|
||||
target: Option<String>,
|
||||
},
|
||||
ToolsList,
|
||||
ImportFigma {
|
||||
fig_path: String,
|
||||
|
|
@ -204,6 +213,12 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result<Com
|
|||
document_path: flag_value(flags, "file"),
|
||||
}),
|
||||
"stop" => 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<Com
|
|||
"codegen:plan" | "codegen:submit" | "codegen:assemble" | "codegen:clean" => {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
406
crates/op-cli/src/skill_install_cli.rs
Normal file
406
crates/op-cli/src/skill_install_cli.rs
Normal file
|
|
@ -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<Self, String> {
|
||||
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<String, String> {
|
||||
run_for_home(Action::Install, target, &home_dir()?)
|
||||
}
|
||||
|
||||
pub(crate) fn run_uninstall(target: Option<&str>) -> Result<String, String> {
|
||||
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<String, String> {
|
||||
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<Vec<Target>, 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<Target> {
|
||||
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<SkillBundle, String> {
|
||||
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<Map<String, Value>, 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<String, Value>) -> 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<String, Value>,
|
||||
key: &str,
|
||||
) -> Result<&'a mut Map<String, Value>, 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<String, Value>, key: &str) -> &'a mut Vec<Value> {
|
||||
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<PathBuf, String> {
|
||||
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())
|
||||
}
|
||||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue