tokf 0.2.33

Config-driven CLI tool that compresses command output before it reaches an LLM context
Documentation
use std::path::{Path, PathBuf};

use anyhow::Context;

// R1: Use export default object literal (not a function returning one).
// R7: Add 5000ms timeout to Bun.spawnSync.
// R8: Check proc.stdout for null before decoding.
// R9: Log errors in catch block with [tokf] prefix.
// {{TOKF_BIN}} is replaced at install time with the configured tokf binary (command or path).
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));
    }
  },
};
"#;

/// Install the `OpenCode` plugin.
///
/// # Errors
///
/// Returns an error if the plugin directory cannot be created or the plugin file cannot be written.
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)?;
    // R5: Standardize eprintln prefix to [tokf]
    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()))?;

    // Use serde_json for proper JS/JSON string escaping (handles \n, \r, \t, etc.).
    // serde_json::to_string wraps in quotes — strip them for template substitution.
    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();

        // Only one file exists, no errors
        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""#));
    }

    // R1: Verify the plugin uses export default object literal, not a function.
    #[test]
    fn plugin_template_uses_object_export() {
        assert!(PLUGIN_TEMPLATE.contains("export default {"));
        assert!(!PLUGIN_TEMPLATE.contains("export default async function"));
    }

    // R7: Verify timeout is present.
    #[test]
    fn plugin_template_has_timeout() {
        assert!(PLUGIN_TEMPLATE.contains("timeout: 5000"));
    }

    // R8: Verify stdout null check is present.
    #[test]
    fn plugin_template_checks_stdout_null() {
        assert!(PLUGIN_TEMPLATE.contains("proc.stdout"));
    }

    // R9: Verify error logging is present in catch block.
    #[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"));
    }
}