mod api;
mod host;
use anyhow::{Context, Result, bail};
use serde_json::{Map, Value, json};
use std::fs;
use std::io::{IsTerminal as _, Read as _};
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use crate::{audit, config, ui};
pub(crate) use api::RuntimeHealth;
pub(crate) use host::OpenCodeHost;
use host::{OPENCODE_ENV, OpenCodeContract};
const GENERATED_MARKER: &str = "Generated by oy setup";
const OPENCODE_PLUGIN_PACKAGE: &str = "@oy-cli/opencode";
struct GeneratedAsset {
path: &'static str,
body: &'static str,
}
const TOOL_OUTPUT_MAX_BYTES: usize = 262_144;
const TOOL_OUTPUT_MAX_LINES: usize = 20_000;
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_config_path() -> Result<PathBuf> {
Ok(config_path_in(&global_opencode_dir()?))
}
pub(crate) fn workspace_config_path() -> Result<PathBuf> {
let root = config::oy_root()?;
Ok(config_path_in(&root.join(".opencode")))
}
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"))
}
#[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 dir(self) -> Result<PathBuf> {
match self {
Self::Global => global_opencode_dir(),
Self::Workspace => {
let root = config::oy_root()?;
Ok(root.join(".opencode"))
}
}
}
fn label(self) -> &'static str {
match self {
Self::Global => "global",
Self::Workspace => "workspace",
}
}
}
fn setup_opencode(scope: SetupScope, report: bool, dry_run: bool) -> Result<i32> {
let dir = scope.dir()?;
if dry_run {
return preview_setup(scope, &dir);
}
let _lock = SetupLock::acquire(&dir)?;
reject_ambiguous_dual_config(&dir)?;
let files = generated_files_to_remove(&dir)?;
let retired = retired_generated_files(&dir)?;
let config_path = config_path_in(&dir);
let config = config_body(&config_path)?;
let mut mutations = files
.iter()
.map(|path| crate::config::FileMutation::Delete { path })
.collect::<Vec<_>>();
mutations.push(crate::config::FileMutation::Write {
path: &config_path,
bytes: config.as_bytes(),
});
mutations.extend(
retired
.iter()
.map(|path| crate::config::FileMutation::Delete { path }),
);
crate::config::apply_file_batch_in(&dir, &mutations)?;
if let Ok(root) = config::oy_root() {
let host = OpenCodeHost::selected_in(&root);
if host.supported() {
let _ = api::OpenCodeApi::new(&host).evict_location(&root);
}
}
if report {
ui::success(format_args!(
"installed {} oy integration in {}",
scope.label(),
dir.display()
));
ui::line(format_args!(
"Restart opencode for {} to install and load.",
opencode_plugin_spec()
));
}
Ok(0)
}
fn remove_opencode(scope: SetupScope, dry_run: bool) -> Result<i32> {
let dir = scope.dir()?;
let _lock = if dry_run {
None
} else {
Some(SetupLock::acquire(&dir)?)
};
reject_ambiguous_dual_config(&dir)?;
let files = generated_files(&dir)
.into_iter()
.filter_map(|(path, body)| match fs::read_to_string(&path) {
Ok(current) if current == body => Some(Ok((path, body))),
Ok(current) if current.contains(GENERATED_MARKER) => Some(Err(anyhow::anyhow!(
"refusing to remove modified generated file {}",
path.display()
))),
Ok(_) => None,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => Some(Err(error.into())),
})
.collect::<Result<Vec<_>>>()?;
let retired = retired_generated_files(&dir)?;
let config_path = config_path_in(&dir);
let config = remove_owned_config(&config_path)?;
if dry_run {
ui::section(format!("{} oy integration removal dry run", scope.label()).as_str());
for (path, _) in &files {
ui::kv("delete", path.display());
}
for path in &retired {
ui::kv("delete", path.display());
}
ui::kv("update", config_path.display());
return Ok(0);
}
let mut mutations = files
.iter()
.map(|(path, _)| crate::config::FileMutation::Delete { path })
.collect::<Vec<_>>();
mutations.extend(
retired
.iter()
.map(|path| crate::config::FileMutation::Delete { path }),
);
if config_path.exists() {
mutations.push(crate::config::FileMutation::Write {
path: &config_path,
bytes: config.as_bytes(),
});
}
crate::config::apply_file_batch_in(&dir, &mutations)?;
ui::success(format_args!("removed {} oy integration", scope.label()));
Ok(0)
}
struct SetupLock {
path: PathBuf,
}
impl SetupLock {
fn acquire(dir: &Path) -> Result<Self> {
let parent = dir.parent().unwrap_or(dir);
fs::create_dir_all(parent)?;
let path = parent.join(".oy-opencode-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));
};
#[cfg(unix)]
{
let result = unsafe { libc::kill(pid as i32, 0) };
result != 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
}
#[cfg(not(unix))]
{
false
}
}
impl Drop for SetupLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
fn reject_ambiguous_dual_config(dir: &Path) -> Result<()> {
let json = dir.join("opencode.json");
let jsonc = dir.join("opencode.jsonc");
if json.exists() && jsonc.exists() && config_contains_owned_entries(&json) {
bail!(
"both {} and {} exist, and the lower-precedence JSON file contains oy entries; consolidate them before setup",
json.display(),
jsonc.display()
);
}
Ok(())
}
fn config_contains_owned_entries(path: &Path) -> bool {
fs::read_to_string(path)
.ok()
.and_then(|text| parse_opencode_config(&text).ok())
.is_some_and(|config| {
config.pointer("/mcp/servers/oy").is_some()
|| config
.get("plugins")
.and_then(Value::as_array)
.is_some_and(|plugins| plugins.iter().any(is_oy_plugin_value))
|| ["oy-audit", "oy-review", "oy-enhance"]
.iter()
.any(|name| config.pointer(&format!("/commands/{name}")).is_some())
})
}
fn remove_owned_config(path: &Path) -> Result<String> {
let mut root = if path.exists() {
parse_opencode_config(&fs::read_to_string(path)?)?
} else {
json!({})
};
let object = root
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("{} must contain a JSON object", path.display()))?;
remove_owned_commands(object)?;
remove_owned_plugins(object)?;
if let Some(mcp) = object.get_mut("mcp").and_then(Value::as_object_mut) {
if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_object_mut) {
if let Some(current) = servers.get("oy") {
if current != &generated_mcp_server() {
bail!("refusing to remove modified owned MCP server `oy`");
}
servers.remove("oy");
}
if servers.is_empty() {
mcp.remove("servers");
}
}
if mcp.is_empty() {
object.remove("mcp");
}
}
if let Some(output) = object.get_mut("tool_output").and_then(Value::as_object_mut) {
for (key, expected) in [
("max_bytes", json!(TOOL_OUTPUT_MAX_BYTES)),
("max_lines", json!(TOOL_OUTPUT_MAX_LINES)),
] {
if let Some(current) = output.get(key) {
if current != &expected {
bail!("refusing to remove modified owned tool_output.{key}");
}
output.remove(key);
}
}
if output.is_empty() {
object.remove("tool_output");
}
}
format_json(&root)
}
fn preview_setup(scope: SetupScope, dir: &Path) -> Result<i32> {
ui::section(format!("{} oy integration dry run", scope.label()).as_str());
for path in generated_files_to_remove(dir)? {
ui::kv("delete", path.display());
}
for path in retired_generated_files(dir)? {
ui::kv("delete", path.display());
}
let config_path = config_path_in(dir);
let config_body = config_body(&config_path)?;
ui::kv(
setup_action(&config_path, &config_body)?,
config_path.display(),
);
Ok(0)
}
fn generated_files(dir: &Path) -> Vec<(PathBuf, &'static str)> {
GENERATED_ASSETS
.iter()
.map(|asset| (dir.join(asset.path), asset.body))
.collect()
}
fn generated_files_to_remove(dir: &Path) -> Result<Vec<PathBuf>> {
generated_files(dir)
.into_iter()
.filter_map(|(path, body)| match fs::read_to_string(&path) {
Ok(current) if current == body => Some(Ok(path)),
Ok(current) if current.contains(GENERATED_MARKER) => Some(Err(anyhow::anyhow!(
"refusing to remove modified generated file {}",
path.display()
))),
Ok(_) => None,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => Some(Err(error.into())),
})
.collect()
}
fn retired_generated_files(dir: &Path) -> Result<Vec<PathBuf>> {
RETIRED_GENERATED_ASSETS
.iter()
.map(|relative| dir.join(relative))
.filter(|path| path.exists())
.filter_map(|path| match fs::read_to_string(&path) {
Ok(current) if current.contains(GENERATED_MARKER) => Some(Ok(path)),
Ok(_) => None,
Err(error) => Some(Err(error.into())),
})
.collect()
}
fn setup_action(path: &Path, desired: &str) -> Result<&'static str> {
if !path.exists() {
return Ok("create");
}
let current =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
if current == desired {
Ok("unchanged")
} else if desired.contains(GENERATED_MARKER) && !current.contains(GENERATED_MARKER) {
Ok("blocked")
} else {
Ok("update")
}
}
fn config_path_in(dir: &Path) -> PathBuf {
let jsonc = dir.join("opencode.jsonc");
if jsonc.exists() {
jsonc
} else {
dir.join("opencode.json")
}
}
fn validate_opencode_integration() -> Result<()> {
let global = SetupScope::Global.dir()?;
let workspace = SetupScope::Workspace.dir()?;
if [global, workspace]
.iter()
.any(|dir| integration_complete(dir))
{
return Ok(());
}
bail!(
"oy integration is missing or incomplete; run `oy setup` (or `oy setup --workspace`) first"
)
}
fn integration_complete(dir: &Path) -> bool {
config_has_all_oy_entries(&config_path_in(dir))
}
pub(crate) fn open_command(args: Vec<String>, dry_run: bool) -> Result<i32> {
let root = config::oy_root()?;
let host = if dry_run {
OpenCodeHost::selected_for_preview()
} else {
OpenCodeHost::selected_in(&root)
};
let opencode_args = open_args(args, host.contract())?;
if dry_run {
explain_opencode_launch(&host, &opencode_args);
return Ok(0);
}
require_supported_host(&host)?;
validate_opencode_integration()?;
run_opencode(&host, &root, opencode_args, None)
}
fn open_args(args: Vec<String>, contract: OpenCodeContract) -> Result<Vec<String>> {
if !contract.uses_v2_cli() {
bail!(
"OpenCode 2 is required; install the pinned beta or set {OPENCODE_ENV} to a supported v2 executable"
);
}
Ok(args)
}
pub(crate) fn run_task_command(
task: Vec<String>,
continue_session: bool,
resume: String,
auto: bool,
) -> Result<i32> {
let root = config::oy_root()?;
let host = OpenCodeHost::selected_in(&root);
require_supported_host(&host)?;
validate_opencode_integration()?;
api::OpenCodeApi::new(&host).ensure_agent(&root, "oy")?;
let prompt = collect_prompt(task)?;
if prompt.trim().is_empty() {
return chat_command_with_host(&host, continue_session, resume);
}
let mut args = vec!["run".to_string()];
push_session_args(&mut args, continue_session, &resume);
push_run_agent_args(&mut args, "oy", auto);
if let Some(model) = selected_model() {
args.extend(["--model".to_string(), model]);
}
if ui::is_json() {
args.extend(["--format".to_string(), "json".to_string()]);
}
args.push(prompt);
run_opencode(&host, &root, args, None)
}
pub(crate) fn chat_command(continue_session: bool, resume: String) -> Result<i32> {
let root = config::oy_root()?;
let host = OpenCodeHost::selected_in(&root);
require_supported_host(&host)?;
chat_command_with_host(&host, continue_session, resume)
}
fn chat_command_with_host(
host: &OpenCodeHost,
continue_session: bool,
resume: String,
) -> Result<i32> {
validate_opencode_integration()?;
let mut args = Vec::new();
push_session_args(&mut args, continue_session, &resume);
let root = config::oy_root()?;
run_opencode(host, &root, args, None)
}
pub(crate) fn models_command(model: Option<String>) -> Result<i32> {
let root = config::oy_root()?;
let host = OpenCodeHost::selected_in(&root);
require_supported_host(&host)?;
validate_opencode_integration()?;
let models = api::OpenCodeApi::new(&host).models(&root)?;
let filter = model.unwrap_or_default().to_ascii_lowercase();
let models = models
.into_iter()
.filter(|entry| {
filter.is_empty()
|| entry.id.to_ascii_lowercase().contains(&filter)
|| entry.provider_id.to_ascii_lowercase().contains(&filter)
|| entry.name.to_ascii_lowercase().contains(&filter)
})
.collect::<Vec<_>>();
if ui::is_json() {
ui::line(serde_json::to_string_pretty(
&models.iter().map(|entry| &entry.raw).collect::<Vec<_>>(),
)?);
} else {
for entry in models {
ui::line(format_args!(
"{}/{}\t{}",
entry.provider_id, entry.id, entry.name
));
}
}
Ok(0)
}
pub(crate) fn audit_workflow_command(
focus: Vec<String>,
out: PathBuf,
max_chunks: usize,
format: audit::AuditOutputFormat,
) -> Result<i32> {
if max_chunks == 0 {
bail!("max_chunks must be greater than zero");
}
let root = config::oy_root()?;
let host = OpenCodeHost::selected_in(&root);
require_supported_host(&host)?;
validate_opencode_integration()?;
config::resolve_workspace_output_path(&root, &out)?;
let api = api::OpenCodeApi::new(&host);
api.ensure_workflow(&root, "oy-audit")?;
let model = match selected_model() {
Some(model) => Some(model),
None => Some(api.default_model(&root)?),
};
let (scope, focus) = crate::workflow::resolve_scope(&root, &focus)?;
let context = crate::workflow::WorkflowContext {
schema_version: 1,
run_id: crate::workflow::new_run_id()?,
kind: crate::workflow::WorkflowKind::Audit,
workspace: root.clone(),
scope,
focus,
output: out.clone(),
format: format.name().to_string(),
max_chunks,
model,
session_id: None,
legacy_mode: None,
output_before: crate::workflow::output_digest(&root, &out)?,
};
let message = format!(
"Load the `oy-audit` skill and execute it locally. Bound workflow request: {}",
context.encode()?
);
run_agent_workflow(&host, &root, "oy", message, &context)
}
pub(crate) fn review_workflow_command(
target: Option<String>,
focus: Vec<String>,
out: PathBuf,
max_chunks: usize,
) -> Result<i32> {
if max_chunks == 0 {
bail!("max_chunks must be greater than zero");
}
let root = config::oy_root()?;
let host = OpenCodeHost::selected_in(&root);
require_supported_host(&host)?;
validate_opencode_integration()?;
config::resolve_workspace_output_path(&root, &out)?;
let api = api::OpenCodeApi::new(&host);
api.ensure_workflow(&root, "oy-review")?;
let model = match selected_model() {
Some(model) => Some(model),
None => Some(api.default_model(&root)?),
};
let (scope, focus) = if let Some(target) = target.filter(|target| !target.trim().is_empty()) {
(crate::workflow::resolve_diff_scope(&root, &target)?, focus)
} else {
crate::workflow::resolve_scope(&root, &focus)?
};
let context = crate::workflow::WorkflowContext {
schema_version: 1,
run_id: crate::workflow::new_run_id()?,
kind: crate::workflow::WorkflowKind::Review,
workspace: root.clone(),
scope,
focus,
output: out.clone(),
format: "markdown".to_string(),
max_chunks,
model,
session_id: None,
legacy_mode: None,
output_before: crate::workflow::output_digest(&root, &out)?,
};
let message = format!(
"Load the `oy-review` skill and execute it locally. Bound workflow request: {}",
context.encode()?
);
run_agent_workflow(&host, &root, "oy", message, &context)
}
pub(crate) fn enhance_workflow_command(
review_target: Option<String>,
focus: Vec<String>,
audit_max_chunks: usize,
review_max_chunks: usize,
interactive: bool,
) -> Result<i32> {
if audit_max_chunks == 0 || review_max_chunks == 0 {
bail!("workflow chunk limits must be greater than zero");
}
let root = config::oy_root()?;
let host = OpenCodeHost::selected_in(&root);
require_supported_host(&host)?;
validate_opencode_integration()?;
let scope = if let Some(target) = review_target.filter(|target| !target.trim().is_empty()) {
crate::workflow::resolve_diff_scope(&root, &target)?
} else {
crate::workflow::WorkflowScope::Workspace {
path: ".".to_string(),
}
};
let context = crate::workflow::WorkflowContext {
schema_version: 1,
run_id: crate::workflow::new_run_id()?,
kind: crate::workflow::WorkflowKind::Enhance,
workspace: root.clone(),
scope,
focus,
output: PathBuf::from("REVIEW.md"),
format: "markdown".to_string(),
max_chunks: audit_max_chunks.max(review_max_chunks),
model: selected_model(),
session_id: None,
legacy_mode: None,
output_before: None,
};
let message = format!(
"Load the `oy-enhance` skill and execute it locally. Bound workflow request: {}",
context.encode()?
);
let agent = "oy";
let api = api::OpenCodeApi::new(&host);
api.ensure_workflow(&root, "oy-enhance")?;
let model = match selected_model() {
Some(model) => Some(model),
None => Some(api.default_model(&root)?),
};
let mut context = context;
context.model = model;
if interactive {
let session = api.create_session(&root, agent, context.model.as_deref())?;
context.session_id = Some(session.clone());
let mut args = vec![
"mini".to_string(),
"--session".to_string(),
session,
"--agent".to_string(),
agent.to_string(),
];
if let Some(model) = context.model.clone() {
args.extend(["--model".to_string(), model]);
}
args.extend(["--prompt".to_string(), message]);
return run_opencode(&host, &root, args, Some(&context));
}
run_agent_workflow(&host, &root, agent, message, &context)
}
pub(crate) fn recover_workflow_command() -> Result<i32> {
let root = config::oy_root()?;
let retained = crate::workflow::retained(&root)?
.ok_or_else(|| anyhow::anyhow!("no incomplete oy workflow exists for this workspace"))?;
let host = OpenCodeHost::selected_in(&root);
require_supported_host(&host)?;
validate_opencode_integration()?;
let session = api::OpenCodeApi::new(&host)
.find_session(&root, &format!("oy:{}", retained.run_id))?
.or(retained.session_id.clone())
.ok_or_else(|| anyhow::anyhow!("retained workflow session was not found"))?;
let mut context = retained;
context.session_id = Some(session);
let agent = "oy";
let message = format!(
"Resume the bound oy workflow from its retained context. Call `oy_workflow_status` first. Bound workflow request: {}",
context.encode()?
);
run_agent_workflow(&host, &root, agent, message, &context)
}
fn require_supported_host(host: &OpenCodeHost) -> Result<()> {
host.require_supported().map_err(anyhow::Error::msg)
}
pub(crate) fn runtime_health(host: &OpenCodeHost, root: &Path) -> Result<RuntimeHealth> {
api::OpenCodeApi::new(host).runtime_health(root)
}
fn run_agent_workflow(
host: &OpenCodeHost,
root: &Path,
agent: &str,
message: String,
context: &crate::workflow::WorkflowContext,
) -> Result<i32> {
let api = api::OpenCodeApi::new(host);
let mut bound = context.clone();
let session = match &bound.session_id {
Some(session) => session.clone(),
None => api.create_session(root, agent, bound.model.as_deref())?,
};
bound.session_id = Some(session.clone());
api.rename_session(root, &session, &format!("oy:{}", bound.run_id))?;
let lease = crate::workflow::WorkflowLease::acquire(&bound)?;
let prefix = message
.split_once("Bound workflow request:")
.map_or(message.as_str(), |(prefix, _)| prefix);
let prompt = format!("{prefix}Bound workflow request: {}", bound.encode()?);
let result = match api.run_prompt(root, &session, &prompt) {
Ok(result) => result,
Err(error) => {
ui::err_line(format_args!(
"workflow {} interrupted; session {} and recovery context retained at {}",
bound.run_id,
session,
lease.path().display()
));
return Err(error);
}
};
let output_ok = bound.kind == crate::workflow::WorkflowKind::Enhance
|| crate::workflow::output_digest(root, &bound.output)? != bound.output_before;
if !output_ok {
ui::err_line(format_args!(
"workflow {} ended without writing {}; recovery context retained at {}",
bound.run_id,
bound.output.display(),
lease.path().display()
));
return Ok(1);
}
if ui::is_json() {
ui::line(serde_json::to_string_pretty(&json!({
"run_id": bound.run_id,
"session_id": result.session_id,
"admitted": result.admitted,
"assistant": result.assistant,
"text": result.text,
}))?);
} else if !result.text.trim().is_empty() {
ui::line(result.text);
}
lease.complete();
Ok(0)
}
fn selected_model() -> Option<String> {
std::env::var("OY_OPENCODE_MODEL")
.ok()
.filter(|model| !model.trim().is_empty())
}
fn push_session_args(args: &mut Vec<String>, continue_session: bool, resume: &str) {
if continue_session {
args.push("--continue".to_string());
}
if !resume.trim().is_empty() {
args.extend(["--session".to_string(), resume.to_string()]);
}
}
fn push_run_agent_args(args: &mut Vec<String>, agent: &str, auto: bool) {
args.extend(["--agent".to_string(), agent.to_string()]);
if auto {
args.push("--auto".to_string());
}
}
fn run_opencode(
host: &OpenCodeHost,
root: &Path,
args: Vec<String>,
context: Option<&crate::workflow::WorkflowContext>,
) -> Result<i32> {
let lease = context
.map(crate::workflow::WorkflowLease::acquire)
.transpose()?;
let mut command = Command::new(host.executable());
command.args(args).current_dir(root);
command.env_remove(crate::workflow::WORKFLOW_CONTEXT_ENV);
let status = command.status().with_context(|| {
format!(
"failed to launch {}; install it or set {OPENCODE_ENV} to an OpenCode executable",
host.executable().display()
)
})?;
let code = status.code().unwrap_or(1);
if let Some(lease) = lease {
if status.success() {
let context = context.expect("workflow lease requires context");
let output_ok = context.kind == crate::workflow::WorkflowKind::Enhance
|| crate::workflow::output_digest(root, &context.output)? != context.output_before;
if output_ok {
lease.complete();
} else {
ui::err_line(format_args!(
"workflow {} ended without writing {}; recovery context retained at {}",
context.run_id,
context.output.display(),
lease.path().display()
));
return Ok(1);
}
} else if let Some(context) = context {
ui::err_line(format_args!(
"workflow {} interrupted; session {} and recovery context retained at {}",
context.run_id,
context.session_id.as_deref().unwrap_or("unknown"),
lease.path().display()
));
}
}
Ok(code)
}
fn explain_opencode_launch(host: &OpenCodeHost, args: &[String]) {
ui::section("opencode launch");
ui::kv("host", host.executable().display());
ui::kv("contract", host.contract().label());
ui::kv("agent", "select `oy` in the OpenCode 2 TUI");
ui::kv("command", shell_command(&host.executable_display(), args));
}
fn shell_command(program: &str, args: &[String]) -> String {
std::iter::once(program.to_string())
.chain(args.iter().cloned())
.map(|part| shell_quote(&part))
.collect::<Vec<_>>()
.join(" ")
}
fn shell_quote(value: &str) -> String {
if !value.is_empty()
&& value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/' | ':' | '='))
{
return value.to_string();
}
format!("'{}'", value.replace('\'', "'\\''"))
}
fn collect_prompt(parts: Vec<String>) -> Result<String> {
if !parts.is_empty() {
return Ok(parts.join(" "));
}
if std::io::stdin().is_terminal() {
return Ok(String::new());
}
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
Ok(input.trim().to_string())
}
fn config_body(path: &Path) -> Result<String> {
let mut root = if path.exists() {
let text = fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
parse_opencode_config(&text).with_context(|| {
format!(
"{} must be valid opencode JSON/JSONC for oy setup to update it",
path.display()
)
})?
} else {
json!({})
};
let object = root
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("{} must contain a JSON object", path.display()))?;
migrate_legacy_config(object, path)?;
remove_transitional_owned_config(object)?;
merge_plugin(object)?;
format_json(&root)
}
#[cfg(test)]
fn update_config(path: &Path) -> Result<()> {
let body = config_body(path)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, body)?;
Ok(())
}
fn migrate_legacy_config(object: &mut Map<String, Value>, path: &Path) -> Result<()> {
const UNSUPPORTED: &[&str] = &[
"permission",
"tools",
"agent",
"mode",
"snapshot",
"attachment",
"provider",
"plugin",
"reference",
"autoshare",
"enabled_providers",
"disabled_providers",
"small_model",
"logLevel",
"server",
"layout",
"experimental",
];
let unsupported = UNSUPPORTED
.iter()
.copied()
.filter(|key| object.contains_key(*key))
.collect::<Vec<_>>();
let legacy_skills = object.get("skills").is_some_and(Value::is_object);
let legacy_compaction = object
.get("compaction")
.and_then(Value::as_object)
.is_some_and(|value| {
["tail_turns", "preserve_recent_tokens", "reserved"]
.iter()
.any(|key| value.contains_key(*key))
});
if !unsupported.is_empty() || legacy_skills || legacy_compaction {
bail!(
"{} contains legacy OpenCode fields oy cannot safely migrate ({}); migrate the file to native OpenCode 2 first, then rerun `oy setup`",
path.display(),
unsupported
.into_iter()
.chain(legacy_skills.then_some("skills"))
.chain(legacy_compaction.then_some("compaction"))
.collect::<Vec<_>>()
.join(", ")
);
}
if object.contains_key("command") && object.contains_key("commands") {
bail!(
"{} mixes legacy `command` with native `commands`; migrate it to one OpenCode 2 format before rerunning setup",
path.display()
);
}
if let Some(mut commands) = object.remove("command") {
if let Some(commands) = commands.as_object_mut() {
for command in commands.values_mut().filter_map(Value::as_object_mut) {
if let Some(variant) = command
.remove("variant")
.and_then(|value| value.as_str().map(str::to_owned))
&& let Some(model) = command
.get("model")
.and_then(Value::as_str)
.map(str::to_owned)
{
command.insert("model".to_string(), json!(format!("{model}#{variant}")));
}
}
}
object.insert("commands".to_string(), commands);
}
let legacy_mcp = object
.get("mcp")
.and_then(Value::as_object)
.is_some_and(|mcp| {
!mcp.contains_key("servers") && mcp.keys().any(|key| !matches!(key.as_str(), "timeout"))
});
if legacy_mcp {
let old = object
.remove("mcp")
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
let mut servers = Map::new();
for (name, server) in old {
servers.insert(
name.clone(),
migrate_legacy_mcp_server(server).map_err(|error| {
anyhow::anyhow!(
"cannot safely migrate legacy MCP server `{name}` in {}: {error}",
path.display()
)
})?,
);
}
object.insert("mcp".to_string(), json!({ "servers": servers }));
}
Ok(())
}
fn migrate_legacy_mcp_server(mut server: Value) -> Result<Value> {
let Some(object) = server.as_object_mut() else {
bail!("server entry must be an object");
};
if let Some(enabled) = object.remove("enabled").and_then(|value| value.as_bool()) {
object.insert("disabled".to_string(), json!(!enabled));
}
if let Some(environment) = object.remove("env") {
object.insert("environment".to_string(), environment);
}
if let Some(timeout) = object.remove("timeout") {
object.insert(
"timeout".to_string(),
if timeout.is_number() {
json!({ "catalog": timeout, "execution": timeout })
} else {
timeout
},
);
}
if let Some(oauth) = object.get_mut("oauth").and_then(Value::as_object_mut) {
for (old, new) in [
("clientId", "client_id"),
("clientSecret", "client_secret"),
("callbackPort", "callback_port"),
("redirectUri", "redirect_uri"),
] {
if let Some(value) = oauth.remove(old) {
oauth.insert(new.to_string(), value);
}
}
}
if !object.contains_key("type") {
bail!("disabled-only legacy overrides have no valid native server type");
}
Ok(server)
}
fn generated_mcp_server() -> Value {
let executable = std::env::current_exe()
.ok()
.and_then(|path| path.to_str().map(str::to_owned))
.unwrap_or_else(|| "oy".to_string());
json!({
"type": "local",
"command": [executable, "mcp"],
"cwd": ".",
"environment": { "OY_ROOT": "." },
"disabled": false,
"timeout": { "catalog": 300000, "execution": 300000 }
})
}
fn remove_transitional_owned_config(object: &mut Map<String, Value>) -> Result<()> {
remove_owned_commands(object)?;
if let Some(mcp) = object.get_mut("mcp").and_then(Value::as_object_mut) {
if let Some(servers) = mcp.get_mut("servers").and_then(Value::as_object_mut) {
if servers.get("oy") == Some(&generated_mcp_server()) {
servers.remove("oy");
}
if servers.is_empty() {
mcp.remove("servers");
}
}
if mcp.is_empty() {
object.remove("mcp");
}
}
if let Some(output) = object.get_mut("tool_output").and_then(Value::as_object_mut) {
for (key, expected) in [
("max_bytes", json!(TOOL_OUTPUT_MAX_BYTES)),
("max_lines", json!(TOOL_OUTPUT_MAX_LINES)),
] {
if output.get(key) == Some(&expected) {
output.remove(key);
}
}
if output.is_empty() {
object.remove("tool_output");
}
}
Ok(())
}
fn remove_owned_commands(object: &mut Map<String, Value>) -> Result<()> {
let Some(commands) = object.get_mut("commands") else {
return Ok(());
};
let Some(commands) = commands.as_object_mut() else {
bail!("native OpenCode `commands` must be an object");
};
for (name, expected) in generated_commands() {
if let Some(current) = commands.get(name) {
if current != &expected && !is_retired_owned_command(name, current) {
bail!("refusing to remove modified owned command `{name}`");
}
commands.remove(name);
}
}
if commands.is_empty() {
object.remove("commands");
}
Ok(())
}
fn opencode_plugin_spec() -> String {
format!("{OPENCODE_PLUGIN_PACKAGE}@{}", env!("CARGO_PKG_VERSION"))
}
fn is_oy_plugin_spec(value: &str) -> bool {
value == OPENCODE_PLUGIN_PACKAGE
|| value
.strip_prefix(OPENCODE_PLUGIN_PACKAGE)
.is_some_and(|suffix| suffix.starts_with('@') && suffix.len() > 1)
}
fn is_oy_plugin_value(value: &Value) -> bool {
value.as_str().is_some_and(is_oy_plugin_spec)
|| value
.get("package")
.and_then(Value::as_str)
.is_some_and(is_oy_plugin_spec)
}
fn remove_owned_plugins(object: &mut Map<String, Value>) -> Result<()> {
let Some(plugins) = object.get_mut("plugins") else {
return Ok(());
};
let Some(plugins) = plugins.as_array_mut() else {
bail!("native OpenCode `plugins` must be an array");
};
if plugins.iter().any(|plugin| {
plugin.is_object()
&& plugin
.get("package")
.and_then(Value::as_str)
.is_some_and(is_oy_plugin_spec)
}) {
bail!(
"refusing to replace object-form {OPENCODE_PLUGIN_PACKAGE} configuration; remove its custom options first"
);
}
plugins.retain(|plugin| !plugin.as_str().is_some_and(is_oy_plugin_spec));
if plugins.is_empty() {
object.remove("plugins");
}
Ok(())
}
fn merge_plugin(object: &mut Map<String, Value>) -> Result<()> {
remove_owned_plugins(object)?;
let plugins = object
.entry("plugins")
.or_insert_with(|| Value::Array(Vec::new()));
let Some(plugins) = plugins.as_array_mut() else {
bail!("native OpenCode `plugins` must be an array");
};
plugins.push(Value::String(opencode_plugin_spec()));
Ok(())
}
fn generated_commands() -> [(&'static str, Value); 3] {
[
(
"oy-audit",
json!({
"description": "Run a deterministic-evidence security audit.",
"agent": "oy",
"template": "Load the `oy-audit` skill and execute it locally.\n\n$ARGUMENTS"
}),
),
(
"oy-review",
json!({
"description": "Run a deterministic-evidence code-quality review.",
"agent": "oy",
"template": "Load the `oy-review` skill and execute it locally.\n\n$ARGUMENTS"
}),
),
(
"oy-enhance",
json!({
"description": "Fix findings from ISSUES.md or REVIEW.md one at a time.",
"agent": "oy",
"template": "Load the `oy-enhance` skill and execute it locally.\n\n$ARGUMENTS"
}),
),
]
}
fn is_retired_owned_command(name: &str, current: &Value) -> bool {
let Some(agent) = (match name {
"oy-audit" => Some("oy-auditor"),
"oy-review" => Some("oy-reviewer"),
"oy-enhance" => Some("oy-enhancer"),
_ => None,
}) else {
return false;
};
let Some((_, mut expected)) = generated_commands()
.into_iter()
.find(|(command, _)| *command == name)
else {
return false;
};
expected["agent"] = json!(agent);
if name == "oy-audit" {
expected["description"] =
json!("Run a deterministic-input, no-generic-tools security audit.");
}
if name == "oy-review" {
expected["description"] =
json!("Run a deterministic-input, no-generic-tools code-quality review.");
}
current == &expected
}
fn config_has_all_oy_entries(path: &Path) -> bool {
let Ok(text) = fs::read_to_string(path) else {
return false;
};
let Ok(config) = parse_opencode_config(&text) else {
return false;
};
config
.get("plugins")
.and_then(Value::as_array)
.is_some_and(|plugins| {
plugins
.iter()
.any(|plugin| plugin.as_str() == Some(opencode_plugin_spec().as_str()))
})
}
fn format_json(value: &Value) -> Result<String> {
let mut text = serde_json::to_string_pretty(value)?;
text.push('\n');
Ok(text)
}
fn parse_opencode_config(text: &str) -> Result<Value> {
Ok(serde_json::from_str::<Value>(text)
.or_else(|_| serde_json::from_str::<Value>(&strip_jsonc(text)))?)
}
fn strip_jsonc(text: &str) -> String {
let mut without_comments = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
let mut in_string = false;
let mut escaped = false;
while let Some(ch) = chars.next() {
if in_string {
without_comments.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
if ch == '"' {
in_string = true;
without_comments.push(ch);
continue;
}
if ch == '/' {
match chars.peek().copied() {
Some('/') => {
chars.next();
for next in chars.by_ref() {
if next == '\n' {
without_comments.push('\n');
break;
}
}
}
Some('*') => {
chars.next();
let mut previous = '\0';
for next in chars.by_ref() {
if previous == '*' && next == '/' {
break;
}
if next == '\n' {
without_comments.push('\n');
}
previous = next;
}
}
_ => without_comments.push(ch),
}
continue;
}
without_comments.push(ch);
}
remove_trailing_commas(&without_comments)
}
fn remove_trailing_commas(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let chars = text.chars().collect::<Vec<_>>();
let mut in_string = false;
let mut escaped = false;
for (idx, ch) in chars.iter().copied().enumerate() {
if in_string {
out.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
if ch == '"' {
in_string = true;
out.push(ch);
continue;
}
if ch == ',' {
let next = chars[idx + 1..]
.iter()
.copied()
.find(|next| !next.is_whitespace());
if matches!(next, Some('}' | ']')) {
continue;
}
}
out.push(ch);
}
out
}
#[cfg(test)]
#[allow(clippy::items_after_test_module)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
key: &'static str,
previous: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: &Path) -> Self {
let previous = std::env::var(key).ok();
unsafe {
std::env::set_var(key, value);
}
Self { key, previous }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
if let Some(value) = &self.previous {
std::env::set_var(self.key, value);
} else {
std::env::remove_var(self.key);
}
}
}
}
#[test]
fn setup_defaults_to_global_opencode_config() {
let _lock = ENV_LOCK.lock().unwrap();
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _root = EnvGuard::set("OY_ROOT", workspace.path());
setup_command(false, false, false).unwrap();
let global = config_home.path().join("opencode");
assert!(global.join("opencode.json").exists());
assert!(!global.join("agents/oy.md").exists());
assert!(!global.join("agents/oy-plan.md").exists());
assert!(config_has_all_oy_entries(&global.join("opencode.json")));
assert!(!workspace.path().join(".opencode/opencode.json").exists());
}
#[test]
fn workspace_setup_is_explicit() {
let _lock = ENV_LOCK.lock().unwrap();
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _root = EnvGuard::set("OY_ROOT", workspace.path());
setup_command(true, false, false).unwrap();
assert!(workspace.path().join(".opencode/opencode.json").exists());
assert!(!workspace.path().join(".opencode/agents/oy.md").exists());
assert!(config_has_all_oy_entries(
&workspace.path().join(".opencode/opencode.json")
));
assert!(!config_home.path().join("opencode/opencode.json").exists());
}
#[test]
fn setup_dry_run_does_not_write_files() {
let _lock = ENV_LOCK.lock().unwrap();
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _root = EnvGuard::set("OY_ROOT", workspace.path());
setup_command(false, true, false).unwrap();
assert!(!config_home.path().join("opencode/opencode.json").exists());
assert!(!config_home.path().join("opencode/agents/oy.md").exists());
}
#[test]
fn setup_preserves_user_config_and_merges_oy_entries() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("opencode.json");
fs::write(
&path,
r#"{
"$schema": "https://opencode.ai/config.json",
"model": "test/model",
"command": { "keep": { "template": "keep me" } },
"mcp": { "other": { "type": "local", "command": ["other"] } }
}
"#,
)
.unwrap();
update_config(&path).unwrap();
let updated: Value = serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(updated["model"], "test/model");
assert_eq!(updated["commands"]["keep"]["template"], "keep me");
assert!(updated["commands"].get("oy-audit").is_none());
assert_eq!(updated["plugins"], json!([opencode_plugin_spec()]));
assert_eq!(updated["mcp"]["servers"]["other"]["command"][0], "other");
assert!(updated.pointer("/mcp/servers/oy").is_none());
assert!(updated.get("command").is_none());
assert!(updated.pointer("/mcp/oy").is_none());
assert!(updated.get("tool_output").is_none());
assert!(updated.get("default_agent").is_none());
}
#[test]
fn setup_is_idempotent_for_owned_config_and_generated_files() {
let _lock = ENV_LOCK.lock().unwrap();
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let dir = config_home.path().join("opencode");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("opencode.json"),
r#"{
"$schema": "https://opencode.ai/config.json",
"model": "test/model",
"command": { "keep": { "template": "keep me" } },
"mcp": { "other": { "type": "local", "command": ["other"] } },
"tool_output": { "extra_user_key": true }
}
"#,
)
.unwrap();
setup_command(false, false, false).unwrap();
let owned_paths = [dir.join("opencode.json")];
let first = owned_paths
.iter()
.map(|path| fs::read(path).unwrap())
.collect::<Vec<_>>();
setup_command(false, false, false).unwrap();
let second = owned_paths
.iter()
.map(|path| fs::read(path).unwrap())
.collect::<Vec<_>>();
assert_eq!(second, first);
let updated: Value = serde_json::from_slice(second.last().expect("config bytes")).unwrap();
assert_eq!(updated["model"], "test/model");
assert_eq!(updated["commands"]["keep"]["template"], "keep me");
assert_eq!(updated["mcp"]["servers"]["other"]["command"][0], "other");
assert_eq!(updated["tool_output"]["extra_user_key"], true);
assert_eq!(updated["plugins"], json!([opencode_plugin_spec()]));
}
#[test]
fn setup_preserves_non_generated_agent_file() {
let _lock = ENV_LOCK.lock().unwrap();
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let agent = config_home.path().join("opencode/agents/oy.md");
fs::create_dir_all(agent.parent().unwrap()).unwrap();
fs::write(&agent, "user-owned agent\n").unwrap();
setup_command(false, false, false).unwrap();
assert_eq!(fs::read_to_string(agent).unwrap(), "user-owned agent\n");
assert!(config_has_all_oy_entries(
&config_home.path().join("opencode/opencode.json")
));
}
#[test]
fn setup_removes_transitional_owned_tool_output_budget() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("opencode.json");
fs::write(
&path,
r#"{
"$schema": "https://opencode.ai/config.json",
"tool_output": { "max_bytes": 262144, "max_lines": 20000, "extra_user_key": true }
}
"#,
)
.unwrap();
update_config(&path).unwrap();
let updated: Value = serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
assert!(updated["tool_output"].get("max_bytes").is_none());
assert!(updated["tool_output"].get("max_lines").is_none());
assert_eq!(updated["tool_output"]["extra_user_key"], true);
}
#[test]
fn setup_accepts_opencode_jsonc() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("opencode.json");
fs::write(
&path,
r#"{
// opencode allows comments and trailing commas.
"$schema": "https://opencode.ai/config.json",
"model": "test/model",
"command": {
"keep": { "template": "https://example.com//not-a-comment" },
},
}
"#,
)
.unwrap();
update_config(&path).unwrap();
let updated: Value = serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(updated["model"], "test/model");
assert_eq!(
updated["commands"]["keep"]["template"],
"https://example.com//not-a-comment"
);
assert!(updated.pointer("/mcp/servers/oy").is_none());
}
#[test]
fn setup_accepts_native_v2_config_and_preserves_entries() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("opencode.json");
fs::write(
&path,
r#"{
"commands": { "keep": { "template": "keep me" } },
"mcp": { "servers": {} }
}
"#,
)
.unwrap();
update_config(&path).unwrap();
let updated: Value = serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(updated["commands"]["keep"]["template"], "keep me");
assert!(updated["commands"].get("oy-audit").is_none());
assert_eq!(updated["plugins"], json!([opencode_plugin_spec()]));
assert!(updated.pointer("/mcp/servers/oy").is_none());
}
#[test]
fn setup_refuses_legacy_fields_without_safe_native_equivalents() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("opencode.json");
fs::write(&path, r#"{ "permission": { "edit": "ask" } }"#).unwrap();
let err = update_config(&path).unwrap_err();
assert!(err.to_string().contains("permission"));
fs::write(&path, r#"{ "experimental": { "mcp_timeout": 30000 } }"#).unwrap();
let err = update_config(&path).unwrap_err();
assert!(err.to_string().contains("experimental"));
}
#[test]
fn setup_refuses_disabled_only_legacy_mcp_override() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("opencode.json");
fs::write(&path, r#"{ "mcp": { "old": { "enabled": false } } }"#).unwrap();
let err = update_config(&path).unwrap_err();
assert!(err.to_string().contains("disabled-only"));
}
#[test]
fn explicit_setup_removes_retired_generated_agents() {
let _lock = ENV_LOCK.lock().unwrap();
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _root = EnvGuard::set("OY_ROOT", workspace.path());
setup_command(true, false, false).unwrap();
let agent = workspace.path().join(".opencode/agents/oy.md");
fs::create_dir_all(agent.parent().unwrap()).unwrap();
fs::write(&agent, OY_AGENT).unwrap();
let reviewer = workspace.path().join(".opencode/agents/oy-reviewer.md");
fs::write(
&reviewer,
"<!-- Generated by oy setup -->\nold generated reviewer\n",
)
.unwrap();
setup_opencode(SetupScope::Workspace, false, false).unwrap();
assert!(!reviewer.exists());
assert!(!agent.exists());
assert!(config_has_all_oy_entries(
&workspace.path().join(".opencode/opencode.json")
));
assert!(!config_home.path().join("opencode/opencode.json").exists());
}
#[test]
fn setup_migrates_direct_assets_and_commands_to_versioned_plugin() {
let _lock = ENV_LOCK.lock().unwrap();
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let dir = config_home.path().join("opencode");
fs::create_dir_all(&dir).unwrap();
let commands = generated_commands()
.into_iter()
.map(|(name, command)| (name.to_string(), command))
.collect::<Map<_, _>>();
fs::write(
dir.join("opencode.json"),
format_json(&json!({
"model": "test/model",
"commands": commands,
"plugins": ["keep-plugin", "@oy-cli/opencode@0.13.0"]
}))
.unwrap(),
)
.unwrap();
for (path, body) in generated_files(&dir) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, body).unwrap();
}
setup_command(false, false, false).unwrap();
let updated: Value =
serde_json::from_str(&fs::read_to_string(dir.join("opencode.json")).unwrap()).unwrap();
assert_eq!(updated["model"], "test/model");
assert!(updated.get("commands").is_none());
assert_eq!(
updated["plugins"],
json!(["keep-plugin", opencode_plugin_spec()])
);
for (path, _) in generated_files(&dir) {
assert!(!path.exists());
}
}
#[test]
fn setup_refuses_to_replace_object_form_plugin_options() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("opencode.json");
fs::write(
&path,
r#"{ "plugins": [{ "package": "@oy-cli/opencode", "options": { "custom": true } }] }"#,
)
.unwrap();
let error = update_config(&path).unwrap_err();
assert!(error.to_string().contains("object-form"));
}
#[test]
fn audit_skill_is_canonical() {
assert!(OY_AUDIT_SKILL.contains("OWASP ASVS 5.0"));
assert!(OY_AUDIT_SKILL.contains("oy audit prepare"));
assert!(OY_AUDIT_SKILL.contains("oy audit finalize"));
assert!(OY_AUDIT_SKILL.contains("opencode/autoinvoke: true"));
assert!(OY_AUDIT_SKILL.contains("current OpenCode permissions"));
}
#[test]
fn review_skill_is_canonical() {
assert!(OY_REVIEW_SKILL.contains("complexity is the apex predator"));
assert!(OY_REVIEW_SKILL.contains("oy review prepare"));
assert!(OY_REVIEW_SKILL.contains("oy review finalize"));
assert!(OY_REVIEW_SKILL.contains("current OpenCode permissions"));
}
#[test]
fn generated_skills_require_deterministic_protocol() {
for skill in [OY_AUDIT_SKILL, OY_REVIEW_SKILL] {
assert!(skill.contains("Protocol"));
assert!(skill.contains("`[]`"));
assert!(skill.contains("untrusted"));
}
assert!(OY_ENHANCE_SKILL.contains("Fix one finding per pass"));
}
#[test]
fn generated_oy_agent_is_autonomous_without_permission_overrides() {
assert!(OY_AGENT.contains("mode: primary"));
assert!(!OY_AGENT.contains("permissions:"));
assert!(OY_AGENT.contains("carry the task through"));
assert!(OY_AGENT.contains("Never discard or rewrite changes you did not make"));
assert!(OY_AGENT.contains("OpenCode and the user own permissions"));
}
#[test]
fn launch_validation_does_not_create_integration_when_absent() {
let _lock = ENV_LOCK.lock().unwrap();
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _root = EnvGuard::set("OY_ROOT", workspace.path());
assert!(validate_opencode_integration().is_err());
assert!(!config_home.path().join("opencode/opencode.json").exists());
assert!(!workspace.path().join(".opencode/opencode.json").exists());
}
#[test]
fn setup_remove_round_trip_preserves_unrelated_config() {
let _lock = ENV_LOCK.lock().unwrap();
let config_home = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let _xdg = EnvGuard::set("XDG_CONFIG_HOME", config_home.path());
let _root = EnvGuard::set("OY_ROOT", workspace.path());
let dir = config_home.path().join("opencode");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("opencode.json"), r#"{ "model": "test/model" }"#).unwrap();
setup_command(false, false, false).unwrap();
setup_command(false, false, true).unwrap();
let config: Value =
serde_json::from_str(&fs::read_to_string(dir.join("opencode.json")).unwrap()).unwrap();
assert_eq!(config["model"], "test/model");
assert!(config.pointer("/mcp/servers/oy").is_none());
assert!(config.get("plugins").is_none());
assert!(!dir.join("agents/oy.md").exists());
}
#[test]
fn removal_recognizes_retired_owned_commands() {
for (name, retired_agent) in [
("oy-audit", "oy-auditor"),
("oy-review", "oy-reviewer"),
("oy-enhance", "oy-enhancer"),
] {
let (_, mut command) = generated_commands()
.into_iter()
.find(|(candidate, _)| *candidate == name)
.unwrap();
command["agent"] = json!(retired_agent);
if name == "oy-audit" {
command["description"] =
json!("Run a deterministic-input, no-generic-tools security audit.");
}
if name == "oy-review" {
command["description"] =
json!("Run a deterministic-input, no-generic-tools code-quality review.");
}
assert!(is_retired_owned_command(name, &command));
}
}
#[test]
fn v2_run_arguments_preserve_sessions_agents_and_auto() {
let mut args = vec!["run".to_string()];
push_session_args(&mut args, true, "");
push_run_agent_args(&mut args, "oy", true);
assert_eq!(args, vec!["run", "--continue", "--agent", "oy", "--auto"]);
let mut resumed = Vec::new();
push_session_args(&mut resumed, false, "ses_123");
assert_eq!(resumed, vec!["--session", "ses_123"]);
}
#[test]
fn v1_tui_launch_is_rejected() {
assert!(open_args(Vec::new(), OpenCodeContract::V1).is_err());
}
#[test]
fn v2_launch_avoids_removed_v1_flags() {
assert!(
open_args(Vec::new(), OpenCodeContract::V2Beta)
.unwrap()
.is_empty()
);
assert_eq!(
open_args(
vec!["service".to_string(), "status".to_string()],
OpenCodeContract::V2Beta
)
.unwrap(),
vec!["service", "status"]
);
}
#[test]
fn unprobed_custom_preview_is_rejected() {
assert!(open_args(Vec::new(), OpenCodeContract::Unknown).is_err());
}
}
const OY_AGENT: &str = include_str!("../packages/opencode/assets/agents/oy.md");
const OY_AUDIT_SKILL: &str = include_str!("../packages/opencode/assets/skills/oy-audit/SKILL.md");
const OY_REVIEW_SKILL: &str = include_str!("../packages/opencode/assets/skills/oy-review/SKILL.md");
const OY_ENHANCE_SKILL: &str =
include_str!("../packages/opencode/assets/skills/oy-enhance/SKILL.md");
const GENERATED_ASSETS: &[GeneratedAsset] = &[
GeneratedAsset {
path: "agents/oy.md",
body: OY_AGENT,
},
GeneratedAsset {
path: "skills/oy-audit/SKILL.md",
body: OY_AUDIT_SKILL,
},
GeneratedAsset {
path: "skills/oy-review/SKILL.md",
body: OY_REVIEW_SKILL,
},
GeneratedAsset {
path: "skills/oy-enhance/SKILL.md",
body: OY_ENHANCE_SKILL,
},
];
const RETIRED_GENERATED_ASSETS: &[&str] = &[
"agents/oy-plan.md",
"agents/oy-edit.md",
"agents/oy-auto.md",
"agents/oy-auditor.md",
"agents/oy-reviewer.md",
"agents/oy-enhancer.md",
];