clash 0.5.5

Command Line Agent Safety Harness — permission policies for coding agents
Documentation
use anyhow::{Context, Result};
use serde_json::json;
use tracing::{Level, error, info, instrument, warn};

use crate::settings::ClashSettings;
use crate::style;
use crate::ui;

#[derive(Default)]
struct InitActions {
    policy_created: bool,
    plugin_installed: bool,
    statusline_installed: bool,
}

/// GitHub repository used to install the clash plugin marketplace.
const GITHUB_MARKETPLACE: &str = "empathic/clash";

/// Initialize clash at the chosen scope.
///
/// When `scope` is provided ("user" or "project"), initializes that scope
/// directly. When omitted, runs the interactive policy editor.
/// Only one scope is initialized per invocation.
#[instrument(level = Level::TRACE)]
pub fn run(scope: Option<String>, quick: bool) -> Result<()> {
    match scope.as_deref() {
        Some("project") => run_init_project(),
        _ if quick => run_init_quick(),
        _ => run_init_user(),
    }
}

/// Initialize or reconfigure the user-level policy via the interactive editor.
fn run_init_user() -> Result<()> {
    let mut actions = InitActions::default();

    let policy_path = write_starter_policy()?;
    crate::tui::run_with_options(&policy_path, false, true)?;
    actions.policy_created = true;

    // Always ensure settings.json records clash as an enabled plugin.
    let claude = claude_settings::ClaudeSettings::new();
    if let Err(e) = claude.set_plugin_enabled(claude_settings::SettingsLevel::User, "clash", true) {
        warn!(error = %e, "Could not set enabledPlugins in Claude Code settings");
    }

    // Install the Claude Code plugin from GitHub.
    match install_plugin() {
        Ok(()) => {
            actions.plugin_installed = true;
        }
        Err(e) => {
            error!(error = %e, "Could not install clash plugin");
            ui::warn(&format!(
                "Could not install the clash plugin: {e}\n  \
                 You can install it manually later:\n    \
                 claude plugin marketplace add {GITHUB_MARKETPLACE}\n    \
                 claude plugin install clash"
            ));
        }
    };

    // Install the status line so the user gets ambient policy visibility.
    if let Err(e) = super::statusline::install() {
        warn!(error = %e, "Could not install status line");
    } else {
        actions.statusline_installed = true;
    }

    print_user_summary(&actions);

    Ok(())
}

/// Quick-init: skip the interactive editor and write a sensible default policy directly.
fn run_init_quick() -> Result<()> {
    let settings_dir =
        ClashSettings::settings_dir().context("could not determine clash settings directory")?;

    std::fs::create_dir_all(&settings_dir)
        .with_context(|| format!("failed to create {}", settings_dir.display()))?;

    let policy_path = settings_dir.join("policy.star");

    let quick_policy = {
        use clash_starlark::codegen::ast::Stmt;
        use clash_starlark::codegen::builder::*;

        clash_starlark::codegen::serialize(&[
            load_std(&["match", "tool", "policy", "allow", "ask"]),
            Stmt::Blank,
            Stmt::def(
                "main",
                vec![Stmt::Return(policy(
                    ask(),
                    vec![
                        clash_starlark::match_tree! {
                            "Bash" => {
                                ("git", "cargo", "npm", "npx", "node", "bun", "python", "pip", "uv") => allow(),
                            },
                        },
                        tool(&["Read"]).allow(),
                        tool(&["Write"]).allow(),
                        tool(&["Edit"]).allow(),
                        tool(&["Glob"]).allow(),
                        tool(&["Grep"]).allow(),
                    ],
                    None,
                ))],
            ),
        ])
    };

    std::fs::write(&policy_path, quick_policy)
        .with_context(|| format!("failed to write {}", policy_path.display()))?;

    ui::success(&format!(
        "Quick setup: policy created at {}",
        policy_path.display()
    ));

    // Ensure settings.json records clash as an enabled plugin.
    let claude = claude_settings::ClaudeSettings::new();
    if let Err(e) = claude.set_plugin_enabled(claude_settings::SettingsLevel::User, "clash", true) {
        warn!(error = %e, "Could not set enabledPlugins in Claude Code settings");
    }

    // Install the Claude Code plugin from GitHub.
    if let Err(e) = install_plugin() {
        error!(error = %e, "Could not install clash plugin");
        ui::warn(&format!(
            "Could not install the clash plugin: {e}\n  \
             You can install it manually later:\n    \
             claude plugin marketplace add {GITHUB_MARKETPLACE}\n    \
             claude plugin install clash"
        ));
    }

    // Install the status line so the user gets ambient policy visibility.
    if let Err(e) = super::statusline::install() {
        warn!(error = %e, "Could not install status line");
    }

    Ok(())
}

/// Initialize a project-level policy in the project root's `.clash/` directory.
fn run_init_project() -> Result<()> {
    let project_root = ClashSettings::project_root()
        .context("could not find project root — are you inside a git repository?")?;

    let clash_dir = project_root.join(".clash");
    let policy_path = clash_dir.join("policy.star");

    if policy_path.exists() {
        ui::skip(&format!(
            "Project policy already exists at {}",
            policy_path.display()
        ));
        return Ok(());
    }

    std::fs::create_dir_all(&clash_dir)
        .with_context(|| format!("failed to create {}", clash_dir.display()))?;

    let project_policy = "load(\"@clash//std.star\", \"policy\", \"deny\")\ndef main():\n    return policy(default = deny(), rules = [])\n";
    std::fs::write(&policy_path, project_policy)
        .with_context(|| format!("failed to write {}", policy_path.display()))?;

    ui::success(&format!(
        "Project policy initialized at {}",
        policy_path.display()
    ));

    println!();
    println!("{}", style::bold("Setup complete!"));
    println!();
    ui::success(&format!(
        "Project policy created at {}",
        policy_path.display()
    ));
    println!();
    println!("{}:", style::bold("Next steps"));
    println!(
        "  {}  {}",
        style::dim("clash policy show"),
        style::dim("# view the compiled policy")
    );
    println!(
        "  {}  {}",
        style::dim("clash policy validate"),
        style::dim("# check for errors")
    );

    Ok(())
}

