falsegreen 0.1.1

FalseGreen client — independent verification for coding agents
//! Install FalseGreen into agent MCP configurations.
//!
//! Two installation modes:
//! - **STDIO launch**: the agent spawns `falsegreen mcp` as a subprocess.
//!   Used by Codex CLI and Codex in VS Code.
//! - **HTTP server**: the agent connects directly to mcp.falsegreen.com.
//!   Used by GitHub Copilot, Claude Code, OpenCode, and Cursor.

use std::path::PathBuf;

use crate::shim::cli::AgentTarget;

/// MCP service URL (matches the public documentation).
const MCP_URL: &str = "https://mcp.falsegreen.com/v1/mcp";

/// Install FalseGreen into the specified agent's MCP config.
pub fn install(agent: &AgentTarget) -> std::io::Result<()> {
    match agent {
        AgentTarget::Codex => install_codex(),
        AgentTarget::GithubCopilot => install_vscode_http(),
        AgentTarget::ClaudeCode => install_claude_code(),
        AgentTarget::OpenCode => install_opencode(),
        AgentTarget::Cursor => install_cursor(),
    }
}

// ── STDIO launch configs ──────────────────────────────────────────

fn install_codex() -> std::io::Result<()> {
    let config_path = codex_config_path()?;
    let self_path = std::env::current_exe()?;

    let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
    let mut doc: toml::Value = if existing.trim().is_empty() {
        toml::Value::Table(toml::value::Table::new())
    } else {
        toml::from_str(&existing).unwrap_or(toml::Value::Table(toml::value::Table::new()))
    };

    let root = doc.as_table_mut().unwrap();

    if let Some(mcp) = root.get("mcp_servers").and_then(|v| v.as_table()) {
        if mcp.contains_key("falsegreen") {
            println!("FalseGreen is already configured in {}.", config_path.display());
            println!("To reconfigure, remove the [mcp_servers.falsegreen] section first.");
            return Ok(());
        }
    }

    let server = stdio_server_config(&self_path);

    let mcp_servers = root
        .entry("mcp_servers")
        .or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
    if let Some(table) = mcp_servers.as_table_mut() {
        table.insert("falsegreen".into(), toml::Value::Table(server));
    }

    write_toml(&config_path, &doc)?;
    println!("Installed FalseGreen into {}", config_path.display());
    println!("\nCodex will now launch `falsegreen mcp` as a STDIO MCP server.");
    println!("Run `codex mcp list` to verify.");
    Ok(())
}

// ── HTTP server configs ───────────────────────────────────────────

fn install_vscode_http() -> std::io::Result<()> {
    let config_path = vscode_mcp_path()?;

    let mut doc = read_json_or_default(&config_path);

    // Ensure inputs array has the falsegreen-key prompt.
    if let Some(inputs) = doc.get_mut("inputs").and_then(|v| v.as_array_mut()) {
        if !inputs.iter().any(|i| i.get("id") == Some(&serde_json::json!("falsegreen-key"))) {
            inputs.push(serde_json::json!({
                "type": "promptString",
                "id": "falsegreen-key",
                "description": "FalseGreen key",
                "password": true,
            }));
        }
    } else {
        doc["inputs"] = serde_json::json!([
            {"type": "promptString", "id": "falsegreen-key", "description": "FalseGreen key", "password": true}
        ]);
    }

    let server = serde_json::json!({
        "type": "http",
        "url": MCP_URL,
        "headers": {"X-API-KEY": "${input:falsegreen-key}"}
    });

    insert_json_server(&mut doc, "falsegreen", server);
    write_json(&config_path, &doc)?;
    println!("Installed FalseGreen into {}", config_path.display());
    println!("\nGitHub Copilot will connect to {} with your key.", MCP_URL);
    println!("Open Copilot Chat in Agent mode and enable the FalseGreen tools.");
    Ok(())
}

fn install_claude_code() -> std::io::Result<()> {
    let config_path = project_mcp_path()?;
    let key = crate::shim::config::load_token();

    let mut doc = read_json_or_default(&config_path);

    let header_value = match &key {
        Some(k) => k.clone(),
        None => "${FALSEGREEN_KEY}".to_string(),
    };

    let server = serde_json::json!({
        "type": "http",
        "url": MCP_URL,
        "headers": {"X-API-KEY": header_value}
    });

    // Claude Code uses "mcpServers" (camelCase).
    if let Some(servers) = doc.get_mut("mcpServers").and_then(|v| v.as_object_mut()) {
        if servers.contains_key("falsegreen") {
            println!("FalseGreen is already configured in {}.", config_path.display());
            return Ok(());
        }
        servers.insert("falsegreen".to_string(), server);
    } else {
        let mut servers = serde_json::Map::new();
        servers.insert("falsegreen".to_string(), server);
        doc["mcpServers"] = serde_json::Value::Object(servers);
    }

    write_json(&config_path, &doc)?;
    println!("Installed FalseGreen into {}", config_path.display());
    if key.is_none() {
        println!("\nSet FALSEGREEN_KEY in your environment before launching Claude Code.");
    }
    println!("Alternatively, run: claude mcp add --transport http falsegreen {} \\", MCP_URL);
    println!("  --header \"X-API-KEY: YOUR_KEY\"");
    Ok(())
}

