mod audit;
mod backup;
mod context;
mod hook;
mod init;
mod intent;
mod notify;
mod paths;
mod pg;
mod policy;
mod preview;
mod report;
mod runner;
mod shell;
use anyhow::{bail, Result};
use clap::{Parser, Subcommand};
use policy::Policy;
#[derive(Parser)]
#[command(
name = "termaxa",
version,
about = "Execution gate for AI coding agents: policy, previews, automatic backups, audit",
after_help = "EXAMPLES:\n termaxa init --claude-code wire up Claude Code (also: --cursor --codex --copilot)\n termaxa check \"git push --force\" dry-run a command against policy\n termaxa run -- terraform apply gated execution with preview + backup\n termaxa log --decision deny what got blocked\n termaxa report summarize the last AI session\n\nDOCS: https://github.com/termaxa/termaxa"
)]
struct Cli {
#[command(subcommand)]
command: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Init {
#[arg(long = "claude-code")]
claude_code: bool,
#[arg(long = "cursor")]
cursor: bool,
#[arg(long = "codex")]
codex: bool,
#[arg(long = "copilot")]
copilot: bool,
},
Check {
command: Vec<String>,
},
Hook,
Run {
#[arg(last = true)]
argv: Vec<String>,
},
Log {
#[arg(short, long, default_value_t = 20)]
n: usize,
#[arg(long)]
decision: Option<String>,
#[arg(long)]
source: Option<String>,
#[arg(long)]
json: bool,
},
Notify {
#[arg(long)]
test: bool,
},
Stats,
Backups,
Rollback { id: String },
Paths,
Report {
#[arg(long)]
session: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
md: bool,
},
}
fn main() {
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
let cli = Cli::parse();
let code = match dispatch(cli) {
Ok(code) => code,
Err(e) => {
eprintln!("termaxa: {:#}", e);
2
}
};
std::process::exit(code);
}
fn dispatch(cli: Cli) -> Result<i32> {
match cli.command {
Cmd::Init {
claude_code,
cursor,
codex,
copilot,
} => {
init::run(
&std::env::current_dir()?,
claude_code,
cursor,
codex,
copilot,
)?;
Ok(0)
}
Cmd::Check { command } => {
let cmd = command.join(" ");
if cmd.trim().is_empty() {
bail!("usage: termaxa check \"<command>\"");
}
let resolved = paths::resolve().ok();
let (policy, state_dir) = match &resolved {
Some(p) => (Policy::load(&p.policy_file())?, p.state_dir.clone()),
None => {
eprintln!("ℹ Demo mode — no project policy found.");
eprintln!("ℹ Using Termaxa's built-in starter policy (read-only).");
eprintln!(
"ℹ Run `termaxa init` to create a policy you can review and customize.\n"
);
(Policy::builtin()?, paths::demo_state_dir()?)
}
};
let base = policy.evaluate_command(&cmd);
let signals = context::gather(&cmd);
let (decision, escalated) = context::apply(base, &signals);
println!("command : {}", cmd);
println!("decision: {}", decision.action);
if let Some(rule) = &decision.matched_rule {
println!("rule : {}", rule);
}
println!("reason : {}", decision.reason);
for s in &signals {
println!(
"context : {}{}",
s.label,
if s.escalate { " ⚠" } else { "" }
);
}
if escalated {
println!("note : context escalated allow → ask");
}
let mut preview_summary = None;
if let Some(pv) = preview::generate(&cmd) {
println!("\npreview : {}", pv.title);
for l in &pv.lines {
println!("{}", l);
}
preview_summary = Some(pv.summary);
}
let log = audit::AuditLog::new(&state_dir)?;
let (ts_ms, ts) = audit::now();
log.append(&audit::AuditEntry {
ts_ms,
ts,
source: "check".into(),
command: cmd.clone(),
decision: decision.action.to_string(),
matched_rule: decision.matched_rule.clone(),
reason: decision.reason.clone(),
signals: signals.iter().map(|s| s.label.clone()).collect(),
escalated,
session: None,
backup: None,
preview: preview_summary,
intent: intent::classify_command(&cmd).map(|i| i.label().to_string()),
approved: None,
exit_code: None,
cwd: std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_default(),
})?;
Ok(match decision.action {
policy::Action::Allow => 0,
policy::Action::Ask => 3,
policy::Action::Deny => 4,
})
}
Cmd::Hook => {
hook::run()?;
Ok(0)
}
Cmd::Run { argv } => {
let p = paths::resolve()?;
runner::run(&p, &argv)
}
Cmd::Log {
n,
decision,
source,
json,
} => {
let p = paths::resolve()?;
let log = audit::AuditLog::new(&p.state_dir)?;
let entries: Vec<_> = log
.read_last(100_000)?
.into_iter()
.filter(|e| decision.as_deref().map_or(true, |d| e.decision == d))
.filter(|e| source.as_deref().map_or(true, |s| e.source == s))
.collect();
let skip = entries.len().saturating_sub(n);
let entries: Vec<_> = entries.into_iter().skip(skip).collect();
if json {
for e in &entries {
println!("{}", serde_json::to_string(e)?);
}
return Ok(0);
}
if entries.is_empty() {
println!("(audit log is empty)");
return Ok(0);
}
for e in entries {
let mark = match e.decision.as_str() {
"allow" => "✓",
"ask" => "?",
_ => "✗",
};
let outcome = match (e.approved, e.exit_code) {
(Some(true), Some(code)) => format!(" → approved, exit {}", code),
(Some(false), _) => " → not run".to_string(),
(None, Some(code)) => format!(" → exit {}", code),
_ => String::new(),
};
let sess = e
.session
.as_deref()
.map(|s| format!(" ({})", &s[..s.len().min(8)]))
.unwrap_or_default();
println!(
"{} {} [{}{}] {} — {}{}{}",
e.ts,
mark,
e.source,
sess,
e.command,
e.reason,
if e.escalated { " ⚠ escalated" } else { "" },
outcome
);
}
Ok(0)
}
Cmd::Notify { test } => {
let p = paths::resolve()?;
let policy = Policy::load(&p.policy_file())?;
if test {
notify::test(&policy)
} else {
println!("usage: termaxa notify --test");
Ok(1)
}
}
Cmd::Stats => {
let p = paths::resolve()?;
let log = audit::AuditLog::new(&p.state_dir)?;
let entries = log.read_last(1_000_000)?;
if entries.is_empty() {
println!("(audit log is empty)");
return Ok(0);
}
let total = entries.len();
let count =
|f: &dyn Fn(&audit::AuditEntry) -> bool| entries.iter().filter(|e| f(e)).count();
println!("entries : {}", total);
println!(" allow : {}", count(&|e| e.decision == "allow"));
println!(" ask : {}", count(&|e| e.decision == "ask"));
println!(" deny : {}", count(&|e| e.decision == "deny"));
println!(
"by source : hook {} / run {} / check {}",
count(&|e| e.source == "hook"),
count(&|e| e.source == "run"),
count(&|e| e.source == "check")
);
println!("escalated : {}", count(&|e| e.escalated));
let sessions: std::collections::HashSet<_> = entries
.iter()
.filter_map(|e| e.session.as_deref())
.collect();
println!("sessions : {}", sessions.len());
let mut denied: std::collections::HashMap<&str, usize> =
std::collections::HashMap::new();
for e in entries.iter().filter(|e| e.decision == "deny") {
*denied.entry(e.command.as_str()).or_default() += 1;
}
let mut top: Vec<_> = denied.into_iter().collect();
top.sort_by(|a, b| b.1.cmp(&a.1));
if !top.is_empty() {
println!("top denied :");
for (cmd, n) in top.into_iter().take(5) {
println!(" {}× {}", n, cmd);
}
}
Ok(0)
}
Cmd::Backups => {
let p = paths::resolve()?;
let records = backup::list(&p.state_dir)?;
if records.is_empty() {
println!("(no backups yet)");
return Ok(0);
}
for r in records {
println!(
"{} {} [{}] {}\n insures: {}",
r.id, r.ts, r.kind, r.note, r.command
);
}
Ok(0)
}
Cmd::Report { session, all, md } => {
let p = paths::resolve()?;
report::run(&p, report::Scope { session, all }, md)
}
Cmd::Paths => {
let p = paths::resolve()?;
println!("policy : {}", p.policy_file().display());
println!("state : {}", p.state_dir.display());
println!(
"logs : {}",
p.state_dir.join("logs").join("audit.jsonl").display()
);
println!("backups: {}", p.state_dir.join("backups").display());
Ok(0)
}
Cmd::Rollback { id } => {
let p = paths::resolve()?;
let records = backup::list(&p.state_dir)?;
let Some(rec) = records.iter().find(|r| r.id == id) else {
bail!("no backup with id `{}` — see `termaxa backups`", id);
};
println!("restore : {} [{}]", rec.id, rec.kind);
println!("saved : {}", rec.note);
println!("insured : {}", rec.command);
print!("Restoring writes data. Proceed? [y/N] ");
use std::io::Write as _;
std::io::stdout().flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
if !matches!(line.trim().to_lowercase().as_str(), "y" | "yes") {
eprintln!("termaxa: rollback declined.");
return Ok(1);
}
let msg = backup::restore(&p.state_dir, &id)?;
println!("✓ {}", msg);
Ok(0)
}
}
}