use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use crate::core::envelope::alias::{AliasTable, McpKeyRule, MultiValueRule};
use crate::core::hook_state::marker::OpenlatchMarker;
use crate::error::{ERR_OPENCODE_PLUGIN_FOREIGN, ERR_OPENCODE_PLUGIN_PENDING};
use crate::hooks::binding::{
AgentBinding, BindingCapabilities, DaemonChannel, Enforces, FailureMode, HookSurface,
LivenessReport, ModelRelayWiring,
};
use crate::hooks::hook_files::{self, Existing};
pub static ALIASES: AliasTable = AliasTable {
agent: OpencodeBinding::AGENT_TYPE,
binding: "opencode_plugin",
renders_binding: None,
tools: &[
("bash", "Bash"),
("edit", "Edit"),
("write", "Write"),
("read", "Read"),
("grep", "Grep"),
("glob", "Glob"),
("webfetch", "WebFetch"),
("websearch", "WebSearch"),
("task", "Task"),
("todowrite", "TodoWrite"),
("skill", "Skill"),
],
event_tools: &[],
keep: &[],
inputs: &[
("*", "filePath", "file_path"),
("*", "oldString", "old_string"),
("*", "newString", "new_string"),
("*", "replaceAll", "replace_all"),
],
creates: &[],
mcp_rule: McpKeyRule::NATIVE,
multi: MultiValueRule::NONE,
};
const EVENT_TYPES: &[&str] = &[
"tool.execute.before",
"chat.message",
"tool.execute.after",
"event",
];
const LOAD_BEARING: &[&str] = &["tool.execute.before", "chat.message"];
pub struct OpencodeBinding {
pub root: PathBuf,
}
impl OpencodeBinding {
pub const AGENT_TYPE: &'static str = "opencode";
pub fn detect() -> Option<Self> {
crate::hooks::opencode::detect().map(|root| Self { root })
}
#[must_use]
pub fn plugins_dir(&self) -> PathBuf {
crate::hooks::opencode::plugins_dir(&self.root)
}
#[must_use]
pub fn plugin_path(&self) -> PathBuf {
crate::hooks::opencode::plugin_path(&self.root)
}
pub(crate) fn liveness_from(existing: Existing, seen_after_write: bool) -> LivenessReport {
let report = |armed, detail: &str, remedy: Option<&str>, code, pending| LivenessReport {
armed,
detail: Some(detail.to_string()),
remedy: remedy.map(str::to_string),
code,
off: false,
pending,
settling: false,
};
match existing {
Existing::Nothing => report(
None,
"the OpenLatch plugin is not installed",
None,
None,
false,
),
Existing::Theirs => report(
Some(false),
"a file OpenLatch does not own holds the OpenCode plugin path",
Some("Move that file, then rerun `openlatch init --agent opencode`."),
Some(ERR_OPENCODE_PLUGIN_FOREIGN),
false,
),
Existing::Ours if seen_after_write => report(
Some(true),
"OpenCode loaded the OpenLatch plugin",
None,
None,
false,
),
Existing::Ours => report(
Some(false),
"the plugin is installed and OpenCode has not loaded it since",
Some("Restart OpenCode; this check turns green on the plugin's first load report."),
Some(ERR_OPENCODE_PLUGIN_PENDING),
true,
),
}
}
}
impl AgentBinding for OpencodeBinding {
fn agent_type(&self) -> &'static str {
Self::AGENT_TYPE
}
fn aliases(&self) -> &'static AliasTable {
&ALIASES
}
fn display_name(&self) -> &'static str {
"OpenCode"
}
fn config_dir(&self) -> PathBuf {
self.root.clone()
}
fn hook_config_path(&self) -> PathBuf {
self.plugin_path()
}
fn hook_surface(&self) -> HookSurface {
HookSurface::PluginFile(self.plugin_path())
}
fn hook_event_types(&self) -> &'static [&'static str] {
EVENT_TYPES
}
fn load_bearing_events(&self) -> &'static [&'static str] {
LOAD_BEARING
}
fn daemon_channel(&self) -> DaemonChannel {
DaemonChannel::OpenlatchDirArg
}
fn liveness(&self) -> LivenessReport {
let path = self.plugin_path();
let existing = hook_files::whatever_is_there(&path);
let ol_dir = crate::config::openlatch_dir();
let seen = crate::daemon::opencode_plugin_seen::read_seen(&ol_dir);
let seen_after_write = crate::daemon::opencode_plugin_seen::mtime_ms(&path)
.is_some_and(|written| crate::daemon::opencode_plugin_seen::seen_since(&seen, written));
Self::liveness_from(existing, seen_after_write)
}
fn build_hook_entry(
&self,
_event: &str,
_binary: &Path,
_port: u16,
_marker: &OpenlatchMarker,
) -> Value {
json!({})
}
fn config_is_machine_global(&self) -> bool {
crate::hooks::opencode::config_is_machine_global()
}
fn capabilities(&self) -> BindingCapabilities {
BindingCapabilities {
expressible: &["allow", "deny"],
can_mutate_arguments: false,
native_failure_mode: FailureMode::FailOpen,
admin_owned_settings: false,
declares_session_in_request: true,
notice: false,
observes_execution: true,
enforces: Enforces::All,
can_hold: false,
}
}
fn model_relay_wiring(&self) -> Option<ModelRelayWiring> {
None
}
fn tamper_watch_paths(&self) -> Vec<PathBuf> {
vec![self.plugin_path()]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn binding() -> OpencodeBinding {
OpencodeBinding {
root: PathBuf::from("/home/test/.config/opencode"),
}
}
#[test]
fn surface_is_the_plugin_file() {
let b = binding();
assert_eq!(
b.hook_surface(),
HookSurface::PluginFile(
PathBuf::from("/home/test/.config/opencode")
.join("plugins")
.join("openlatch.js")
)
);
assert_eq!(b.hook_config_path(), b.plugin_path());
}
#[test]
fn opencode_cannot_ask_or_hold_and_blocks_every_tool() {
let caps = binding().capabilities();
assert!(!caps.ask());
assert!(!caps.can_hold);
assert!(!caps.can_mutate_arguments);
assert!(caps.expressible.contains(&"deny"));
assert_eq!(caps.enforces, Enforces::All);
}
#[test]
fn the_binding_id_is_opencode_plugin() {
assert_eq!(binding().binding_id(false), "opencode_plugin");
assert_eq!(
crate::hooks::bindings::binding_id("opencode", false),
"opencode_plugin"
);
}
#[test]
fn liveness_is_pending_until_a_load_report_follows_the_write() {
let pending = OpencodeBinding::liveness_from(Existing::Ours, false);
assert_eq!(pending.armed, Some(false));
assert!(pending.pending);
assert_eq!(pending.code, Some(ERR_OPENCODE_PLUGIN_PENDING));
assert!(pending.remedy.is_some());
let seen = OpencodeBinding::liveness_from(Existing::Ours, true);
assert_eq!(seen.armed, Some(true));
let foreign = OpencodeBinding::liveness_from(Existing::Theirs, true);
assert_eq!(foreign.code, Some(ERR_OPENCODE_PLUGIN_FOREIGN));
assert!(foreign.remedy.is_some());
}
#[test]
fn the_command_plane_has_no_request_plane_yet() {
assert!(binding().model_relay_wiring().is_none());
assert!(binding().provider_endpoints().is_none());
}
}