aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-09-16 23:22:37 +0300
committerGitHub <noreply@github.com>2026-09-16 22:22:37 +0200
commit266d6f6d876ab3eb020b88ef6caee0f91776db37 (patch)
treed5ca72f86c30fac4211209a1d2377511e1107fc2
parent956f23ff6b72d16fe66169730b76bf565d12d449 (diff)
fix: respect opencode tui.json registrations (#4250)
refs #4240 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
-rw-r--r--docs/next/website/src/content/docs/integrations.mdx2
-rw-r--r--src/integration/actions.rs4
-rw-r--r--src/integration/opencode_config.rs72
-rw-r--r--src/integration/targets.rs14
-rw-r--r--src/integration/tests.rs118
-rw-r--r--src/integration/types.rs3
6 files changed, 187 insertions, 26 deletions
diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx
index e584bb06..7bca01e6 100644
--- a/docs/next/website/src/content/docs/integrations.mdx
+++ b/docs/next/website/src/content/docs/integrations.mdx
@@ -223,7 +223,7 @@ herdr integration install opencode
The integration supports OpenCode V1 `1.18.29` or later and OpenCode V2 (tested with beta `19242`). The OpenCode config directory must already exist. Herdr installs the server entrypoint at `~/.config/opencode/plugins/herdr-agent-state.js`, the shared TUI plugin at `herdr-tui-session.js`, and a V2 TUI entrypoint at `herdr-opencode/tui.js` in that config directory.
-Install registers the V1 TUI plugin in `tui.jsonc` and the V2 TUI plugin in `cli.json`, preserving other preferences and plugins. If OpenCode still has V1 TUI preferences to import (`tui.json` or `kv.json`), Herdr defers registration so the first-start migration can run; start `opencode2` once and reinstall the integration afterward. Otherwise Herdr creates `cli.json` with the plugin registration. Restart the OpenCode TUI after installation. Uninstall removes the managed plugin files and their configuration entries.
+Install registers the V1 TUI plugin in `tui.jsonc` and the V2 TUI plugin in `cli.json`, preserving other preferences and plugins. An existing V1 registration in `tui.json` also counts as installed and is reused without creating `tui.jsonc`. Uninstall removes the Herdr registration from both TUI config files while keeping the files and other preferences. If OpenCode still has V1 TUI preferences to import (`tui.json` or `kv.json`), Herdr defers registration so the first-start migration can run; start `opencode2` once and reinstall the integration afterward. Otherwise Herdr creates `cli.json` with the plugin registration. Restart the OpenCode TUI after installation. Uninstall removes the managed plugin files and their configuration entries.
V2 lifecycle reporting runs in the pane-local TUI, which associates events with its selected root session even when multiple panes share one OpenCode server. Completion and interruption clear the working state; pending permission requests, pending forms, and failed executions keep the pane blocked. V2 Mini and headless clients do not run the TUI plugin and therefore do not provide this lifecycle reporting.
diff --git a/src/integration/actions.rs b/src/integration/actions.rs
index d51682f8..e57fc0c2 100644
--- a/src/integration/actions.rs
+++ b/src/integration/actions.rs
@@ -556,10 +556,10 @@ pub(crate) fn uninstall_target(
result.tui_plugin_path.display()
)
});
- if result.updated_tui_config {
+ for path in result.updated_tui_configs {
messages.push(format!(
"removed herdr opencode plugin entry from {}",
- result.tui_config_path.display()
+ path.display()
));
}
messages
diff --git a/src/integration/opencode_config.rs b/src/integration/opencode_config.rs
index b062365b..888efa13 100644
--- a/src/integration/opencode_config.rs
+++ b/src/integration/opencode_config.rs
@@ -10,12 +10,17 @@ use super::config_file::{check_config_target, write_config};
const TUI_CONFIG_NAME: &str = "tui.jsonc";
-pub(crate) fn tui_config_path(config_dir: &Path) -> PathBuf {
- config_dir.join(TUI_CONFIG_NAME)
+fn tui_config_paths(config_dir: &Path) -> [PathBuf; 2] {
+ [
+ config_dir.join(TUI_CONFIG_NAME),
+ config_dir.join("tui.json"),
+ ]
}
pub(crate) fn validate_tui_plugin_config(config_dir: &Path) -> io::Result<()> {
- validate_plugin_config(&tui_config_path(config_dir), "plugin")?;
+ for path in tui_config_paths(config_dir) {
+ validate_plugin_config(&path, "plugin")?;
+ }
validate_plugin_config(&config_dir.join("cli.json"), "plugins")
}
@@ -37,7 +42,13 @@ fn validate_plugin_config(config_path: &Path, key: &str) -> io::Result<()> {
}
pub(crate) fn add_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<PathBuf> {
- add_plugin(tui_config_path(config_dir), "plugin", plugin_spec)
+ for path in tui_config_paths(config_dir) {
+ if plugin_is_configured(&path, "plugin", plugin_spec) {
+ return Ok(path);
+ }
+ }
+ // Keep tui.json absent on fresh installs so OpenCode can migrate its settings.
+ add_plugin(config_dir.join(TUI_CONFIG_NAME), "plugin", plugin_spec)
}
pub(crate) fn add_cli_plugin(
@@ -98,8 +109,21 @@ fn add_plugin(config_path: PathBuf, key: &str, plugin_spec: &str) -> io::Result<
Ok(config_path)
}
-pub(crate) fn remove_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<bool> {
- remove_plugin(&tui_config_path(config_dir), "plugin", plugin_spec)
+pub(crate) fn remove_tui_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<Vec<PathBuf>> {
+ let mut updated = Vec::new();
+ let mut errors = Vec::new();
+ for path in tui_config_paths(config_dir) {
+ match remove_plugin(&path, "plugin", plugin_spec) {
+ Ok(true) => updated.push(path),
+ Ok(false) => {}
+ Err(err) => errors.push(err.to_string()),
+ }
+ }
+ if errors.is_empty() {
+ Ok(updated)
+ } else {
+ Err(io::Error::other(errors.join("; ")))
+ }
}
pub(crate) fn remove_cli_plugin(config_dir: &Path, plugin_spec: &str) -> io::Result<bool> {
@@ -143,7 +167,9 @@ fn remove_plugin(config_path: &Path, key: &str, plugin_spec: &str) -> io::Result
}
pub(crate) fn tui_plugin_is_configured(config_dir: &Path, plugin_spec: &str) -> bool {
- plugin_is_configured(&tui_config_path(config_dir), "plugin", plugin_spec)
+ tui_config_paths(config_dir)
+ .iter()
+ .any(|path| plugin_is_configured(path, "plugin", plugin_spec))
}
pub(crate) fn cli_plugin_is_configured(config_dir: &Path, plugin_spec: &str) -> bool {
@@ -323,7 +349,10 @@ mod tests {
])
);
- assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap());
+ assert_eq!(
+ remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(),
+ vec![config_path.clone()]
+ );
let removed_content = fs::read_to_string(&config_path).unwrap();
assert!(removed_content.contains("// Keep this comment."));
let removed = parse_config(&config_path);
@@ -364,7 +393,10 @@ mod tests {
let dir = unique_dir();
let config_path = add_tui_plugin(&dir, "./herdr-tui-state.js").unwrap();
- assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap());
+ assert_eq!(
+ remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(),
+ vec![config_path.clone()]
+ );
assert!(config_path.is_file());
assert_eq!(parse_config(&config_path), json!({}));
@@ -372,6 +404,23 @@ mod tests {
}
#[test]
+ fn configured_tui_plugin_accepts_json_registration() {
+ let dir = unique_dir();
+ fs::write(
+ dir.join("tui.json"),
+ r#"{"plugin":["./herdr-tui-session.js"]}"#,
+ )
+ .unwrap();
+
+ let configured = tui_plugin_is_configured(&dir, "./herdr-tui-session.js");
+ fs::remove_dir_all(dir).unwrap();
+ assert!(
+ configured,
+ "OpenCode loads plugin registrations from tui.json"
+ );
+ }
+
+ #[test]
fn configured_tui_plugin_accepts_option_tuple() {
let dir = unique_dir();
fs::write(
@@ -381,7 +430,10 @@ mod tests {
.unwrap();
assert!(tui_plugin_is_configured(&dir, "./herdr-tui-state.js"));
- assert!(remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap());
+ assert_eq!(
+ remove_tui_plugin(&dir, "./herdr-tui-state.js").unwrap(),
+ vec![dir.join(TUI_CONFIG_NAME)]
+ );
fs::remove_dir_all(dir).unwrap();
}
diff --git a/src/integration/targets.rs b/src/integration/targets.rs
index 6cb90c18..ec605866 100644
--- a/src/integration/targets.rs
+++ b/src/integration/targets.rs
@@ -29,7 +29,7 @@ use super::file_ops::{
make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file,
};
use super::opencode_config::{
- add_cli_plugin, add_tui_plugin, remove_cli_plugin, remove_tui_plugin, tui_config_path,
+ add_cli_plugin, add_tui_plugin, remove_cli_plugin, remove_tui_plugin,
validate_tui_plugin_config,
};
use super::types::{
@@ -461,7 +461,7 @@ pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> {
pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> {
let dir = opencode_dir()?;
- check_config_targets(&dir, &["tui.jsonc", "cli.json"])?;
+ check_config_targets(&dir, &["tui.jsonc", "tui.json", "cli.json"])?;
if !dir.is_dir() {
return Err(io::Error::other(format!(
"opencode config directory not found at {}. install opencode first",
@@ -844,8 +844,7 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> {
pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> {
let dir = opencode_dir()?;
- check_config_targets(&dir, &["tui.jsonc", "cli.json"])?;
- let tui_config_path = tui_config_path(&dir);
+ check_config_targets(&dir, &["tui.jsonc", "tui.json", "cli.json"])?;
let plugin_path = dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME);
let tui_plugin_path = dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME);
let mut errors = Vec::new();
@@ -858,10 +857,10 @@ pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> {
errors.push(format!("failed to remove {}: {err}", v2_dir.display()));
false
});
- let updated_tui_config =
+ let updated_tui_configs =
remove_tui_plugin(&dir, OPENCODE_TUI_PLUGIN_SPEC).unwrap_or_else(|err| {
errors.push(err.to_string());
- false
+ Vec::new()
});
let removed_plugin = remove_file_if_exists(&plugin_path).unwrap_or_else(|err| {
errors.push(format!("failed to remove {}: {err}", plugin_path.display()));
@@ -881,10 +880,9 @@ pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> {
Ok(OpenCodeUninstallResult {
plugin_path,
tui_plugin_path,
- tui_config_path,
removed_plugin,
removed_tui_plugin,
- updated_tui_config,
+ updated_tui_configs,
})
}
diff --git a/src/integration/tests.rs b/src/integration/tests.rs
index 1a102992..f7e2154e 100644
--- a/src/integration/tests.rs
+++ b/src/integration/tests.rs
@@ -2326,6 +2326,69 @@ fn install_opencode_writes_server_and_tui_plugins() {
let _ = fs::remove_dir_all(base);
}
+#[cfg(unix)]
+#[test]
+fn opencode_reuses_json_registration_in_symlinked_config_directory() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let dotfiles = base.join("dotfiles");
+ let dir = home.join(".config/opencode");
+ fs::create_dir_all(home.join(".config")).unwrap();
+ fs::create_dir_all(&dotfiles).unwrap();
+ std::os::unix::fs::symlink(&dotfiles, &dir).unwrap();
+ std::env::set_var("HOME", &home);
+ let json_path = dir.join("tui.json");
+ let original = "{\n // User preferences\n \"theme\":\"system\",\n \"plugin\":[\"other\",[\"./herdr-tui-session.js\",{\"enabled\":true}]]\n}\n";
+ fs::write(&json_path, original).unwrap();
+
+ for _ in 0..2 {
+ let installed = install_opencode().unwrap();
+ assert_eq!(installed.tui_config_path, json_path);
+ assert!(installed.cli_config_path.is_none());
+ assert!(!dir.join("tui.jsonc").exists());
+ assert_eq!(fs::read_to_string(&json_path).unwrap(), original);
+ assert_eq!(
+ integration_status_at(
+ crate::api::schema::IntegrationTarget::Opencode,
+ installed.plugin_path,
+ OPENCODE_INTEGRATION_VERSION,
+ )
+ .state,
+ IntegrationStatusKind::Current
+ );
+ }
+
+ // Older installs may have registered the same plugin in both files.
+ let jsonc_path = dir.join("tui.jsonc");
+ fs::write(
+ &jsonc_path,
+ r#"{"plugin":["./herdr-tui-session.js","another"]}"#,
+ )
+ .unwrap();
+ assert_eq!(install_opencode().unwrap().tui_config_path, jsonc_path);
+ let result = uninstall_opencode().unwrap();
+ assert_eq!(
+ result.updated_tui_configs,
+ vec![jsonc_path.clone(), json_path.clone()]
+ );
+ let json = fs::read_to_string(&json_path).unwrap();
+ assert!(json.contains("// User preferences"));
+ assert!(!json.contains("herdr-tui-session.js"));
+ assert!(json.contains("\"other\""));
+ assert!(json.contains("\"system\""));
+ assert_eq!(
+ serde_json::from_str::<Value>(&fs::read_to_string(jsonc_path).unwrap()).unwrap(),
+ json!({"plugin":["another"]})
+ );
+ assert!(uninstall_opencode().unwrap().updated_tui_configs.is_empty());
+ assert_eq!(fs::read_link(&dir).unwrap(), dotfiles);
+ assert!(!result.plugin_path.exists());
+ assert!(!result.tui_plugin_path.exists());
+ std::env::remove_var("HOME");
+ fs::remove_dir_all(base).unwrap();
+}
+
#[test]
fn opencode_install_defers_v2_registration_while_migration_pending() {
let _lock = integration_env_lock();
@@ -2427,6 +2490,42 @@ fn opencode_hard_link_rejection_precedes_install_and_uninstall_asset_changes() {
fs::remove_dir_all(base).unwrap();
}
+#[test]
+fn opencode_json_config_validation_precedes_asset_changes() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let dir = home.join(".config/opencode");
+ fs::create_dir_all(dir.join("plugins")).unwrap();
+ std::env::set_var("HOME", &home);
+ let plugin = dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME);
+ fs::write(&plugin, "previous integration").unwrap();
+ let config = dir.join("tui.json");
+ fs::write(&config, r#"{"plugin":{}}"#).unwrap();
+ assert!(install_opencode()
+ .unwrap_err()
+ .to_string()
+ .contains("plugin list"));
+ assert_eq!(fs::read_to_string(&plugin).unwrap(), "previous integration");
+ let original = r#"{"plugin":["./herdr-tui-session.js"]}"#;
+ fs::write(&config, original).unwrap();
+ let alias = base.join("linked-config");
+ fs::hard_link(&config, &alias).unwrap();
+ for error in [
+ install_opencode().unwrap_err(),
+ uninstall_opencode().unwrap_err(),
+ ] {
+ assert!(error.to_string().contains("multiple hard links"));
+ assert!(error.to_string().contains("tui.json"));
+ }
+ assert_eq!(fs::read_to_string(&plugin).unwrap(), "previous integration");
+ assert_eq!(fs::read_to_string(&alias).unwrap(), original);
+ assert!(!dir.join("tui.jsonc").exists());
+ assert!(!dir.join(OPENCODE_TUI_PLUGIN_INSTALL_NAME).exists());
+ std::env::remove_var("HOME");
+ fs::remove_dir_all(base).unwrap();
+}
+
#[cfg(windows)]
#[test]
fn opencode_recovery_copy_blocks_retry_before_parsing_or_asset_changes() {
@@ -2539,12 +2638,15 @@ fn uninstall_opencode_removes_plugins_and_managed_tui_config_entry() {
assert!(result.removed_plugin);
assert!(result.removed_tui_plugin);
- assert!(result.updated_tui_config);
+ assert_eq!(
+ result.updated_tui_configs,
+ vec![installed.tui_config_path.clone()]
+ );
assert!(!result.plugin_path.exists());
assert!(!result.tui_plugin_path.exists());
- assert!(result.tui_config_path.exists());
+ assert!(installed.tui_config_path.exists());
let tui_config: Value =
- serde_json::from_str(&fs::read_to_string(&result.tui_config_path).unwrap()).unwrap();
+ serde_json::from_str(&fs::read_to_string(&installed.tui_config_path).unwrap()).unwrap();
assert_eq!(tui_config, json!({}));
assert_eq!(installed.plugin_path, result.plugin_path);
@@ -2588,6 +2690,12 @@ fn uninstall_opencode_removes_plugins_when_tui_config_is_invalid() {
fs::write(&plugin_path, OPENCODE_PLUGIN_ASSET).unwrap();
fs::write(&tui_plugin_path, OPENCODE_TUI_PLUGIN_ASSET).unwrap();
fs::write(opencode_dir.join("tui.jsonc"), "{\"plugin\":").unwrap();
+ let json_path = opencode_dir.join("tui.json");
+ fs::write(
+ &json_path,
+ r#"{"plugin":["./herdr-tui-session.js","other"]}"#,
+ )
+ .unwrap();
std::env::set_var("HOME", &home);
let err = uninstall_opencode().unwrap_err().to_string();
@@ -2595,6 +2703,10 @@ fn uninstall_opencode_removes_plugins_when_tui_config_is_invalid() {
assert!(err.contains("failed to parse OpenCode TUI config"));
assert!(!plugin_path.exists());
assert!(!tui_plugin_path.exists());
+ assert_eq!(
+ serde_json::from_str::<Value>(&fs::read_to_string(json_path).unwrap()).unwrap(),
+ json!({"plugin":["other"]})
+ );
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
diff --git a/src/integration/types.rs b/src/integration/types.rs
index 928675f6..8e42a5da 100644
--- a/src/integration/types.rs
+++ b/src/integration/types.rs
@@ -259,10 +259,9 @@ pub(crate) struct DroidUninstallResult {
pub(crate) struct OpenCodeUninstallResult {
pub plugin_path: PathBuf,
pub tui_plugin_path: PathBuf,
- pub tui_config_path: PathBuf,
pub removed_plugin: bool,
pub removed_tui_plugin: bool,
- pub updated_tui_config: bool,
+ pub updated_tui_configs: Vec<PathBuf>,
}
#[derive(Debug)]