From 5a079cd989fa33181bf706eaa12fb600410ecb8c Mon Sep 17 00:00:00 2001 From: bnivanov <251194612+bnivanov@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:18:43 +0100 Subject: [PATCH] feat(cli): add omp install target for skill installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `omp` (Oh My Pi agent CLI) as a supported `op install --target` value, with `oh-my-pi` alias. Detection probes both the `omp` binary on PATH and the ~/.omp config directory. Install stages the bundle under ~/.omp/openpencil-skill and links ~/.omp/agent/skills/openpencil-skill (symlink, copy fallback) — the dedicated path keeps ownership clean vs the codex target's shared ~/.agents/skills entry. The discovery entry is recreated each install so a stale file/symlink can't shadow a fresh bundle; uninstall removes both paths. Tests: parse/key unit test (incl. alias) and an integration test covering stale-entry replacement, idempotent install, SKILL.md reachability under ~/.omp/agent/skills, and full uninstall cleanup. --- crates/op-cli/src/skill_install_cli.rs | 45 ++++++++++++++++++++++++-- crates/op-cli/src/tests.rs | 30 +++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/op-cli/src/skill_install_cli.rs b/crates/op-cli/src/skill_install_cli.rs index 06859c97d..45399f309 100644 --- a/crates/op-cli/src/skill_install_cli.rs +++ b/crates/op-cli/src/skill_install_cli.rs @@ -22,6 +22,7 @@ enum Target { Codex, Cursor, OpenCode, + Omp, } impl Target { @@ -31,8 +32,9 @@ impl Target { "codex" => Ok(Target::Codex), "cursor" => Ok(Target::Cursor), "opencode" | "open-code" => Ok(Target::OpenCode), + "omp" | "oh-my-pi" => Ok(Target::Omp), _ => Err(format!( - "unknown target {raw:?}; available: claude, codex, cursor, opencode" + "unknown target {raw:?}; available: claude, codex, cursor, opencode, omp" )), } } @@ -43,6 +45,7 @@ impl Target { Target::Codex => "codex", Target::Cursor => "cursor", Target::OpenCode => "opencode", + Target::Omp => "omp", } } } @@ -107,7 +110,7 @@ fn resolve_targets( 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|opencode" + "no supported AI coding agents detected; pass --target claude|codex|cursor|opencode|omp" .into(), ); } @@ -128,6 +131,11 @@ fn detect_targets(home: &Path) -> Vec { if command_exists("opencode") { targets.push(Target::OpenCode); } + // omp reads user skills from ~/.omp/agent/skills; probe both the CLI and + // the config dir — either alone is a valid install. + if command_exists("omp") || home.join(".omp").exists() { + targets.push(Target::Omp); + } targets } @@ -147,6 +155,7 @@ fn install_target(target: Target, home: &Path, bundle: &SkillBundle) -> Result<( Target::Codex => install_codex(home, bundle), Target::Cursor => write_bundle_to(&home.join(".cursor/plugins").join(SKILL_NAME), bundle), Target::OpenCode => install_opencode(home, bundle), + Target::Omp => install_omp(home, bundle), } } @@ -156,6 +165,7 @@ fn uninstall_target(target: Target, home: &Path) -> Result<(), String> { Target::Codex => uninstall_codex(home), Target::Cursor => remove_path(&home.join(".cursor/plugins").join(SKILL_NAME)), Target::OpenCode => uninstall_opencode(home), + Target::Omp => uninstall_omp(home), } } @@ -259,6 +269,31 @@ fn uninstall_opencode(home: &Path) -> Result<(), String> { prune_opencode_plugin_entry(home) } +fn install_omp(home: &Path, bundle: &SkillBundle) -> Result<(), String> { + // omp discovers user skills from ~/.omp/agent/skills. ~/.agents/skills is + // also scanned but is shared with the codex target, which would make + // install/uninstall ownership ambiguous — stage the bundle under ~/.omp + // and link the dedicated discovery path instead. + let bundle_dir = home.join(".omp").join(SKILL_NAME); + write_bundle_to(&bundle_dir, bundle)?; + + let skills_dir = home.join(".omp/agent/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 = bundle_dir.join("skills"); + // The discovery entry is owned by this installer: recreate it on every + // install so a stale symlink, plain file, or outdated copied directory + // can't shadow the freshly written bundle. + remove_path(&link_path)?; + link_or_copy_dir(&link_target, &link_path)?; + Ok(()) +} + +fn uninstall_omp(home: &Path) -> Result<(), String> { + remove_path(&home.join(".omp/agent/skills").join(SKILL_NAME))?; + remove_path(&home.join(".omp").join(SKILL_NAME)) +} + /// Remove the legacy `openpencil-skill@git+…` plugin entry (older installers /// wrote it; opencode installs the package but never loads anything from it). fn prune_opencode_plugin_entry(home: &Path) -> Result<(), String> { @@ -541,6 +576,12 @@ mod tests { assert!(error.contains(&found), "unexpected error: {error}"); } + #[test] + fn omp_is_an_install_target() { + assert_eq!(super::Target::parse("omp").map(|t| t.key()), Ok("omp")); + assert_eq!(super::Target::parse("oh-my-pi").map(|t| t.key()), Ok("omp")); + } + #[test] fn gemini_cli_is_not_an_install_target() { let error = super::Target::parse("gemini-cli") diff --git a/crates/op-cli/src/tests.rs b/crates/op-cli/src/tests.rs index cbaab3f5a..59b70078d 100644 --- a/crates/op-cli/src/tests.rs +++ b/crates/op-cli/src/tests.rs @@ -180,6 +180,36 @@ fn install_opencode_writes_scanned_skills_dir_and_prunes_legacy_plugin_entry() { let _ = std::fs::remove_dir_all(&home); } +#[test] +fn install_omp_writes_agent_skills_dir_and_uninstall_removes_it() { + let home = std::env::temp_dir().join(format!("op-cli-skill-omp-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).expect("temp home"); + + // A stale plain file squatting on the discovery entry must be replaced, + // not silently kept (same shadowing failure mode as opencode). + let link = home.join(".omp/agent/skills/openpencil-skill"); + std::fs::create_dir_all(link.parent().unwrap()).expect("skills dir"); + std::fs::write(&link, "stale").expect("seed stale entry"); + + skill_install_cli::install_target_at_home("omp", &home).expect("install omp"); + skill_install_cli::install_target_at_home("omp", &home).expect("install is idempotent"); + + // The skill lands where omp scans user skills: ~/.omp/agent/skills/**/SKILL.md. + assert!( + link.join("openpencil-design/SKILL.md").exists(), + "SKILL.md must be reachable under ~/.omp/agent/skills" + ); + assert!(home + .join(".omp/openpencil-skill/skills/openpencil-design/SKILL.md") + .exists()); + + skill_install_cli::uninstall_target_at_home("omp", &home).expect("uninstall omp"); + assert!(std::fs::symlink_metadata(&link).is_err()); + assert!(!home.join(".omp/openpencil-skill").exists()); + let _ = std::fs::remove_dir_all(&home); +} + #[test] fn status_json_matches_ts_running_shape_without_requiring_server() { assert_eq!(