mod backup;
mod legacy_config;
use super::opencode_host::OpenCodeHost;
use super::{OY_AUDIT_SKILL, OY_ENHANCE_SKILL, OY_PERSONA, OY_REVIEW_SKILL, OY_SETUP_SKILL};
use anyhow::{Context, Result, bail};
use backup::{create_backup_dir, move_path, restore_moved_paths};
use legacy_config::strip_owned_config;
use serde_json::json;
use std::fs;
use std::path::{Path, PathBuf};
use crate::{config, ui};
const GENERATED_MARKER: &str = "<!-- Generated by oy setup -->";
fn bundled_files() -> [(&'static str, &'static str); 5] {
[
("oy-audit/SKILL.md", OY_AUDIT_SKILL),
("oy-review/SKILL.md", OY_REVIEW_SKILL),
("oy-enhance/SKILL.md", OY_ENHANCE_SKILL),
("oy-setup/SKILL.md", OY_SETUP_SKILL),
("oy-setup/oy-persona.md", OY_PERSONA),
]
}
pub(crate) fn plugin_cache_paths() -> Vec<PathBuf> {
let Some(cache) = dirs::cache_dir() else {
return Vec::new();
};
let packages = cache.join("opencode").join("packages");
let oy_scope = packages.join("@oy-cli");
let fork_scope = packages.join("@stablekernel");
let mut paths = Vec::new();
for (parent, prefix) in [
(packages.as_path(), "cursor-opencode-provider"),
(oy_scope.as_path(), "opencode@"),
(fork_scope.as_path(), "opencode-cursor"),
] {
let Ok(entries) = fs::read_dir(parent) else {
continue;
};
for entry in entries.flatten() {
let name = entry.file_name();
if name.to_str().is_some_and(|name| name.starts_with(prefix)) {
paths.push(entry.path());
}
}
}
paths.sort();
paths
}
fn remove_plugin_cache(paths: &[PathBuf]) -> Vec<PathBuf> {
let mut removed = Vec::new();
for path in paths {
let result = fs::remove_dir_all(path).or_else(|_| fs::remove_file(path));
if result.is_ok() {
removed.push(path.clone());
}
}
if let Some(cache) = dirs::cache_dir() {
let packages = cache.join("opencode").join("packages");
for scope in ["@oy-cli", "@stablekernel"] {
let _ = fs::remove_dir(packages.join(scope));
}
}
removed
}
#[derive(Debug)]
struct SetupOutcome {
skills_dir: PathBuf,
backup: Option<PathBuf>,
preserved: Vec<PathBuf>,
cache_removed: Vec<PathBuf>,
}
struct ConfigUpdate {
path: PathBuf,
body: String,
current: Option<Vec<u8>>,
}
impl ConfigUpdate {
fn new(path: PathBuf, body: String) -> Result<Self> {
let current = match fs::read(&path) {
Ok(bytes) => Some(bytes),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => {
return Err(error).with_context(|| format!("failed reading {}", path.display()));
}
};
Ok(Self {
path,
body,
current,
})
}
fn changed(&self) -> bool {
self.current.as_deref() != Some(self.body.as_bytes())
}
}
pub(crate) fn setup_command(workspace: bool, dry_run: bool, remove: bool) -> Result<i32> {
let scope = SetupScope::from_workspace_flag(workspace);
if remove {
remove_opencode(scope, dry_run)
} else {
setup_opencode(scope, true, dry_run)
}
}
pub(crate) fn global_skills_dir() -> Result<PathBuf> {
if let Some(value) = std::env::var_os("OY_SKILLS_DIR") {
if value.is_empty() {
bail!("OY_SKILLS_DIR must not be empty");
}
let path = PathBuf::from(value);
return Ok(if path.is_absolute() {
path
} else {
config::oy_root()?.join(path)
});
}
dirs::home_dir()
.context("failed to find the user home directory for agent skills")
.map(|home| home.join(".agents").join("skills"))
}
pub(crate) fn workspace_skills_dir() -> Result<PathBuf> {
Ok(config::oy_root()?.join(".agents").join("skills"))
}
pub(crate) fn skills_complete(dir: &Path) -> bool {
bundled_files().iter().all(|(relative, canonical)| {
fs::read_to_string(dir.join(relative)).ok().as_deref() == Some(canonical)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SetupScope {
Global,
Workspace,
}
impl SetupScope {
fn from_workspace_flag(workspace: bool) -> Self {
if workspace {
Self::Workspace
} else {
Self::Global
}
}
fn skills_dir(self) -> Result<PathBuf> {
match self {
Self::Global => global_skills_dir(),
Self::Workspace => workspace_skills_dir(),
}
}
fn opencode_dir(self) -> Result<PathBuf> {
match self {
Self::Global => global_opencode_dir(),
Self::Workspace => Ok(config::oy_root()?.join(".opencode")),
}
}
fn label(self) -> &'static str {
match self {
Self::Global => "global",
Self::Workspace => "workspace",
}
}
}
fn global_opencode_dir() -> Result<PathBuf> {
if let Some(value) = std::env::var_os("OPENCODE_CONFIG_DIR") {
if value.is_empty() {
bail!("OPENCODE_CONFIG_DIR must not be empty");
}
let path = PathBuf::from(value);
return Ok(if path.is_absolute() {
path
} else {
config::oy_root()?.join(path)
});
}
dirs::config_dir()
.context("failed to find user config directory")
.map(|dir| dir.join("opencode"))
}
fn setup_opencode(scope: SetupScope, report: bool, dry_run: bool) -> Result<i32> {
let skills_dir = scope.skills_dir()?;
let opencode_dir = scope.opencode_dir()?;
if dry_run {
return preview_setup(scope, &skills_dir, &opencode_dir);
}
let _lock = SetupLock::acquire(&skills_dir)?;
let mut old_paths = legacy_oy_paths(&opencode_dir)?;
old_paths.extend(
plugin_paths(&opencode_dir)
.into_iter()
.filter(|path| path.exists()),
);
dedupe_nested_paths(&mut old_paths);
let (skill_updates, preserved) = install_skill_updates(&skills_dir)?;
let mut updates = skill_updates;
updates.extend(strip_opencode_config_updates(&opencode_dir)?);
let changed = !old_paths.is_empty() || updates.iter().any(ConfigUpdate::changed);
let backup = apply_integration_update(&[&skills_dir, &opencode_dir], &old_paths, &updates)?;
let cache_removed = remove_plugin_cache(&plugin_cache_paths());
if changed && let Ok(root) = config::oy_root() {
let host = OpenCodeHost::selected_in(&root);
if host.supported() {
let _ = super::opencode_api::OpenCodeApi::new(&host).evict_location(&root);
}
}
if report {
report_setup(
"installed",
scope,
&SetupOutcome {
skills_dir,
backup,
preserved,
cache_removed,
},
)?;
}
Ok(0)
}
fn remove_opencode(scope: SetupScope, dry_run: bool) -> Result<i32> {
let skills_dir = scope.skills_dir()?;
let opencode_dir = scope.opencode_dir()?;
let old_paths = remove_owned_paths(&skills_dir, &opencode_dir)?;
let updates = strip_opencode_config_updates(&opencode_dir)?;
let cache_paths = plugin_cache_paths();
if dry_run {
ui::section(format!("{} oy skills removal dry run", scope.label()).as_str());
for path in &old_paths {
ui::kv("move", path.display());
}
for path in &cache_paths {
ui::kv("delete", path.display());
}
preview_config_updates(&updates);
return Ok(0);
}
let _lock = SetupLock::acquire(&skills_dir)?;
let backup = apply_integration_update(&[&skills_dir, &opencode_dir], &old_paths, &updates)?;
let cache_removed = remove_plugin_cache(&cache_paths);
for (relative, _) in bundled_files() {
let directory = skills_dir.join(relative).parent().unwrap().to_path_buf();
let _ = fs::remove_dir(directory);
}
for namespace in ["agents", "commands", "plugins", "skills"] {
let _ = fs::remove_dir(opencode_dir.join(namespace));
}
report_setup(
"removed",
scope,
&SetupOutcome {
skills_dir,
backup,
preserved: Vec::new(),
cache_removed,
},
)?;
Ok(0)
}
fn legacy_oy_paths(dir: &Path) -> Result<Vec<PathBuf>> {
let mut paths = Vec::new();
for namespace in ["agents", "commands", "skills"] {
let parent = dir.join(namespace);
let metadata = match fs::symlink_metadata(&parent) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
return Err(error).with_context(|| format!("failed reading {}", parent.display()));
}
};
if metadata.file_type().is_symlink() {
bail!(
"refusing to scan symlinked OpenCode namespace {}",
parent.display()
);
}
if !metadata.is_dir() {
bail!(
"OpenCode namespace is not a directory: {}",
parent.display()
);
}
let entries = fs::read_dir(&parent)
.with_context(|| format!("failed reading {}", parent.display()))?;
for entry in entries {
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if name == "oy" || name.starts_with("oy-") || name.starts_with("oy.") {
let path = entry.path();
if path.ends_with("agents/oy.md")
&& fs::read_to_string(&path).ok().as_deref() == Some(OY_PERSONA)
{
continue;
}
paths.push(path);
}
}
}
paths.sort();
Ok(paths)
}
fn plugin_paths(dir: &Path) -> Vec<PathBuf> {
[
"plugins/oy.js",
"plugins/assets",
"plugins/assets/agents/oy.md",
"plugins/assets/skills/oy-audit/SKILL.md",
"plugins/assets/skills/oy-review/SKILL.md",
"plugins/assets/skills/oy-enhance/SKILL.md",
]
.into_iter()
.map(|relative| dir.join(relative))
.collect()
}
fn remove_owned_paths(skills_dir: &Path, opencode_dir: &Path) -> Result<Vec<PathBuf>> {
let mut paths = legacy_oy_paths(opencode_dir)?;
paths.extend(
plugin_paths(opencode_dir)
.into_iter()
.filter(|path| path.exists()),
);
for (relative, canonical) in bundled_files() {
let path = skills_dir.join(relative);
if path.exists() && file_is_oy_owned(&path, canonical) {
paths.push(path);
}
}
dedupe_nested_paths(&mut paths);
Ok(paths)
}
fn dedupe_nested_paths(paths: &mut Vec<PathBuf>) {
paths.sort();
let mut kept: Vec<PathBuf> = Vec::new();
for path in paths.drain(..) {
if kept.iter().any(|kept| path.starts_with(kept)) {
continue;
}
kept.push(path);
}
*paths = kept;
}
fn file_is_oy_owned(path: &Path, canonical: &str) -> bool {
fs::read_to_string(path)
.is_ok_and(|content| content == canonical || content.contains(GENERATED_MARKER))
}
fn install_skill_updates(dir: &Path) -> Result<(Vec<ConfigUpdate>, Vec<PathBuf>)> {
let mut updates = Vec::new();
let mut preserved = Vec::new();
for (relative, canonical) in bundled_files() {
let path = dir.join(relative);
if path.exists() && !file_is_oy_owned(&path, canonical) {
preserved.push(path);
continue;
}
if fs::read_to_string(&path).ok().as_deref() == Some(canonical) {
continue;
}
updates.push(ConfigUpdate::new(path, canonical.to_string())?);
}
Ok((updates, preserved))
}
fn strip_opencode_config_updates(dir: &Path) -> Result<Vec<ConfigUpdate>> {
let mut updates = Vec::new();
for path in config_paths_in(dir) {
if !path.exists() {
continue;
}
let Some(body) = strip_owned_config(&path)? else {
continue;
};
updates.push(ConfigUpdate::new(path, body)?);
}
Ok(updates)
}
fn config_paths_in(dir: &Path) -> [PathBuf; 2] {
[dir.join("opencode.json"), dir.join("opencode.jsonc")]
}
fn report_setup(status: &str, scope: SetupScope, outcome: &SetupOutcome) -> Result<()> {
if ui::is_json() {
ui::line(serde_json::to_string_pretty(&json!({
"status": status,
"scope": scope.label(),
"skills": outcome.skills_dir,
"backup": outcome.backup,
"plugin_cache_removed": outcome.cache_removed,
}))?);
return Ok(());
}
ui::success(format_args!("{status} {} oy skills", scope.label()));
ui::line(format_args!(
"Skills directory: {}",
outcome.skills_dir.display()
));
for path in &outcome.preserved {
ui::line(format_args!(
"Preserved user file (remove or edit it to let oy manage this path): {}",
path.display()
));
}
for path in &outcome.cache_removed {
ui::line(format_args!(
"Removed obsolete OpenCode plugin cache: {}",
path.display()
));
}
if let Some(backup) = &outcome.backup {
ui::line(format_args!(
"Previous oy integration files were moved to {}.",
backup.display()
));
}
if status == "installed" {
ui::line(
"Ask your agent to run the oy-setup skill to finish host-specific setup and verify discovery.",
);
}
Ok(())
}
struct SetupLock {
path: PathBuf,
}
impl SetupLock {
fn acquire(skills_dir: &Path) -> Result<Self> {
let parent = skills_dir.parent().unwrap_or(skills_dir);
fs::create_dir_all(parent)?;
let path = parent.join(".oy-setup.lock");
for attempt in 0..2 {
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
use std::io::Write as _;
writeln!(file, "{}", std::process::id())?;
return Ok(Self { path });
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists && attempt == 0 => {
if stale_setup_lock(&path) {
fs::remove_file(&path)?;
continue;
}
return Err(error).with_context(|| {
format!("another oy setup/remove may be running: {}", path.display())
});
}
Err(error) => {
return Err(error).with_context(|| {
format!("failed acquiring setup lock: {}", path.display())
});
}
}
}
unreachable!("setup lock loop always returns")
}
}
fn stale_setup_lock(path: &Path) -> bool {
let pid = fs::read_to_string(path)
.ok()
.and_then(|value| value.trim().parse::<u32>().ok());
let Some(pid) = pid else {
return fs::metadata(path)
.and_then(|metadata| metadata.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age > std::time::Duration::from_secs(30));
};
let result = unsafe { libc::kill(pid as i32, 0) };
result != 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
}
impl Drop for SetupLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
fn preview_setup(scope: SetupScope, skills_dir: &Path, opencode_dir: &Path) -> Result<i32> {
ui::section(format!("{} oy skills dry run", scope.label()).as_str());
for path in legacy_oy_paths(opencode_dir)? {
ui::kv("move", path.display());
}
for path in plugin_paths(opencode_dir)
.into_iter()
.filter(|path| path.exists())
{
ui::kv("move", path.display());
}
for path in plugin_cache_paths() {
ui::kv("delete", path.display());
}
let (skill_updates, preserved) = install_skill_updates(skills_dir)?;
for path in &preserved {
ui::kv("preserve", path.display());
}
preview_config_updates(&skill_updates);
preview_config_updates(&strip_opencode_config_updates(opencode_dir)?);
Ok(0)
}
fn preview_config_updates(updates: &[ConfigUpdate]) {
for update in updates {
let action = if update.current.is_none() {
"create"
} else if update.changed() {
"backup+update"
} else {
"unchanged"
};
ui::kv(action, update.path.display());
}
}
fn relative_to(roots: &[&Path], path: &Path) -> Result<PathBuf> {
for root in roots {
if let Ok(relative) = path.strip_prefix(root) {
return Ok(relative.to_path_buf());
}
}
Ok(PathBuf::from(
path.file_name().context("path has no file name")?,
))
}
fn apply_integration_update(
roots: &[&Path],
old_paths: &[PathBuf],
updates: &[ConfigUpdate],
) -> Result<Option<PathBuf>> {
let changed = updates
.iter()
.filter(|update| update.changed())
.collect::<Vec<_>>();
let existing_configs = changed
.iter()
.filter(|update| update.current.is_some())
.collect::<Vec<_>>();
let backup = if old_paths.is_empty() && existing_configs.is_empty() {
None
} else {
Some(create_backup_dir()?)
};
if let Some(backup) = &backup {
let result = (|| -> Result<()> {
for update in existing_configs {
let destination = backup.join(relative_to(roots, &update.path)?);
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
fs::write(
&destination,
update.current.as_deref().expect("existing config bytes"),
)
.with_context(|| format!("failed backing up {}", update.path.display()))?;
let permissions = fs::metadata(&update.path)?.permissions();
fs::set_permissions(&destination, permissions).with_context(|| {
format!(
"failed preserving permissions for {}",
destination.display()
)
})?;
}
Ok(())
})();
if let Err(error) = result {
let _ = fs::remove_dir_all(backup);
return Err(error);
}
}
let mut moved = Vec::new();
if let Some(backup) = &backup {
for source in old_paths {
let destination = backup.join(relative_to(roots, source)?);
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
if let Err(error) = move_path(source, &destination) {
if let Err(rollback) = restore_moved_paths(&moved) {
return Err(error).context(format!(
"failed moving {} to {}; rollback also failed: {rollback:#}",
source.display(),
destination.display()
));
}
return Err(error).with_context(|| {
format!(
"failed moving {} to {}",
source.display(),
destination.display()
)
});
}
moved.push((source.clone(), destination));
}
}
let mutations = changed
.iter()
.map(|update| config::FileMutation::Write {
path: update.path.as_path(),
bytes: update.body.as_bytes(),
})
.collect::<Vec<_>>();
if let Err(error) = config::apply_file_batch_in_roots(roots, &mutations) {
if let Err(rollback) = restore_moved_paths(&moved) {
return Err(error).context(format!(
"setup rollback failed; backup retained at {}: {rollback:#}",
backup
.as_ref()
.map_or_else(|| Path::new("<none>"), PathBuf::as_path)
.display()
));
}
if let Some(backup) = &backup {
return Err(error).context(format!(
"config update failed; backup retained at {}",
backup.display()
));
}
return Err(error);
}
Ok(backup)
}
#[cfg(test)]
mod tests;