use std::io::{self, IsTerminal, Read, Write};
use std::process;
use clap::{CommandFactory, Parser};
use safe_chains::cli::{Cli, Subcommand};
use safe_chains::targets::{self, HookFormat};
use safe_chains::verdict::{SafetyLevel, Verdict};
fn print_docs() {
let docs = safe_chains::docs::all_command_docs();
print!("{}", safe_chains::docs::render_markdown(&docs));
}
fn run_cli(
command: &str,
threshold: SafetyLevel,
engine_level: Option<&'static safe_chains::engine::level::Level>,
) {
let verdict = safe_chains::command_verdict_ceilinged(command, threshold, engine_level);
process::exit(i32::from(!verdict.is_allowed()));
}
fn run_explain(command: &str) -> ! {
let explanation = safe_chains::cst::explain(command);
print!("{}", explanation.render());
print!("{}", safe_chains::facet_breakdown(command));
process::exit(i32::from(!explanation.is_allowed()));
}
fn run_suggest(command: &str) -> ! {
use safe_chains::suggest::{self, Outcome};
const DOCS: &str = "https://www.michaeldhopkins.com/docs/safe-chains/custom-commands.html";
match suggest::analyze(command) {
Outcome::AlreadyAllowed => {
println!("safe-chains already auto-approves this command — nothing to add.");
process::exit(0);
}
Outcome::Unparseable => {
eprintln!(
"safe-chains couldn't parse this command, so a command definition can't help — \
check the quoting."
);
process::exit(1);
}
Outcome::RecognizedButDenied { names } => {
eprintln!(
"Every command here is one safe-chains already recognizes ({}). It isn't \
auto-approving because of HOW it's used — a flag, subcommand, or path — not because \
the command is unknown, so --suggest won't generate an override. See {DOCS}.",
names.join(", ")
);
process::exit(1);
}
Outcome::Generated { entries, also_recognized } => {
emit_suggestion(&entries, &also_recognized);
}
}
}
fn repo_config_path() -> std::path::PathBuf {
const REPO_FILENAME: &str = ".safe-chains.toml";
let Ok(start) = std::env::current_dir() else {
return std::path::PathBuf::from(REPO_FILENAME);
};
let mut dir = start.clone();
loop {
let candidate = dir.join(REPO_FILENAME);
if candidate.is_file() {
return candidate;
}
if !dir.pop() {
return start.join(REPO_FILENAME);
}
}
}
fn emit_suggestion(
entries: &[safe_chains::suggest::GeneratedEntry],
also_recognized: &[String],
) -> ! {
use safe_chains::suggest;
let target = repo_config_path();
let existing = std::fs::read_to_string(&target).unwrap_or_default();
let merged = suggest::merged_content(&existing, entries);
let hash = suggest::config_hash(merged.as_bytes());
let block = suggest::render_toml(entries);
let dir = target.parent().unwrap_or_else(|| std::path::Path::new("."));
let canonical = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
let pin = suggest::pin_block(&canonical.to_string_lossy(), &hash);
match std::fs::write(&target, &merged) {
Ok(()) => {
println!("Added this to {}:\n\n{block}", target.display());
println!(
"That file does nothing until you approve it. Add this to ~/.config/safe-chains.toml \
(which safe-chains never edits):\n\n{pin}"
);
println!(
"The level defaults to \"SafeWrite\" — edit it to \"SafeRead\" (runs code, no \
artifacts) or \"Inert\" (read-only) if that fits the tool. Any later edit to \
{} changes its hash: recompute with `shasum -a 256 {}` and update the pin.",
target.display(),
target.display(),
);
if !also_recognized.is_empty() {
println!(
"\nHeads up: this command also uses commands safe-chains already recognizes \
({}). The entry above only covers the unrecognized one(s), so if the whole \
command still isn't approved, one of those is why.",
also_recognized.join(", ")
);
}
process::exit(0);
}
Err(e) => {
eprintln!(
"Couldn't write {} ({e}). Add this block to a `.safe-chains.toml` yourself:\n\n{block}\n\
then pin it in ~/.config/safe-chains.toml:\n\n{pin}",
target.display()
);
process::exit(1);
}
}
}
fn run_setup(name: Option<String>, auto_detect: bool) -> ! {
let Some(home) = std::env::var_os("HOME") else {
eprintln!("Error: HOME environment variable not set");
process::exit(1);
};
let home = std::path::PathBuf::from(home);
if auto_detect {
let detected = targets::detect_installed(&home);
if detected.is_empty() {
eprintln!(
"No supported tools detected on this machine. Run with --list-tools to see candidates."
);
process::exit(1);
}
let mut any_failed = false;
for target in detected {
match target.install(&home) {
Ok(outcome) => println!("{}", outcome.message(target.display_name())),
Err(e) => {
eprintln!("{}: {e}", target.display_name());
any_failed = true;
}
}
}
process::exit(i32::from(any_failed));
}
let target_name = name.as_deref().unwrap_or("claude");
let Some(target) = targets::find(target_name) else {
eprintln!("Unknown tool: {target_name}. Run with --list-tools to see candidates.");
process::exit(1);
};
match target.install(&home) {
Ok(outcome) => {
println!("{}", outcome.message(target.display_name()));
process::exit(0);
}
Err(e) => {
eprintln!("{}: {e}", target.display_name());
process::exit(1);
}
}
}
fn run_list_tools() -> ! {
for target in targets::registry() {
println!("{}\t{}", target.name(), target.display_name());
}
process::exit(0);
}
fn run_hook_for(target_name: &str) -> ! {
let Some(target) = targets::find(target_name) else {
eprintln!("Unknown tool: {target_name}. Run with --list-tools to see candidates.");
process::exit(1);
};
let Some(format) = target.hook_format() else {
eprintln!(
"{}: this target does not use a runtime hook (config-only integration).",
target.display_name()
);
process::exit(1);
};
run_hook_format(format);
}
fn run_hook_format(format: &dyn HookFormat) -> ! {
let mut buf = String::new();
if io::stdin().read_to_string(&mut buf).is_err() {
process::exit(0);
}
let Ok(input) = format.parse_input(&buf) else {
process::exit(0);
};
let _ctx = safe_chains::pathctx::enter(safe_chains::pathctx::PathCtx {
cwd: input.cwd.clone(),
root: input.root.clone().or_else(|| input.cwd.clone()),
session_id: input.session_id.clone(),
});
let (threshold, engine_level) = safe_chains::configured_hook_ceiling();
let verdict = safe_chains::command_verdict_ceilinged(&input.command, threshold, engine_level);
if verdict.is_allowed() {
let response = format.render_response(verdict);
let _ = io::stdout().write_all(response.stdout.as_bytes());
process::exit(response.exit_code);
}
let explanation = safe_chains::explain_with_coverage_at_level(&input.command, engine_level);
if let Verdict::Allowed(level) = explanation.overall
&& level <= threshold
{
let response = format.render_response(Verdict::Allowed(level));
let _ = io::stdout().write_all(response.stdout.as_bytes());
process::exit(response.exit_code);
}
const DOCS_URL: &str = "https://www.michaeldhopkins.com/docs/safe-chains/how-it-works.html";
let overreach = safe_chains::workspace_overreach(&input.command);
let overreach_why = overreach.as_ref().map(|(path, reason)| reason.message(path));
match format.gated_policy() {
safe_chains::targets::GatedPolicy::Deny => {
let reason = match &overreach_why {
Some(why) => format!(
"safe-chains blocked this: {why}. This harness has no interactive approval. {DOCS_URL}"
),
None => format!(
"safe-chains blocked this: it is not on the allowlist and this harness has no \
interactive approval. To allow it, add a custom command or a grant to \
~/.config/safe-chains.toml. {DOCS_URL}"
),
};
let response = format.render_deny(&reason);
let _ = io::stdout().write_all(response.stdout.as_bytes());
process::exit(response.exit_code);
}
safe_chains::targets::GatedPolicy::Ask => {
let reason = match &overreach_why {
Some(why) => format!(
"safe-chains did not auto-approve this — please confirm: {why}. {DOCS_URL}"
),
None => "safe-chains did not auto-approve this command — please confirm. (Add it to \
~/.config/safe-chains.toml so safe-chains stops flagging it.)"
.to_string(),
};
let response = format.render_ask(&reason);
let _ = io::stdout().write_all(response.stdout.as_bytes());
process::exit(response.exit_code);
}
safe_chains::targets::GatedPolicy::Defer => {}
}
if explanation.should_surface() {
let response = format.render_context(&explanation.render());
let _ = io::stdout().write_all(response.stdout.as_bytes());
process::exit(response.exit_code);
}
if let Some((path, reason)) = overreach {
let nudge = format!(
"safe-chains did not auto-approve this: {}. {DOCS_URL}",
reason.message(&path)
);
let response = format.render_context(&nudge);
let _ = io::stdout().write_all(response.stdout.as_bytes());
process::exit(response.exit_code);
}
process::exit(0);
}
fn main() {
let cli = Cli::try_parse();
match cli {
Ok(cli) => {
if let Some(Subcommand::Hook { tool }) = cli.subcommand {
run_hook_for(&tool);
}
if cli.list_tools {
run_list_tools();
}
if cli.setup {
run_setup(cli.tool, cli.auto_detect);
}
if cli.list_commands {
print_docs();
} else if cli.generate_book {
let docs = safe_chains::docs::all_command_docs();
safe_chains::docs::render_book(&docs, std::path::Path::new("docs"));
} else if let Some(command) = cli.command {
let _ctx = safe_chains::pathctx::enter(safe_chains::pathctx::PathCtx {
cwd: cli.cwd,
root: cli.root,
session_id: cli.session_id,
});
if cli.explain {
run_explain(&command);
}
if cli.suggest {
run_suggest(&command);
}
let (threshold, engine_level) = match cli.level.as_deref() {
None => (SafetyLevel::SafeWrite, None), Some(name) => {
if let Some((_, Some(current))) = SafetyLevel::resolve_threshold(name) {
eprintln!(
"note: '--level {name}' is a legacy level name — mapping to \
'{current}'. Current levels: paranoid, reader, editor, \
developer, local-admin, network-admin, yolo."
);
}
match safe_chains::level_ceiling(name) {
Some(pair) => pair,
None => {
eprintln!(
"Error: unknown --level '{name}'. Levels: paranoid, reader, editor, \
developer, local-admin, network-admin, yolo (legacy: inert, \
safe-read, safe-write)."
);
process::exit(2);
}
}
}
};
run_cli(&command, threshold, engine_level);
} else if io::stdin().is_terminal() {
Cli::command().print_help().ok();
println!();
process::exit(2);
} else {
let claude = targets::find("claude").expect("claude target registered");
let format = claude
.hook_format()
.expect("claude target has a hook format");
run_hook_format(format);
}
}
Err(e) => e.exit(),
}
}