silence-cli 0.1.0

Remove comments from source code using tree-sitter. Installs a post-edit hook into Claude Code, Codex, Opencode, and Pi so AI-written slop comments are stripped automatically. Provides the `silence` binary.
use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Copy)]
pub enum Scope {
    User,
    Project,
}

#[derive(Clone, Copy)]
enum Op<'a> {
    Install { command: &'a str },
    Uninstall,
    Status,
}

struct Report {
    agent: &'static str,
    state: &'static str,
    path: PathBuf,
    note: Option<&'static str>,
}

pub fn install(scope: Scope) -> Result<()> {
    let bin = silence_bin();
    let command = silence_command(&bin);
    run(scope, &bin, Op::Install { command: &command }, "installing")
}

pub fn uninstall(scope: Scope) -> Result<()> {
    let bin = silence_bin();
    run(scope, &bin, Op::Uninstall, "removing")
}

pub fn status(scope: Scope) -> Result<()> {
    let bin = silence_bin();
    run(scope, &bin, Op::Status, "status of")
}

#[allow(clippy::unnecessary_wraps)]
fn run(scope: Scope, bin: &str, op: Op, verb: &str) -> Result<()> {
    println!(
        "{verb} silence post-edit hooks ({} scope)",
        scope_label(scope)
    );
    let reports = [
        json_agent(
            "Claude Code",
            claude_path(scope),
            "Write|Edit",
            JsonShape::WrappedInHooks,
            op,
            None,
        ),
        json_agent(
            "Codex",
            codex_path(scope),
            "apply_patch",
            JsonShape::PostToolUseAtRoot,
            op,
            Some("run /hooks in codex to trust"),
        ),
        file_agent("Opencode", opencode_path(scope), &opencode_plugin(bin), op),
        file_agent("Pi", pi_path(scope), &pi_extension(bin), op),
    ];
    let mut any_note = false;
    for r in &reports {
        println!(
            "  {:<13} {:<18} {}",
            r.agent,
            r.state,
            display_path(&r.path)
        );
        if r.note.is_some() && r.state == "installed" {
            any_note = true;
        }
    }
    if matches!(op, Op::Install { .. }) && any_note {
        println!();
        for r in &reports {
            if let (Some(note), "installed") = (r.note, r.state) {
                println!("  note: {}{}", r.agent, note);
            }
        }
    }
    Ok(())
}

#[derive(Clone, Copy)]
enum JsonShape {
    WrappedInHooks,
    PostToolUseAtRoot,
}

fn json_agent(
    agent: &'static str,
    path: PathBuf,
    matcher: &str,
    shape: JsonShape,
    op: Op,
    note: Option<&'static str>,
) -> Report {
    let result: Result<&'static str> = (|| match op {
        Op::Install { command } => {
            let mut root = read_json(&path)?;
            let arr = post_tool_use(&mut root, shape)?;
            if arr.iter().any(is_silence_entry) {
                return Ok("already installed");
            }
            arr.push(json!({
                "matcher": matcher,
                "hooks": [{ "type": "command", "command": command }],
            }));
            write_json(&path, &root)?;
            Ok("installed")
        }
        Op::Uninstall => {
            if !path.exists() {
                return Ok("not set");
            }
            let mut root = read_json(&path)?;
            let arr = post_tool_use(&mut root, shape)?;
            let before = arr.len();
            arr.retain(|e| !is_silence_entry(e));
            if arr.len() == before {
                return Ok("not set");
            }
            write_json(&path, &root)?;
            Ok("removed")
        }
        Op::Status => {
            if !path.exists() {
                return Ok("not set");
            }
            let mut root = read_json(&path)?;
            let active = post_tool_use(&mut root, shape)?
                .iter()
                .any(is_silence_entry);
            Ok(if active { "active" } else { "not set" })
        }
    })();
    report(agent, path, result, note)
}

fn read_json(path: &Path) -> Result<Value> {
    if !path.exists() {
        return Ok(json!({}));
    }
    let text = std::fs::read_to_string(path)?;
    if text.trim().is_empty() {
        return Ok(json!({}));
    }
    serde_json::from_str(&text).with_context(|| format!("{} is not valid JSON", path.display()))
}

fn write_json(path: &Path, root: &Value) -> Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let mut text = serde_json::to_string_pretty(root)?;
    text.push('\n');
    std::fs::write(path, text)?;
    Ok(())
}

fn post_tool_use(root: &mut Value, shape: JsonShape) -> Result<&mut Vec<Value>> {
    let obj = root
        .as_object_mut()
        .context("config root is not a JSON object")?;
    let host = match shape {
        JsonShape::PostToolUseAtRoot => obj,
        JsonShape::WrappedInHooks => {
            let hooks = obj.entry("hooks").or_insert_with(|| json!({}));
            hooks
                .as_object_mut()
                .context("\"hooks\" is not a JSON object")?
        }
    };
    let post = host.entry("PostToolUse").or_insert_with(|| json!([]));
    post.as_array_mut()
        .context("\"PostToolUse\" is not a JSON array")
}

