safe-chains 0.164.0

Auto-allow safe bash commands in agentic coding tools
Documentation
use std::path::{Path, PathBuf};

use serde_json::{Map, Value};

use super::{InstallOutcome, Target};

pub struct OpenCodeTarget;

impl Target for OpenCodeTarget {
    fn name(&self) -> &'static str {
        "opencode"
    }

    fn display_name(&self) -> &'static str {
        "OpenCode"
    }

    fn detect_paths(&self, home: &Path) -> Vec<PathBuf> {
        vec![home.join(".config").join("opencode")]
    }

    fn install(&self, _home: &Path) -> Result<InstallOutcome, String> {
        Ok(InstallOutcome::Skipped {
            reason: "OpenCode integration generates `opencode.json` to stdout; \
                     run `safe-chains --opencode-config > opencode.json` from the project root"
                .to_string(),
        })
    }
}

pub fn render_opencode_json_in(dir: &Path, patterns: &[String]) -> String {
    let mut root: Map<String, Value> = std::fs::read_to_string(dir.join("opencode.json"))
        .ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .and_then(|v: Value| v.as_object().cloned())
        .unwrap_or_else(|| {
            let mut m = Map::new();
            m.insert(
                "$schema".to_string(),
                Value::String("https://opencode.ai/config.json".to_string()),
            );
            m
        });

    let mut bash = Map::new();
    bash.insert("*".to_string(), Value::String("ask".to_string()));
    for pat in patterns {
        bash.insert(pat.clone(), Value::String("allow".to_string()));
    }

    let permission = root
        .entry("permission")
        .or_insert_with(|| Value::Object(Map::new()));
    if !permission.is_object() {
        *permission = Value::Object(Map::new());
    }
    if let Value::Object(perm_map) = permission {
        perm_map.insert("bash".to_string(), Value::Object(bash));
    }

    let mut out = serde_json::to_string_pretty(&Value::Object(root)).unwrap_or_default();
    out.push('\n');
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn render_emits_valid_json() {
        let dir = tempfile::tempdir().unwrap();
        let patterns = vec!["ls".to_string(), "git status".to_string()];
        let json = render_opencode_json_in(dir.path(), &patterns);
        let parsed: Value = serde_json::from_str(&json).unwrap();
        assert_eq!(
            parsed.pointer("/permission/bash/ls").and_then(|v| v.as_str()),
            Some("allow"),
        );
        assert_eq!(
            parsed.pointer("/permission/bash/*").and_then(|v| v.as_str()),
            Some("ask"),
        );
    }

    #[test]
    fn render_includes_schema() {
        let dir = tempfile::tempdir().unwrap();
        let json = render_opencode_json_in(dir.path(), &[]);
        let parsed: Value = serde_json::from_str(&json).unwrap();
        assert_eq!(
            parsed.get("$schema").and_then(|v| v.as_str()),
            Some("https://opencode.ai/config.json"),
        );
    }

    #[test]
    fn render_trailing_newline() {
        let dir = tempfile::tempdir().unwrap();
        let json = render_opencode_json_in(dir.path(), &[]);
        assert!(json.ends_with('\n'));
    }

    #[test]
    fn render_merges_existing_config() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("opencode.json");
        std::fs::write(
            &path,
            r#"{"$schema": "https://opencode.ai/config.json", "model": "sonnet"}"#,
        )
        .unwrap();
        let json = render_opencode_json_in(dir.path(), &["ls".to_string()]);
        let parsed: Value = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.get("model").and_then(|v| v.as_str()), Some("sonnet"));
        assert_eq!(
            parsed.pointer("/permission/bash/ls").and_then(|v| v.as_str()),
            Some("allow"),
        );
    }

    #[test]
    fn install_returns_skip_with_guidance() {
        let dir = tempfile::tempdir().unwrap();
        let outcome = OpenCodeTarget.install(dir.path()).unwrap();
        match outcome {
            InstallOutcome::Skipped { reason } => {
                assert!(reason.contains("opencode.json"));
            }
            other => panic!("expected Skipped, got {:?}", std::mem::discriminant(&other)),
        }
    }
}