/// Build the starter policy JSON value for onboarding.
///
/// Includes pre-configured rules for base Claude tools (Read, Write, Edit,
/// Glob, Grep) so new users start with a working set of file-operation
/// permissions out of the box.
fn starter_policy_json() -> serde_json::Value {
    json!({
        "schema_version": 5,
        "default_effect": "ask",
        "default_sandbox": "default",
        "includes": [{"path": "@clash//builtin.star"}],
        "sandboxes": {
            "default": {
                "default": ["read", "execute"],
                "rules": [
                    {
                        "effect": "allow",
                        "caps": ["read", "write", "create"],
                        "path": "$PWD",
                        "path_match": "subpath"
                    },
                    {
                        "effect": "allow",
                        "caps": ["read", "write", "create"],
                        "path": "$TMPDIR",
                        "path_match": "subpath"
                    },
                    {
                        "effect": "allow",
                        "caps": ["read"],
                        "path": "$HOME",
                        "path_match": "subpath"
                    }
                ],
                "network": "deny"
            }
        },
        "tree": [
            {
                "condition": {
                    "observe": "tool_name",
                    "pattern": { "any_of": [
                        { "literal": { "literal": "Read" } },
                        { "literal": { "literal": "Glob" } },
                        { "literal": { "literal": "Grep" } }
                    ]},
                    "children": [{ "decision": { "allow": "default" } }]
                }
            },
            {
                "condition": {
                    "observe": "tool_name",
                    "pattern": { "any_of": [
                        { "literal": { "literal": "Write" } },
                        { "literal": { "literal": "Edit" } }
                    ]},
                    "children": [{ "decision": { "allow": "default" } }]
                }
            }
        ]
    })
}

/// Write a starter policy.json for onboarding.
///
/// Creates a minimal policy with `default_effect: "ask"`, the builtin include,
/// a sensible dev sandbox, and an empty rule tree. Returns the path to the file.
pub fn write_starter_policy() -> Result<std::path::PathBuf> {
    let policy_path = ClashSettings::policy_file()?;
    let policy_path = policy_path.with_extension("json");
    let dir = policy_path
        .parent()
        .context("policy file path has no parent directory")?;
    std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;

    let policy = starter_policy_json();

    std::fs::write(&policy_path, serde_json::to_string_pretty(&policy)?)
        .with_context(|| format!("failed to write {}", policy_path.display()))?;

    Ok(policy_path)
}

fn print_user_summary(actions: &InitActions) {
    let any_action =
        actions.policy_created || actions.plugin_installed || actions.statusline_installed;
    if !any_action {
        return;
    }

    println!();
    println!(
        "{}",
        style::bold("Setup complete! Here's what was configured:")
    );
    println!();

    if actions.policy_created {
        ui::success("Policy created");
    }
    if actions.plugin_installed {
        ui::success("Clash plugin installed in Claude Code");
    }
    if actions.statusline_installed {
        ui::success("Status line installed");
    }

    println!();
    println!("{}:", style::bold("To undo"));
    println!(
        "  {}  {}",
        style::dim("clash uninstall"),
        style::dim("# remove everything")
    );
    if actions.policy_created {
        println!(
            "  {}  {}",
            style::dim("clash policy edit"),
            style::dim("# modify your policy")
        );
    }

    println!();
    println!("{}:", style::bold("Next steps"));
    println!(
        "  {}  {}",
        style::dim("claude"),
        style::dim("# start a session with clash active")
    );
    println!(
        "  {}  {}",
        style::dim("/clash:status"),
        style::dim("# check policy status inside a session")
    );
    println!(
        "  {}  {}",
        style::dim("/clash:edit"),
        style::dim("# interactively edit your policy")
    );
}

/// Install the clash plugin into Claude Code from the GitHub marketplace.
pub fn install_plugin() -> Result<()> {
    ui::progress(&format!(
        "Installing clash plugin from {}...",
        GITHUB_MARKETPLACE,
    ));

    // Register the marketplace.
    let add_output = std::process::Command::new("claude")
        .args(["plugin", "marketplace", "add", GITHUB_MARKETPLACE])
        .output()
        .context("failed to run `claude plugin marketplace add` — is claude on PATH?")?;

    if !add_output.status.success() {
        let stderr = String::from_utf8_lossy(&add_output.stderr);
        // "already exists" is fine — marketplace was previously registered.
        if !stderr.contains("already") {
            anyhow::bail!("claude plugin marketplace add failed: {stderr}");
        }
        info!("marketplace already registered, continuing");
    }

    // Install the plugin.
    let install_output = std::process::Command::new("claude")
        .args(["plugin", "install", "clash"])
        .output()
        .context("failed to run `claude plugin install`")?;

    if !install_output.status.success() {
        let stderr = String::from_utf8_lossy(&install_output.stderr);
        // "already installed" is fine.
        if !stderr.contains("already") {
            anyhow::bail!("claude plugin install failed: {stderr}");
        }
        info!("plugin already installed");
    }

    ui::success("Clash plugin installed in Claude Code.");
    Ok(())
}

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

    #[test]
    fn starter_policy_compiles() {
        let policy = starter_policy_json();
        let json_str = serde_json::to_string_pretty(&policy).expect("serialize starter policy");
        crate::policy::compile::compile_to_tree(&json_str)
            .expect("starter policy must compile without errors");
    }
}