Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions crates/op-cli/src/skill_install_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ enum Target {
Codex,
Cursor,
OpenCode,
Omp,
}

impl Target {
Expand All @@ -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"
)),
}
}
Expand All @@ -43,6 +45,7 @@ impl Target {
Target::Codex => "codex",
Target::Cursor => "cursor",
Target::OpenCode => "opencode",
Target::Omp => "omp",
}
}
}
Expand Down Expand Up @@ -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(),
);
}
Expand All @@ -128,6 +131,11 @@ fn detect_targets(home: &Path) -> Vec<Target> {
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
}

Expand All @@ -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),
}
}

Expand All @@ -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),
}
}

Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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")
Expand Down
30 changes: 30 additions & 0 deletions crates/op-cli/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
Loading