fn is_silence_entry(entry: &Value) -> bool {
    entry
        .get("hooks")
        .and_then(Value::as_array)
        .is_some_and(|hooks| {
            hooks.iter().any(|h| {
                h.get("command")
                    .and_then(Value::as_str)
                    .is_some_and(|c| c.contains("--hook") && c.contains("silence"))
            })
        })
}

fn file_agent(agent: &'static str, path: PathBuf, content: &str, op: Op) -> Report {
    let result: Result<&'static str> = (|| match op {
        Op::Install { .. } => {
            let existed = path.exists();
            if let Some(parent) = path.parent() {
                std::fs::create_dir_all(parent)?;
            }
            std::fs::write(&path, content)?;
            Ok(if existed { "updated" } else { "installed" })
        }
        Op::Uninstall => {
            if !path.exists() {
                return Ok("not set");
            }
            std::fs::remove_file(&path)?;
            Ok("removed")
        }
        Op::Status => Ok(if path.exists() { "active" } else { "not set" }),
    })();
    report(agent, path, result, None)
}

fn report(
    agent: &'static str,
    path: PathBuf,
    result: Result<&'static str>,
    note: Option<&'static str>,
) -> Report {
    let state = match result {
        Ok(s) => s,
        Err(e) => {
            eprintln!("  {agent}: {e:#}");
            "error"
        }
    };
    Report {
        agent,
        state,
        path,
        note,
    }
}

fn claude_path(scope: Scope) -> PathBuf {
    match scope {
        Scope::User => home().join(".claude/settings.json"),
        Scope::Project => PathBuf::from(".claude/settings.json"),
    }
}

fn codex_path(scope: Scope) -> PathBuf {
    match scope {
        Scope::User => home().join(".codex/hooks.json"),
        Scope::Project => PathBuf::from(".codex/hooks.json"),
    }
}

fn opencode_path(scope: Scope) -> PathBuf {
    match scope {
        Scope::User => home().join(".config/opencode/plugins/silence.js"),
        Scope::Project => PathBuf::from(".opencode/plugin/silence.js"),
    }
}

fn pi_path(scope: Scope) -> PathBuf {
    match scope {
        Scope::User => home().join(".pi/agent/extensions/silence.ts"),
        Scope::Project => PathBuf::from(".pi/extensions/silence.ts"),
    }
}

fn home() -> PathBuf {
    std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .map_or_else(|| PathBuf::from("."), PathBuf::from)
}

fn display_path(path: &Path) -> String {
    let home = home();
    path.strip_prefix(&home).map_or_else(
        |_| path.display().to_string(),
        |rest| format!("~/{}", rest.display()),
    )
}

fn scope_label(scope: Scope) -> &'static str {
    match scope {
        Scope::User => "user",
        Scope::Project => "project",
    }
}

fn silence_bin() -> String {
    std::env::current_exe()
        .ok()
        .and_then(|p| p.to_str().map(String::from))
        .unwrap_or_else(|| "silence".into())
}

fn silence_command(bin: &str) -> String {
    if bin.contains(char::is_whitespace) {
        format!("\"{bin}\" --hook")
    } else {
        format!("{bin} --hook")
    }
}

fn js_string(s: &str) -> String {
    serde_json::to_string(s).unwrap_or_else(|_| format!("{s:?}"))
}

fn opencode_plugin(bin: &str) -> String {
    format!(
        r#"// silence post-edit hook — installed by `silence --install-hook`.
// Strips agent-written comments. Delete this file (or run
// `silence --uninstall-hook`) to remove it.
const BIN = {bin};

export const SilencePlugin = async ({{ $ }}) => ({{
  "tool.execute.after": async (input, output) => {{
    const tool = input && input.tool;
    if (tool !== "write" && tool !== "edit") return;
    const args = (output && output.args) || (input && input.args) || {{}};
    const file = args.filePath;
    if (file) await $`${{BIN}} --hook ${{file}}`.quiet().nothrow();
  }},
}});
"#,
        bin = js_string(bin)
    )
}

fn pi_extension(bin: &str) -> String {
    format!(
        r#"// silence post-edit hook — installed by `silence --install-hook`.
// Strips agent-written comments. Delete this file (or run
// `silence --uninstall-hook`) to remove it.
import {{ execFile }} from "node:child_process";

const BIN = {bin};

export default function (pi: any) {{
  pi.on("tool_result", (event: any) => {{
    if (!event || event.isError) return;
    const tool = event.toolName;
    if (tool !== "edit" && tool !== "write") return;
    const file = event.input && event.input.path;
    if (file) execFile(BIN, ["--hook", String(file)], () => {{}});
  }});
}}
"#,
        bin = js_string(bin)
    )
}