use std::path::{Path, PathBuf};
use anyhow::Context;
const PLUGIN_TEMPLATE: &str = r#"// tokf-filter plugin for OpenCode
// Generated by: tokf hook install --tool opencode
// Docs: https://github.com/mpecan/tokf
const TOKF_BIN = "{{TOKF_BIN}}";
export default {
"tool.execute.before": async (
input: { tool: string },
output: { args: { command?: string } }
): Promise<void> => {
if (input.tool !== "bash" || !output.args.command) return;
try {
const proc = Bun.spawnSync([TOKF_BIN, "rewrite", output.args.command], {
timeout: 5000,
});
if (proc.exitCode === 0 && proc.stdout) {
const rewritten = new TextDecoder().decode(proc.stdout).trim();
if (rewritten && rewritten !== output.args.command) {
output.args.command = rewritten;
}
}
} catch (err) {
// passthrough — never block a command
console.error("[tokf] plugin error:", err instanceof Error ? err.message : String(err));
}
},
};
"#;
pub fn install(global: bool, tokf_bin: &str) -> anyhow::Result<()> {
let plugin_dir = if global {
global_plugin_dir()?
} else {
PathBuf::from(".opencode/plugins")
};
install_to(&plugin_dir, tokf_bin)
}
pub(crate) fn install_to(plugin_dir: &Path, tokf_bin: &str) -> anyhow::Result<()> {
write_plugin_file(plugin_dir, tokf_bin)?;
eprintln!(
"[tokf] OpenCode plugin installed to {}",
plugin_dir.join("tokf.ts").display()
);
eprintln!("[tokf] OpenCode will auto-load the plugin on next start.");
Ok(())
}
fn global_plugin_dir() -> anyhow::Result<PathBuf> {
let home = dirs::home_dir().context("could not determine home directory")?;
Ok(home.join(".config/opencode/plugins"))
}
fn write_plugin_file(plugin_dir: &Path, tokf_bin: &str) -> anyhow::Result<()> {
std::fs::create_dir_all(plugin_dir)
.with_context(|| format!("failed to create plugin dir: {}", plugin_dir.display()))?;
let json_str = serde_json::to_string(tokf_bin)
.with_context(|| format!("failed to JSON-escape tokf binary: {tokf_bin}"))?;
let js_escaped = &json_str[1..json_str.len() - 1];
let content = PLUGIN_TEMPLATE.replace("{{TOKF_BIN}}", js_escaped);
let plugin_file = plugin_dir.join("tokf.ts");
std::fs::write(&plugin_file, content)
.with_context(|| format!("failed to write plugin file: {}", plugin_file.display()))?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn install_to_creates_plugin_file() {
let dir = TempDir::new().unwrap();
let plugin_dir = dir.path().join("plugins");
install_to(&plugin_dir, "tokf").unwrap();
assert!(plugin_dir.join("tokf.ts").exists());
}
#[test]
fn install_to_is_idempotent() {
let dir = TempDir::new().unwrap();
let plugin_dir = dir.path().join("plugins");
install_to(&plugin_dir, "tokf").unwrap();
install_to(&plugin_dir, "tokf").unwrap();
let entries: Vec<_> = std::fs::read_dir(&plugin_dir).unwrap().collect();
assert_eq!(entries.len(), 1);
}
#[test]
fn write_plugin_uses_bare_tokf() {
let dir = TempDir::new().unwrap();
let plugin_dir = dir.path().join("plugins");
write_plugin_file(&plugin_dir, "tokf").unwrap();
let content = std::fs::read_to_string(plugin_dir.join("tokf.ts")).unwrap();
assert!(
content.contains(r#"const TOKF_BIN = "tokf";"#),
"should use bare tokf, got: {content}"
);
}
#[test]
fn write_plugin_custom_path() {
let dir = TempDir::new().unwrap();
let plugin_dir = dir.path().join("plugins");
write_plugin_file(&plugin_dir, "/opt/bin/tokf").unwrap();
let content = std::fs::read_to_string(plugin_dir.join("tokf.ts")).unwrap();
assert!(
content.contains(r#"const TOKF_BIN = "/opt/bin/tokf";"#),
"should use custom path, got: {content}"
);
}
#[test]
fn write_plugin_escapes_backslash_in_path() {
let dir = TempDir::new().unwrap();
let plugin_dir = dir.path().join("plugins");
write_plugin_file(&plugin_dir, r"C:\Users\me\tokf.exe").unwrap();
let content = std::fs::read_to_string(plugin_dir.join("tokf.ts")).unwrap();
assert!(
content.contains(r#"const TOKF_BIN = "C:\\Users\\me\\tokf.exe";"#),
"backslashes should be escaped for JS, got: {content}"
);
}
#[test]
fn write_plugin_rewrites_bash_tool_name() {
let dir = TempDir::new().unwrap();
let plugin_dir = dir.path().join("plugins");
write_plugin_file(&plugin_dir, "tokf").unwrap();
let content = std::fs::read_to_string(plugin_dir.join("tokf.ts")).unwrap();
assert!(content.contains(r#"input.tool !== "bash""#));
}
#[test]
fn plugin_template_uses_object_export() {
assert!(PLUGIN_TEMPLATE.contains("export default {"));
assert!(!PLUGIN_TEMPLATE.contains("export default async function"));
}
#[test]
fn plugin_template_has_timeout() {
assert!(PLUGIN_TEMPLATE.contains("timeout: 5000"));
}
#[test]
fn plugin_template_checks_stdout_null() {
assert!(PLUGIN_TEMPLATE.contains("proc.stdout"));
}
#[test]
fn plugin_template_logs_errors() {
assert!(PLUGIN_TEMPLATE.contains("console.error"));
assert!(PLUGIN_TEMPLATE.contains("[tokf] plugin error:"));
}
#[test]
fn write_plugin_no_placeholder_remains() {
let dir = TempDir::new().unwrap();
let plugin_dir = dir.path().join("plugins");
write_plugin_file(&plugin_dir, "tokf").unwrap();
let content = std::fs::read_to_string(plugin_dir.join("tokf.ts")).unwrap();
assert!(!content.contains("{{TOKF_BIN}}"));
assert!(content.contains("const TOKF_BIN"));
}
}