use anyhow::Result;
use tracing::{Level, instrument, warn};
use crate::sandbox;
use crate::settings::ClashSettings;
use crate::style;
use crate::ui;
enum CheckResult {
Pass(String),
Warn(String),
Fail(String),
}
enum OnboardFix {
InstallPlugin,
ConfigureBypass,
CreatePolicy,
InstallStatusLine,
}
impl CheckResult {
fn print(&self, label: &str) {
match self {
CheckResult::Pass(msg) => {
println!(
" {} {}: {}",
style::green_bold("PASS"),
style::bold(label),
msg
);
}
CheckResult::Warn(msg) => {
println!(
" {} {}: {}",
style::yellow_bold("WARN"),
style::bold(label),
msg
);
}
CheckResult::Fail(msg) => {
println!(
" {} {}: {}",
style::red_bold("FAIL"),
style::bold(label),
msg
);
}
}
}
fn is_fail(&self) -> bool {
matches!(self, CheckResult::Fail(_))
}
fn is_warn(&self) -> bool {
matches!(self, CheckResult::Warn(_))
}
}
#[instrument(level = Level::TRACE, skip(onboard))]
pub fn run(onboard: bool) -> Result<()> {
if onboard {
run_onboard()
} else {
run_diagnose()
}
}
fn run_diagnose() -> Result<()> {
ui::banner_section("Doctor");
let checks = vec![
("Disabled", check_disabled()),
("Passthrough", check_passthrough()),
("Policy files", check_policy_files()),
("Policy parsing", check_policy_parsing()),
("Plugin installed", check_plugin_installed()),
("Binary on PATH", check_binary_on_path()),
("File permissions", check_file_permissions()),
("Sandbox support", check_sandbox_support()),
];
let mut fail_count = 0;
let mut warn_count = 0;
for (label, result) in &checks {
result.print(label);
if result.is_fail() {
fail_count += 1;
}
if result.is_warn() {
warn_count += 1;
}
}
println!();
if fail_count == 0 && warn_count == 0 {
println!(
" {} All checks passed. clash is ready to use.",
style::green_bold("OK"),
);
} else if fail_count == 0 {
println!(
" {} {} warning(s), but no failures.",
style::yellow_bold("OK"),
warn_count,
);
} else {
println!(
" {} {} check(s) failed, {} warning(s). See above for fix instructions.",
style::red_bold("!!"),
fail_count,
warn_count,
);
}
Ok(())
}
fn run_onboard() -> Result<()> {
ui::banner_section("Doctor (onboard)");
println!(" Checking your setup...\n");
let mut fixed = 0u32;
let mut already_ok = 0u32;
let binary_check = check_binary_on_path();
binary_check.print("Binary on PATH");
match &binary_check {
CheckResult::Pass(_) => already_ok += 1,
_ => {
println!(
" {} This must be fixed manually — ensure clash is on your $PATH.",
style::dim("->"),
);
}
}
let (plugin_ok, bypass_ok) = check_plugin_and_bypass();
if plugin_ok {
println!(
" {} {}: installed",
style::green_bold("PASS"),
style::bold("Claude Code plugin"),
);
already_ok += 1;
} else {
println!(
" {} {}: not installed",
style::red_bold("FAIL"),
style::bold("Claude Code plugin"),
);
if offer_fix(OnboardFix::InstallPlugin)? {
ui::progress("Installing plugin...");
match super::init::install_plugin() {
Ok(()) => fixed += 1,
Err(e) => {
warn!(error = %e, "Plugin install failed during onboard");
ui::fail(&format!("Could not install plugin: {e}"));
}
}
}
}
if bypass_ok {
println!(
" {} {}: configured",
style::green_bold("PASS"),
style::bold("bypassPermissions"),
);
already_ok += 1;
} else {
println!(
" {} {}: not set",
style::red_bold("FAIL"),
style::bold("bypassPermissions"),
);
if offer_fix(OnboardFix::ConfigureBypass)? {
ui::progress("Configuring bypassPermissions...");
match super::init::set_bypass_permissions() {
Ok(()) => fixed += 1,
Err(e) => {
warn!(error = %e, "bypassPermissions config failed during onboard");
ui::fail(&format!("Could not configure bypassPermissions: {e}"));
}
}
}
}
let policy_check = check_policy_files();
policy_check.print("Policy files");
match &policy_check {
CheckResult::Fail(_) => {
if offer_fix(OnboardFix::CreatePolicy)? {
ui::progress("Launching policy wizard...");
match crate::cmd::wizard::wiz() {
Ok(()) => fixed += 1,
Err(e) => {
warn!(error = %e, "Policy wizard failed during onboard");
ui::fail(&format!("Policy creation failed: {e}"));
}
}
}
}
_ => already_ok += 1,
}
let statusline_installed = check_statusline_installed();
if statusline_installed {
println!(
" {} {}: installed",
style::green_bold("PASS"),
style::bold("Status line"),
);
already_ok += 1;
} else {
println!(
" {} {}: not installed (optional)",
style::green_bold("PASS"),
style::bold("Status line"),
);
if offer_fix(OnboardFix::InstallStatusLine)? {
ui::progress("Installing status line...");
match super::statusline::install() {
Ok(()) => fixed += 1,
Err(e) => {
warn!(error = %e, "Status line install failed during onboard");
ui::fail(&format!("Could not install status line: {e}"));
}
}
}
}
check_disabled().print("Disabled");
check_passthrough().print("Passthrough");
check_sandbox_support().print("Sandbox support");
println!();
if fixed > 0 {
println!(
" {} Fixed {} issue(s). {} already OK.",
style::green_bold("OK"),
fixed,
already_ok,
);
} else {
println!(
" {} All checks passed.",
style::green_bold("OK"),
);
}
println!(
"\n Run {} to see your active policy.",
style::bold("`clash status`"),
);
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 during onboard");
}
Ok(())
}
fn offer_fix(fix: OnboardFix) -> Result<bool> {
let (prompt, default_yes) = match fix {
OnboardFix::InstallPlugin => ("Install now?", true),
OnboardFix::ConfigureBypass => ("Configure now?", true),
OnboardFix::CreatePolicy => ("Create a starter policy?", true),
OnboardFix::InstallStatusLine => ("Install status line?", false),
};
let result = dialoguer::Confirm::new()
.with_prompt(format!(" {} {}", style::dim("->"), prompt))
.default(default_yes)
.interact();
match result {
Ok(answer) => Ok(answer),
Err(_) => {
ui::skip("Skipping (non-interactive)");
Ok(false)
}
}
}
fn check_plugin_and_bypass() -> (bool, bool) {
let claude = claude_settings::ClaudeSettings::new();
let settings = match claude.read(claude_settings::SettingsLevel::User) {
Ok(Some(s)) => s,
_ => return (false, false),
};
let plugin_ok = settings
.hooks
.as_ref()
.is_some_and(hooks_reference_clash)
|| settings
.enabled_plugins
.as_ref()
.and_then(|p| p.get("clash").copied())
== Some(true);
let bypass_ok = settings.bypass_permissions == Some(true);
(plugin_ok, bypass_ok)
}
fn check_statusline_installed() -> bool {
let cs = claude_settings::ClaudeSettings::new();
let current = match cs.read_or_default(claude_settings::SettingsLevel::User) {
Ok(s) => s,
Err(_) => return false,
};
current
.extra
.get("statusLine")
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str())
.is_some_and(|c| c.contains("clash statusline"))
}
fn check_disabled() -> CheckResult {
if crate::settings::is_disabled() {
CheckResult::Warn(format!(
"{} is set — all hooks are pass-through. Unset to re-enable.",
crate::settings::CLASH_DISABLE_ENV,
))
} else {
CheckResult::Pass("Clash is enabled.".into())
}
}
fn check_passthrough() -> CheckResult {
if crate::settings::is_passthrough() {
CheckResult::Warn(format!(
"{} is set — permission decisions are deferred to Claude Code's native system. \
Unset to re-enable policy enforcement.",
crate::settings::CLASH_PASSTHROUGH_ENV,
))
} else {
CheckResult::Pass("Policy enforcement is active.".into())
}
}
fn check_policy_files() -> CheckResult {
let levels = ClashSettings::available_policy_levels();
if levels.is_empty() {
return CheckResult::Fail("No policy files found. Run `clash init` to create one.".into());
}
let names: Vec<String> = levels
.iter()
.map(|(level, path)| format!("{} ({})", level, path.display()))
.collect();
CheckResult::Pass(format!("Found: {}", names.join(", ")))
}
fn check_policy_parsing() -> CheckResult {
let levels = ClashSettings::available_policy_levels();
if levels.is_empty() {
return CheckResult::Warn("No policy files to parse (none found).".into());
}
let mut errors = Vec::new();
for (level, path) in &levels {
match crate::settings::evaluate_policy_file(path) {
Ok(json) => {
if let Err(e) = crate::policy::compile::compile_to_tree(&json) {
errors.push(format!("{}: {}", level, e));
}
}
Err(e) => {
errors.push(format!("{}: {}", level, e));
}
}
}
if errors.is_empty() {
CheckResult::Pass("All policy files parse and compile successfully.".into())
} else {
CheckResult::Fail(format!(
"Policy errors:\n{}",
errors
.iter()
.map(|e| format!(" {}", e))
.collect::<Vec<_>>()
.join("\n")
))
}
}
fn check_plugin_installed() -> CheckResult {
let claude = claude_settings::ClaudeSettings::new();
let settings = match claude.read(claude_settings::SettingsLevel::User) {
Ok(Some(s)) => s,
Ok(None) => {
return CheckResult::Warn(
"No Claude Code user settings found. \
Run `clash init` to install the plugin."
.into(),
);
}
Err(e) => {
return CheckResult::Warn(format!(
"Could not read Claude Code settings: {}. \
Run `clash init` to install the plugin.",
e
));
}
};
if let Some(ref hooks) = settings.hooks
&& hooks_reference_clash(hooks)
{
return if settings.bypass_permissions == Some(true) {
CheckResult::Pass("clash hooks are registered and bypassPermissions is enabled.".into())
} else {
CheckResult::Warn(
"clash hooks are registered but bypassPermissions is not set. \
You may see double permission prompts. \
Fix: run `clash init` or set bypassPermissions in Claude Code settings."
.into(),
)
};
}
if let Some(ref plugins) = settings.enabled_plugins
&& plugins.get("clash").copied() == Some(true)
{
return if settings.bypass_permissions == Some(true) {
CheckResult::Pass("clash plugin is enabled and bypassPermissions is set.".into())
} else {
CheckResult::Warn(
"clash plugin is enabled but bypassPermissions is not set. \
Fix: run `clash init` or set bypassPermissions in Claude Code settings."
.into(),
)
};
}
CheckResult::Fail(
"clash is not registered as a Claude Code plugin. \
Fix: run `clash init` to install and configure."
.into(),
)
}
fn hooks_reference_clash(hooks: &claude_settings::Hooks) -> bool {
let configs = [
hooks.pre_tool_use.as_ref(),
hooks.post_tool_use.as_ref(),
hooks.notification.as_ref(),
];
for config in configs.into_iter().flatten() {
match config {
claude_settings::HookConfig::Simple(map) => {
for cmd in map.values() {
if cmd.contains("clash") {
return true;
}
}
}
claude_settings::HookConfig::Matchers(matchers) => {
for matcher in matchers {
for hook in &matcher.hooks {
if let Some(ref cmd) = hook.command
&& cmd.contains("clash")
{
return true;
}
}
}
}
}
}
false
}
fn check_binary_on_path() -> CheckResult {
match which_clash() {
Some(path) => CheckResult::Pass(format!("Found at {}", path)),
None => CheckResult::Fail(
"clash not found on PATH. \
Ensure the clash binary is installed and in your $PATH."
.into(),
),
}
}
fn which_clash() -> Option<String> {
std::process::Command::new("which")
.arg("clash")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
}
fn check_file_permissions() -> CheckResult {
let levels = ClashSettings::available_policy_levels();
if levels.is_empty() {
return CheckResult::Warn("No policy files to check permissions on.".into());
}
let mut issues = Vec::new();
for (level, path) in &levels {
match std::fs::metadata(path) {
Ok(metadata) => {
if metadata.is_dir() {
issues.push(format!(
"{}: {} is a directory, not a file. \
Remove it and run `clash init`.",
level,
path.display()
));
continue;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = metadata.permissions().mode() & 0o777;
if mode & 0o077 != 0 {
issues.push(format!(
"{}: {} has mode {:o} (world/group accessible). \
Fix: chmod 600 {}",
level,
path.display(),
mode,
path.display()
));
}
}
}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
issues.push(format!(
"{}: {} is not readable. Fix: chmod 600 {}",
level,
path.display(),
path.display()
));
}
Err(e) => {
issues.push(format!("{}: cannot stat {} — {}", level, path.display(), e));
}
}
}
if issues.is_empty() {
CheckResult::Pass("All policy files have appropriate permissions.".into())
} else {
CheckResult::Warn(format!(
"Permission issues:\n{}",
issues
.iter()
.map(|i| format!(" {}", i))
.collect::<Vec<_>>()
.join("\n")
))
}
}
fn check_sandbox_support() -> CheckResult {
match sandbox::check_support() {
sandbox::SupportLevel::Full => {
let backend = if cfg!(target_os = "macos") {
"seatbelt"
} else if cfg!(target_os = "linux") {
"landlock"
} else {
"unknown"
};
CheckResult::Pass(format!("Fully supported ({backend})."))
}
sandbox::SupportLevel::Partial { missing } => CheckResult::Warn(format!(
"Partially supported. Missing: {}",
missing.join(", ")
)),
sandbox::SupportLevel::Unsupported { reason } => CheckResult::Warn(format!(
"Not supported on this platform: {}. \
Sandbox enforcement will be skipped.",
reason
)),
}
}
#[allow(dead_code)]
fn check_settings_dir() -> CheckResult {
match ClashSettings::settings_dir() {
Ok(dir) if dir.exists() => CheckResult::Pass(format!("Found at {}", dir.display())),
Ok(dir) => CheckResult::Fail(format!(
"{} does not exist. Run `clash init` to create it.",
dir.display()
)),
Err(e) => CheckResult::Fail(format!("Cannot determine settings directory: {}", e)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_result_pass_is_not_fail() {
let r = CheckResult::Pass("ok".into());
assert!(!r.is_fail());
assert!(!r.is_warn());
}
#[test]
fn check_result_warn_is_warn() {
let r = CheckResult::Warn("warning".into());
assert!(!r.is_fail());
assert!(r.is_warn());
}
#[test]
fn check_result_fail_is_fail() {
let r = CheckResult::Fail("error".into());
assert!(r.is_fail());
assert!(!r.is_warn());
}
#[test]
fn which_clash_returns_some_when_on_path() {
let result = which_clash();
let _ = result;
}
#[test]
fn hooks_reference_clash_detects_matcher_hooks() {
use claude_settings::{Hook, HookConfig, HookMatcher, Hooks};
let hooks = Hooks {
pre_tool_use: Some(HookConfig::Matchers(vec![HookMatcher {
matcher: "*".into(),
hooks: vec![Hook {
hook_type: "command".into(),
command: Some("clash hook pre-tool-use".into()),
timeout: None,
}],
}])),
post_tool_use: None,
stop: None,
notification: None,
};
assert!(hooks_reference_clash(&hooks));
}
#[test]
fn hooks_reference_clash_returns_false_for_unrelated_hooks() {
use claude_settings::{Hook, HookConfig, HookMatcher, Hooks};
let hooks = Hooks {
pre_tool_use: Some(HookConfig::Matchers(vec![HookMatcher {
matcher: "*".into(),
hooks: vec![Hook {
hook_type: "command".into(),
command: Some("other-tool hook".into()),
timeout: None,
}],
}])),
post_tool_use: None,
stop: None,
notification: None,
};
assert!(!hooks_reference_clash(&hooks));
}
#[test]
fn hooks_reference_clash_handles_empty_hooks() {
let hooks = claude_settings::Hooks {
pre_tool_use: None,
post_tool_use: None,
stop: None,
notification: None,
};
assert!(!hooks_reference_clash(&hooks));
}
#[test]
fn check_sandbox_does_not_panic() {
let result = check_sandbox_support();
assert!(!result.is_fail());
}
#[test]
fn check_binary_on_path_does_not_panic() {
let _ = check_binary_on_path();
}
#[test]
fn check_plugin_and_bypass_does_not_panic() {
let (plugin, bypass) = check_plugin_and_bypass();
let _ = (plugin, bypass);
}
#[test]
fn check_statusline_installed_does_not_panic() {
let _ = check_statusline_installed();
}
}