fn install_opencode() -> std::io::Result<()> {
    let config_path = opencode_config_path()?;

    let mut doc = read_json_or_default(&config_path);

    let header_value = "{env:FALSEGREEN_KEY}".to_string();

    let server = serde_json::json!({
        "type": "remote",
        "url": MCP_URL,
        "headers": {"X-API-KEY": header_value}
    });

    // OpenCode uses "mcp" > "falsegreen".
    if let Some(mcp) = doc.get_mut("mcp").and_then(|v| v.as_object_mut()) {
        if mcp.contains_key("falsegreen") {
            println!("FalseGreen is already configured in {}.", config_path.display());
            return Ok(());
        }
        mcp.insert("falsegreen".to_string(), server);
    } else {
        let mut mcp = serde_json::Map::new();
        mcp.insert("falsegreen".to_string(), server);
        doc["mcp"] = serde_json::Value::Object(mcp);
    }

    write_json(&config_path, &doc)?;
    println!("Installed FalseGreen into {}", config_path.display());
    println!("\nSet FALSEGREEN_KEY in your environment before launching OpenCode.");
    Ok(())
}

fn install_cursor() -> std::io::Result<()> {
    let config_path = cursor_mcp_path()?;
    let key = crate::shim::config::load_token();

    let mut doc = read_json_or_default(&config_path);

    let header_value = match &key {
        Some(k) => k.clone(),
        None => "${env:FALSEGREEN_KEY}".to_string(),
    };

    let server = serde_json::json!({
        "url": MCP_URL,
        "headers": {"X-API-KEY": header_value}
    });

    // Cursor uses "mcpServers" (camelCase).
    if let Some(servers) = doc.get_mut("mcpServers").and_then(|v| v.as_object_mut()) {
        if servers.contains_key("falsegreen") {
            println!("FalseGreen is already configured in {}.", config_path.display());
            return Ok(());
        }
        servers.insert("falsegreen".to_string(), server);
    } else {
        let mut servers = serde_json::Map::new();
        servers.insert("falsegreen".to_string(), server);
        doc["mcpServers"] = serde_json::Value::Object(servers);
    }

    write_json(&config_path, &doc)?;
    println!("Installed FalseGreen into {}", config_path.display());
    if key.is_none() {
        println!("\nSet FALSEGREEN_KEY in your environment before launching Cursor.");
    }
    Ok(())
}

// ── Helpers ───────────────────────────────────────────────────────

/// Build a STDIO server config (TOML table) for the current binary.
fn stdio_server_config(self_path: &std::path::Path) -> toml::value::Table {
    let mut server = toml::value::Table::new();
    server.insert(
        "command".into(),
        toml::Value::String(self_path.to_string_lossy().to_string()),
    );
    server.insert(
        "args".into(),
        toml::Value::Array(vec![toml::Value::String("mcp".into())]),
    );

    let mut env = toml::value::Table::new();
    if let Some(token) = crate::shim::config::load_token() {
        env.insert("FALSEGREEN_KEY".into(), toml::Value::String(token));
    }
    if let Ok(api_url) = std::env::var("FALSEGREEN_API_URL") {
        env.insert("FALSEGREEN_API_URL".into(), toml::Value::String(api_url));
    }
    if !env.is_empty() {
        server.insert("env".into(), toml::Value::Table(env));
    }

    server
}

/// Insert a server into a JSON "servers" object, checking for duplicates.
fn insert_json_server(doc: &mut serde_json::Value, name: &str, server: serde_json::Value) {
    // Try "servers" first (VS Code style).
    if let Some(servers) = doc.get_mut("servers").and_then(|v| v.as_object_mut()) {
        if servers.contains_key(name) {
            println!("FalseGreen is already configured.");
            return;
        }
        servers.insert(name.to_string(), server);
        return;
    }
    // Create "servers" if it doesn't exist.
    let mut servers = serde_json::Map::new();
    servers.insert(name.to_string(), server);
    doc["servers"] = serde_json::Value::Object(servers);
}

fn read_json_or_default(path: &std::path::Path) -> serde_json::Value {
    let content = std::fs::read_to_string(path).unwrap_or_default();
    if content.trim().is_empty() {
        serde_json::json!({})
    } else {
        serde_json::from_str(&content).unwrap_or(serde_json::json!({}))
    }
}

fn write_json(path: &std::path::Path, doc: &serde_json::Value) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let serialized = serde_json::to_string_pretty(doc).unwrap_or_default();
    std::fs::write(path, serialized + "\n")
}

fn write_toml(path: &std::path::Path, doc: &toml::Value) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let serialized = toml::to_string_pretty(doc).unwrap_or_default();
    std::fs::write(path, serialized)
}

// ── Config paths ──────────────────────────────────────────────────

fn codex_config_path() -> std::io::Result<PathBuf> {
    if let Ok(home) = std::env::var("CODEX_HOME") {
        return Ok(PathBuf::from(home).join("config.toml"));
    }
    let home = dirs::home_dir().ok_or_else(|| {
        std::io::Error::new(std::io::ErrorKind::NotFound, "cannot find home directory")
    })?;
    Ok(home.join(".codex").join("config.toml"))
}

fn vscode_mcp_path() -> std::io::Result<PathBuf> {
    Ok(PathBuf::from(".vscode").join("mcp.json"))
}

fn project_mcp_path() -> std::io::Result<PathBuf> {
    Ok(PathBuf::from(".mcp.json"))
}

fn opencode_config_path() -> std::io::Result<PathBuf> {
    Ok(PathBuf::from("opencode.json"))
}

fn cursor_mcp_path() -> std::io::Result<PathBuf> {
    Ok(PathBuf::from(".cursor").join("mcp.json"))
}