mod admin_cmd;
mod init;
mod logview;
mod model_cmd;
mod record;
mod service;
use std::io::IsTerminal;
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use kintsugi_core::{Class, Decision, EventLog, ProposedCommand, Verdict};
use kintsugi_daemon::{default_db_path, ipc, Client};
#[derive(Debug, Parser)]
#[command(name = "kintsugi", version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
enum Command {
Init {
#[arg(long)]
no_daemon: bool,
#[arg(long)]
print_path: bool,
#[arg(long)]
enterprise: bool,
},
Status,
Stop,
Admin {
#[command(subcommand)]
cmd: AdminCmd,
},
Service {
#[command(subcommand)]
cmd: ServiceCmd,
},
Update {
#[arg(long)]
check: bool,
#[arg(long, short = 'y')]
yes: bool,
},
Log {
#[arg(short = 'n', long, default_value_t = 20)]
number: usize,
#[arg(short = 'p', long, default_value_t = 1)]
page: usize,
#[arg(long)]
show_redacted: bool,
#[command(flatten)]
filter: FilterArgs,
},
Redact {
id: Option<String>,
#[arg(long, default_value = "redacted by user")]
reason: String,
#[command(flatten)]
filter: FilterArgs,
},
Purge {
#[arg(long)]
yes: bool,
#[arg(long, default_value = "purged by user")]
reason: String,
#[command(flatten)]
filter: FilterArgs,
},
Undo {
#[arg(long)]
session: bool,
},
Watch {
#[arg(required = true)]
paths: Vec<PathBuf>,
},
Tui,
Test {
command: String,
},
Queue,
Approve {
id: String,
},
Deny {
id: String,
},
Run {
id: Option<String>,
},
Panic,
Resume,
Record {
#[command(subcommand)]
cmd: RecordCmd,
},
Ingest {
command: String,
#[arg(long)]
cwd: Option<PathBuf>,
},
Model {
#[command(subcommand)]
cmd: ModelCmd,
},
Report {
#[arg(long)]
catastrophic_only: bool,
#[arg(short = 'n', long, default_value_t = 50)]
number: usize,
#[command(flatten)]
filter: FilterArgs,
},
}
#[derive(Debug, Subcommand)]
enum RecordCmd {
Install {
#[arg(long, value_name = "RC_FILE")]
write: Option<PathBuf>,
},
Uninstall {
#[arg(long, value_name = "RC_FILE")]
write: Option<PathBuf>,
},
Status,
}
#[derive(Debug, Subcommand)]
enum ModelCmd {
Status,
Use {
path: PathBuf,
},
Pick,
Install,
Remove,
}
#[derive(Debug, Subcommand)]
enum AdminCmd {
Provision {
#[arg(long)]
password_file: Option<std::path::PathBuf>,
#[arg(long)]
force: bool,
},
Status,
ChangePassword,
Settings {
#[arg(long)]
password_file: Option<std::path::PathBuf>,
},
Set {
key: String,
value: String,
#[arg(long)]
password_file: Option<std::path::PathBuf>,
},
}
#[derive(Debug, Subcommand)]
enum ServiceCmd {
Install,
Uninstall,
Status,
}
#[derive(Debug, Clone, clap::Args)]
struct FilterArgs {
#[arg(long)]
agent: Option<String>,
#[arg(long)]
session: Option<String>,
#[arg(long)]
class: Option<String>,
#[arg(long)]
grep: Option<String>,
#[arg(long)]
since: Option<String>,
#[arg(long)]
before: Option<String>,
}
impl FilterArgs {
fn is_empty(&self) -> bool {
self.agent.is_none()
&& self.session.is_none()
&& self.class.is_none()
&& self.grep.is_none()
&& self.since.is_none()
&& self.before.is_none()
}
fn to_filter(
&self,
include_redacted: bool,
limit: Option<usize>,
) -> Result<kintsugi_core::Filter> {
self.to_filter_paged(include_redacted, limit, None)
}
fn to_filter_paged(
&self,
include_redacted: bool,
limit: Option<usize>,
offset: Option<usize>,
) -> Result<kintsugi_core::Filter> {
let class = match self.class.as_deref() {
None => None,
Some("safe") => Some(kintsugi_core::Class::Safe),
Some("ambiguous") => Some(kintsugi_core::Class::Ambiguous),
Some("catastrophic") => Some(kintsugi_core::Class::Catastrophic),
Some(other) => anyhow::bail!("unknown class '{other}' (safe|ambiguous|catastrophic)"),
};
Ok(kintsugi_core::Filter {
agent: self.agent.clone(),
session: self.session.clone(),
class,
grep: self.grep.clone(),
since: self.since.as_deref().map(parse_instant).transpose()?,
until: self.before.as_deref().map(parse_instant).transpose()?,
include_redacted,
limit,
offset,
})
}
}
fn parse_instant(s: &str) -> Result<time::OffsetDateTime> {
use time::{Duration, OffsetDateTime};
let now = OffsetDateTime::now_utc();
let ago = |d: Duration| now - d;
let s = s.trim();
let parsed = match s {
"day" => ago(Duration::days(1)),
"week" => ago(Duration::weeks(1)),
"month" => ago(Duration::days(30)),
other => {
if let Some(n) = other.strip_suffix('d').and_then(|n| n.parse::<i64>().ok()) {
ago(Duration::days(n))
} else if let Some(n) = other.strip_suffix('h').and_then(|n| n.parse::<i64>().ok()) {
ago(Duration::hours(n))
} else {
OffsetDateTime::parse(other, &time::format_description::well_known::Rfc3339)
.with_context(|| {
format!("invalid time '{other}' (RFC3339 or day|week|month|<N>d|<N>h)")
})?
}
}
};
Ok(parsed)
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
None => cmd_banner(),
Some(Command::Init {
no_daemon,
print_path,
enterprise,
}) => {
if print_path {
println!("export PATH=\"{}:$PATH\"", shim_dir().display());
Ok(())
} else {
cmd_init(no_daemon, enterprise)
}
}
Some(Command::Status) => cmd_status(),
Some(Command::Stop) => cmd_stop(),
Some(Command::Admin { cmd }) => match cmd {
AdminCmd::Provision {
password_file,
force,
} => admin_cmd::provision(password_file, force),
AdminCmd::Status => admin_cmd::status(),
AdminCmd::ChangePassword => admin_cmd::change_password(),
AdminCmd::Settings { password_file } => admin_cmd::settings(password_file),
AdminCmd::Set {
key,
value,
password_file,
} => admin_cmd::set(&key, &value, password_file),
},
Some(Command::Service { cmd }) => match cmd {
ServiceCmd::Install => service::install(),
ServiceCmd::Uninstall => service::uninstall(),
ServiceCmd::Status => service::status(),
},
Some(Command::Update { check, yes }) => cmd_update(check, yes),
Some(Command::Log {
number,
page,
show_redacted,
filter,
}) => cmd_log(number, page, show_redacted, &filter),
Some(Command::Redact { id, reason, filter }) => cmd_redact(id, &reason, &filter),
Some(Command::Purge {
yes,
reason,
filter,
}) => cmd_purge(yes, &reason, &filter),
Some(Command::Undo { session }) => cmd_undo(session),
Some(Command::Watch { paths }) => kintsugi_daemon::watch::run(&paths),
Some(Command::Tui) => kintsugi_tui::run(&default_db_path(), &snapshot_dir()),
Some(Command::Test { command }) => cmd_test(&command),
Some(Command::Queue) => cmd_queue(),
Some(Command::Approve { id }) => cmd_resolve_pending(&id, true),
Some(Command::Deny { id }) => cmd_resolve_pending(&id, false),
Some(Command::Run { id }) => cmd_run(id.as_deref()),
Some(Command::Panic) => cmd_panic(),
Some(Command::Resume) => cmd_resume(),
Some(Command::Record { cmd }) => match cmd {
RecordCmd::Install { write } => record::install(write),
RecordCmd::Uninstall { write } => record::uninstall(write),
RecordCmd::Status => record::status(),
},
Some(Command::Model { cmd }) => match cmd {
ModelCmd::Status => model_cmd::status(),
ModelCmd::Use { path } => model_cmd::use_model(&path),
ModelCmd::Pick => model_cmd::pick(),
ModelCmd::Install => model_cmd::install(),
ModelCmd::Remove => model_cmd::remove(),
},
Some(Command::Ingest { command, cwd }) => record::ingest(&command, cwd),
Some(Command::Report {
catastrophic_only,
number,
filter,
}) => cmd_report(catastrophic_only, number, &filter),
}
}
fn cmd_test(raw: &str) -> Result<()> {
use kintsugi_core::rules;
let m = rules::classify_line(raw);
let decision = rules::decide(m.class, kintsugi_core::Mode::Attended);
let label = match m.class {
kintsugi_core::Class::Catastrophic => "⛔ CATASTROPHIC",
kintsugi_core::Class::Ambiguous => "● AMBIGUOUS",
kintsugi_core::Class::Safe => "✓ SAFE",
};
let outcome = match (m.class, decision) {
(_, kintsugi_core::Decision::Allow) => "allowed — runs normally; recorded on the timeline.",
(kintsugi_core::Class::Catastrophic, _) => {
"blocked — the agent won't run it; you'd run it yourself, reversibly."
}
(_, kintsugi_core::Decision::Hold) => "held — paused for your one-key approval.",
(_, kintsugi_core::Decision::Deny) => "denied.",
};
println!("command: {raw}");
println!("class: {label} (rule: {})", m.rule);
println!("with you: {outcome}");
if let Some(analysis) = kintsugi_core::parse::analyze(raw) {
if analysis.commands.len() > 1
|| analysis
.commands
.first()
.map(|c| !c.args.is_empty())
.unwrap_or(false)
{
println!();
println!("Kintsugi sees these commands:");
for c in &analysis.commands {
let args = c.args.join(" ");
if args.is_empty() {
println!(" • {}", c.program);
} else {
println!(" • {} {}", c.program, args);
}
}
}
} else {
println!();
println!("(couldn't fully parse this line — Kintsugi stays cautious and would hold it.)");
}
println!();
println!("Dry run: nothing was executed, logged, or sent anywhere.");
Ok(())
}
fn cmd_queue() -> Result<()> {
if !Client::is_daemon_running() {
println!("The daemon isn't running. Start it with `kintsugi init`.");
return Ok(());
}
let items = Client::list_pending().context("list pending")?;
if items.is_empty() {
println!("The approval queue is empty.");
return Ok(());
}
println!("{:<10} {:<13} command", "id", "class");
for it in &items {
let id = it.command.id.to_string();
println!(
"{:<10} {:<13} {}",
&id[..id.len().min(8)],
it.class.as_str(),
it.command.raw
);
}
println!();
println!("In-band (shim/MCP): `kintsugi approve <id>` runs it where it's waiting.");
println!("Hook-blocked: `kintsugi run <id>` runs it yourself, reversibly.");
println!("Either: `kintsugi deny <id>` to drop it.");
Ok(())
}
fn is_in_band(agent: &str) -> bool {
matches!(agent, "shim" | "mcp")
}
fn cmd_resolve_pending(id: &str, approve: bool) -> Result<()> {
if !Client::is_daemon_running() {
anyhow::bail!("the daemon isn't running; start it with `kintsugi init`");
}
let items = Client::list_pending().context("list pending")?;
let matches: Vec<_> = items
.iter()
.filter(|i| i.command.id.to_string().starts_with(id))
.collect();
let item = match matches.as_slice() {
[one] => *one,
[] => anyhow::bail!("no pending command matches id `{id}`"),
_ => anyhow::bail!("id `{id}` is ambiguous; use more characters"),
};
let full = item.command.id.to_string();
let short = full.get(..8).unwrap_or(&full);
if approve {
Client::approve(&full).context("approve")?;
if is_in_band(&item.command.agent) {
println!("✓ approved {short} — the requesting agent may now proceed.");
} else {
println!("✓ approved {short} (recorded). It came from a hook, so nothing is");
println!(" waiting to run it — use `kintsugi run {short}` to run it yourself.");
}
} else {
Client::deny(&full).context("deny")?;
println!("✗ denied {short}.");
}
Ok(())
}
fn cmd_run(id: Option<&str>) -> Result<()> {
if !Client::is_daemon_running() {
anyhow::bail!("the daemon isn't running; start it with `kintsugi init`");
}
let items = Client::list_pending().context("list pending")?;
if items.is_empty() {
println!("The approval queue is empty — nothing to run.");
return Ok(());
}
let item = match id {
Some(prefix) => {
let m: Vec<_> = items
.iter()
.filter(|i| i.command.id.to_string().starts_with(prefix))
.collect();
match m.as_slice() {
[one] => *one,
[] => anyhow::bail!("no held command matches id `{prefix}` (see `kintsugi queue`)"),
_ => anyhow::bail!("id `{prefix}` is ambiguous; use more characters"),
}
}
None => match items.as_slice() {
[one] => one,
_ => anyhow::bail!(
"{} commands are held — pass an id (see `kintsugi queue`)",
items.len()
),
},
};
let full = item.command.id.to_string();
let short = full.get(..8).unwrap_or(&full);
if is_in_band(&item.command.agent) {
anyhow::bail!(
"{short} came from the `{}` adapter, which is waiting to run it itself — \
approve it with `kintsugi approve {short}` (or press `a` in `kintsugi tui`).",
item.command.agent
);
}
let reversible = kintsugi_core::snapshot::is_fully_reversible(&item.command);
println!("Run this held command yourself? Kintsugi snapshots first, then runs it in");
println!("its original directory. The agent does not run it — you do.");
println!();
println!(" {}", item.command.raw);
println!();
println!(" dir: {}", item.command.cwd.display());
println!(" class: {}", item.class.as_str());
println!(" reason: {}", item.reason);
if reversible {
println!(" undo: `kintsugi undo` can roll this back — the snapshot covers its targets.");
} else {
println!(" undo: ⚠ unbounded target (glob/expansion/root/device): a snapshot may NOT");
println!(" fully cover it. The filesystem-watcher backstop is the only net.");
}
println!();
if !confirm_code_on_tty() {
println!("Not run. (It stays queued; `kintsugi deny {short}` to drop it.)");
return Ok(());
}
Client::approve(&full).context("approve for run")?;
let status = run_in_shell(&item.command.cwd, &item.command.raw)?;
let code = status.code().unwrap_or(1);
println!();
if code == 0 {
let tail = if reversible {
" Reverse it with `kintsugi undo`."
} else {
""
};
println!("✓ ran {short}.{tail}");
} else {
println!("• {short} exited with code {code}.");
}
std::process::exit(code);
}
fn run_in_shell(cwd: &std::path::Path, raw: &str) -> Result<std::process::ExitStatus> {
let mut cmd = if cfg!(windows) {
let mut c = std::process::Command::new("cmd");
c.arg("/C").arg(raw);
c
} else {
let mut c = std::process::Command::new("sh");
c.arg("-c").arg(raw);
c
};
cmd.current_dir(cwd);
cmd.status().with_context(|| format!("run `{raw}`"))
}
#[cfg(unix)]
fn confirm_code_on_tty() -> bool {
use std::io::{Read, Write};
let code = tty_code();
let Ok(mut tty) = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")
else {
eprintln!(
"kintsugi: no terminal to confirm on — run `kintsugi run` from an interactive shell."
);
return false;
};
let _ = write!(
tty,
"This prompt is Kintsugi (not the agent). To run it, type {code} then Enter: "
);
let _ = tty.flush();
let mut buf = [0u8; 64];
let n = tty.read(&mut buf).unwrap_or(0);
String::from_utf8_lossy(&buf[..n]).trim() == code
}
#[cfg(unix)]
fn tty_code() -> String {
use std::io::Read;
let mut b = [0u8; 2];
if std::fs::File::open("/dev/urandom")
.and_then(|mut f| f.read_exact(&mut b))
.is_err()
{
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
b = [(n >> 8) as u8, n as u8];
}
format!("{:02x}{:02x}", b[0], b[1])
}
#[cfg(not(unix))]
fn confirm_code_on_tty() -> bool {
use std::io::Write;
print!("Type 'yes' to run it: ");
let _ = std::io::stdout().flush();
let mut line = String::new();
if std::io::stdin().read_line(&mut line).is_err() {
return false;
}
matches!(line.trim(), "yes" | "YES")
}
fn cmd_panic() -> Result<()> {
let path = kintsugi_daemon::kill_switch_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&path, b"engaged\n").with_context(|| format!("write {}", path.display()))?;
log_control_event("panic", Decision::Deny, "kill-switch:engaged");
println!("⛔ Kill-switch ENGAGED. All agent actions are now denied.");
println!(" Run `kintsugi resume` to restore normal operation.");
Ok(())
}
fn cmd_resume() -> Result<()> {
let path = kintsugi_daemon::kill_switch_path();
if path.exists() {
std::fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
}
log_control_event("resume", Decision::Allow, "kill-switch:cleared");
println!("✓ Kill-switch cleared. Normal operation resumed.");
Ok(())
}
fn log_control_event(name: &str, decision: Decision, reason: &str) {
let db = default_db_path();
if let Some(parent) = db.parent() {
std::fs::create_dir_all(parent).ok();
}
if let Ok(log) = EventLog::open(&db) {
let cwd = std::env::current_dir().unwrap_or_default();
let cmd = ProposedCommand::new("kintsugi", cwd, vec![name.to_string()], name);
let _ = log.log_event(&cmd, &Verdict::rules(Class::Safe, decision, reason), None);
}
}
fn snapshot_dir() -> PathBuf {
default_db_path()
.parent()
.map(|p| p.join("snapshots"))
.unwrap_or_else(|| std::env::temp_dir().join("kintsugi-snapshots"))
}
fn cmd_undo(session: bool) -> Result<()> {
let db = default_db_path();
if !db.exists() {
println!("Nothing to undo.");
return Ok(());
}
let log = EventLog::open(&db).with_context(|| format!("open log {}", db.display()))?;
let dir = snapshot_dir();
let targets = if session {
log.unreverted_snapshots()?
} else {
log.latest_unreverted_snapshot()?.into_iter().collect()
};
if targets.is_empty() {
println!("Nothing to undo.");
return Ok(());
}
for m in &targets {
kintsugi_core::restore_snapshot(&dir, m)
.with_context(|| format!("restore snapshot for `{}`", m.command))?;
log.mark_reverted(&m.id)?;
let cwd = std::env::current_dir().unwrap_or_default();
let raw = format!("undo {}", m.command);
let cmd = ProposedCommand::new("kintsugi", cwd, vec!["undo".into(), m.id.clone()], raw);
log.log_event(
&cmd,
&Verdict::rules(Class::Safe, Decision::Allow, "undo"),
None,
)?;
println!(
"✓ undid `{}` ({} path(s) restored)",
m.command,
m.entries.len()
);
}
println!();
println!(
"Restored {} snapshot(s). Note: undo covers files only — not network calls, \
external APIs, or already-pushed commits.",
targets.len()
);
Ok(())
}
pub(crate) fn home_dir() -> Option<PathBuf> {
directories::BaseDirs::new().map(|b| b.home_dir().to_path_buf())
}
fn shim_dir() -> PathBuf {
if let Ok(dir) = std::env::var("KINTSUGI_DATA_DIR") {
return PathBuf::from(dir).join("shims");
}
if let Some(dirs) = directories::ProjectDirs::from("", "", "kintsugi") {
return dirs.data_dir().join("shims");
}
std::env::temp_dir().join("kintsugi-shims")
}
fn cmd_init(no_daemon: bool, enterprise: bool) -> Result<()> {
println!(
"kintsugi init{}",
if enterprise { " (enterprise)" } else { "" }
);
println!();
let shim_bin = init::sibling_bin("kintsugi-shim");
let shim_dir = shim_dir();
let linked = init::create_shims(&shim_dir, &shim_bin, init::SHIM_COMMANDS)
.context("create $PATH shims")?;
println!(
" ✓ shim: linked {} commands in {}",
linked.len(),
shim_dir.display()
);
println!(" add this to your PATH (prepend) to guard raw shell-outs:");
println!(" export PATH=\"{}:$PATH\"", shim_dir.display());
let home = home_dir();
let agents = home.as_deref().map(init::detect_agents).unwrap_or_default();
if agents.is_empty() {
println!(
" • no agent config dirs detected (~/.claude, ~/.qwen, ~/.gemini, ~/.copilot, ~/.cursor, ~/.codex, ~/.config/opencode)"
);
}
let mut mcp_agents = Vec::new();
for agent in &agents {
match agent.via {
init::Interception::Hook(kind) => match wire_hook(kind, home.as_deref()) {
Ok(()) => println!(" ✓ {}: wired via {}", agent.name, agent.via.as_str()),
Err(e) => println!(" ✗ {}: could not wire ({e})", agent.name),
},
init::Interception::Mcp => {
mcp_agents.push(agent.name);
println!(" • {}: intercept via {}", agent.name, agent.via.as_str());
}
}
}
if !mcp_agents.is_empty() {
println!();
println!(
" To wire MCP agents ({}), add the kintsugi-exec server:",
mcp_agents.join(", ")
);
println!(
" command = \"{}\"",
init::sibling_bin("kintsugi-mcp").display()
);
println!(" (see docs/mcp.md). The shim still covers their raw shell-outs.");
}
if no_daemon {
println!();
println!(" • daemon not started (--no-daemon)");
} else if Client::is_daemon_running() {
println!();
println!(
" ✓ daemon already running on {}",
ipc::socket_path().display()
);
} else {
start_daemon()?;
println!();
println!(" ✓ daemon started on {}", ipc::socket_path().display());
}
if !no_daemon {
if let Some(label) = active_scorer_label() {
println!(" ✓ scoring with: {label}");
}
}
println!();
if enterprise {
println!("Enterprise setup — run these next (not applied yet; each is deliberate):");
println!(" 1. Lock settings + require a password to stop:");
println!(" kintsugi admin provision");
println!(" 2. Install the auto-restart watchdog (a kill relaunches the daemon):");
println!(" kintsugi service install");
println!(" 3. Record human shell sessions for a tamper-evident audit trail:");
println!(" kintsugi record install --write ~/.bashrc # or ~/.zshrc");
println!(" (filesystem undo for rm/overwrites; DB DROP/TRUNCATE → use PITR/backups)");
println!(
" Review the audit trail with `kintsugi report` and the live TUI `kintsugi tui`."
);
} else {
println!("You're protected. Kintsugi holds dangerous agent commands for your OK and");
println!("makes them reversible — `kintsugi undo` rolls back the last destructive action.");
println!(" Try: kintsugi status · kintsugi tui · kintsugi test \"rm -rf /\"");
println!(" Running on a shared/production host? `kintsugi init --enterprise` adds the");
println!(" password lock, auto-restart watchdog, and session recorder.");
}
Ok(())
}
fn hook_command(agent: &str) -> String {
format!(
"{} --agent {agent}",
init::sibling_bin("kintsugi-hook").display()
)
}
fn backup_once(path: &std::path::Path) {
if path.exists() {
let backup = path.with_extension(format!(
"{}.kintsugi-bak",
path.extension().and_then(|e| e.to_str()).unwrap_or("bak")
));
let _ = std::fs::copy(path, backup);
}
}
fn write_file(path: &std::path::Path, contents: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(path, contents).with_context(|| format!("write {}", path.display()))
}
fn wire_hook(kind: init::HookKind, home: Option<&std::path::Path>) -> Result<()> {
let Some(home) = home else {
return Ok(());
};
use init::HookKind::*;
match kind {
Claude => wire_settings_json(home, ".claude", "PreToolUse", "Bash", "claude"),
Qwen => wire_settings_json(
home,
".qwen",
"PreToolUse",
"run_shell_command|Bash|Shell|ShellTool",
"qwen",
),
Gemini => wire_settings_json(home, ".gemini", "BeforeTool", "run_shell_command", "gemini"),
Cursor => wire_cursor(home),
Copilot => wire_copilot(home),
Codex => wire_codex(home),
OpenCode => wire_opencode(home),
}
}
fn wire_settings_json(
home: &std::path::Path,
dir: &str,
event: &str,
matcher: &str,
agent: &str,
) -> Result<()> {
let path = home.join(dir).join("settings.json");
let existing = std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok());
backup_once(&path);
let merged = init::merge_settings_hook(existing, event, matcher, &hook_command(agent));
write_file(&path, &serde_json::to_string_pretty(&merged)?)
}
fn wire_cursor(home: &std::path::Path) -> Result<()> {
let path = home.join(".cursor").join("hooks.json");
let existing = std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok());
backup_once(&path);
let merged = init::merge_cursor_hooks(existing, &hook_command("cursor"));
write_file(&path, &serde_json::to_string_pretty(&merged)?)
}
fn wire_copilot(home: &std::path::Path) -> Result<()> {
let path = home.join(".copilot").join("hooks").join("kintsugi.json");
let cfg = init::copilot_hooks_config(&hook_command("copilot"));
write_file(&path, &serde_json::to_string_pretty(&cfg)?)
}
fn wire_codex(home: &std::path::Path) -> Result<()> {
let path = home.join(".codex").join("config.toml");
let existing = std::fs::read_to_string(&path).unwrap_or_default();
backup_once(&path);
let merged = init::merge_codex_toml(&existing, &hook_command("codex"))?;
write_file(&path, &merged)
}
fn wire_opencode(home: &std::path::Path) -> Result<()> {
let path = home
.join(".config")
.join("opencode")
.join("plugin")
.join("kintsugi.js");
let hook_bin = init::sibling_bin("kintsugi-hook");
let js = init::opencode_plugin_js(&hook_bin.to_string_lossy());
write_file(&path, &js)
}
fn cmd_banner() -> Result<()> {
println!("kintsugi {}", env!("CARGO_PKG_VERSION"));
println!("A local-first safety layer for AI coding agents.");
println!();
if kintsugi_daemon::kill_switch_path().exists() {
println!(" ⚠ KILL-SWITCH ENGAGED — all agent actions are denied.");
println!(" run `kintsugi resume` to clear it.");
} else if Client::is_daemon_running() {
println!(" ✓ running and guarding your machine.");
if let Some(label) = active_scorer_label() {
println!(" model: {label}");
}
println!(" `kintsugi tui` (live timeline) · `kintsugi status` · `kintsugi stop`");
} else {
println!(" • not running yet.");
println!(" run `kintsugi init` to detect your agents and start the daemon.");
}
println!();
println!("Run `kintsugi --help` for all commands.");
Ok(())
}
pub(crate) fn cmd_stop() -> Result<()> {
if Client::is_daemon_running() {
return stop_via_daemon();
}
if !admin_cmd::allow_stop() {
return Ok(());
}
let pid_path = kintsugi_daemon::pid_file_path();
let pid = std::fs::read_to_string(&pid_path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
match pid {
Some(pid) => {
kill_pid(&pid);
let _ = std::fs::remove_file(&pid_path);
println!("kintsugi: stopped the daemon (pid {pid}).");
}
None => println!("kintsugi: the daemon is not running."),
}
Ok(())
}
fn stop_via_daemon() -> Result<()> {
let (locked, nonce, salt, params) =
Client::auth_begin("shutdown").context("begin shutdown handshake")?;
let (nonce_hex, proof_hex) = if locked {
let pw = admin_cmd::read_admin_password("Admin password to stop Kintsugi: ")?;
let nonce_bytes = hex::decode(&nonce).context("decode challenge nonce")?;
let proof =
kintsugi_core::admin::compute_proof(&pw, &salt, params, &nonce_bytes, b"shutdown")
.map_err(|e| anyhow::anyhow!("{e}"))?;
(nonce, hex::encode(proof))
} else {
(String::new(), String::new())
};
match Client::shutdown("shutdown", &nonce_hex, &proof_hex) {
Ok(()) => {
println!("kintsugi: stopped the daemon.");
}
Err(e) => {
eprintln!("kintsugi: not stopping — {e}");
}
}
Ok(())
}
const UPDATE_REPO: &str = "arrowassassin/kintsugi";
pub(crate) const INSTALL_URL: &str =
"https://github.com/arrowassassin/kintsugi/releases/latest/download/install.sh";
pub(crate) const PICKER_URL: &str =
"https://github.com/arrowassassin/kintsugi/releases/latest/download/pick-model.sh";
fn cmd_update(check_only: bool, yes: bool) -> Result<()> {
let current = env!("CARGO_PKG_VERSION");
println!("kintsugi {current} — checking GitHub for a newer release…");
let tag = latest_release_tag().context("check for the latest release")?;
let latest = tag.trim_start_matches('v');
if !version_is_newer(&tag, current) {
println!(" ✓ up to date (latest release is {tag}).");
return Ok(());
}
println!(" ↑ update available: {current} → {latest}");
let one_liner = format!("curl -fsSL {INSTALL_URL} | sh -s -- --bin-only");
if check_only {
println!(" install it with:");
println!(" {one_liner}");
return Ok(());
}
let had_llama = daemon_has_llama();
let prompt = if had_llama {
"Download the update and rebuild the local model engine now?"
} else {
"Download and install the new binaries now?"
};
if !yes && !confirm(prompt)? {
println!(" • skipped. To update later: kintsugi update (or: {one_liner})");
return Ok(());
}
run_installer(&tag, had_llama).context("install the update")?;
println!(
" ✓ updated to {latest}. Restart the daemon to run it: kintsugi stop && kintsugi init"
);
Ok(())
}
pub(crate) fn daemon_has_llama() -> bool {
let Some(daemon) = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("kintsugi-daemon")))
else {
return false;
};
std::process::Command::new(daemon)
.arg("--has-llama")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn latest_release_tag() -> Result<String> {
let url = format!("https://api.github.com/repos/{UPDATE_REPO}/releases/latest");
let body = http_get(&url)?;
let json: serde_json::Value =
serde_json::from_slice(&body).context("parse the GitHub release response")?;
json.get("tag_name")
.and_then(|v| v.as_str())
.map(str::to_string)
.context("no tag_name in the GitHub response (no published release yet?)")
}
pub(crate) fn http_get(url: &str) -> Result<Vec<u8>> {
let attempts: [(&str, &[&str]); 2] = [("curl", &["-fsSL", url]), ("wget", &["-qO-", url])];
for (bin, args) in attempts {
match std::process::Command::new(bin).args(args).output() {
Ok(out) if out.status.success() => return Ok(out.stdout),
Ok(_) | Err(_) => continue,
}
}
anyhow::bail!("could not reach GitHub — need curl or wget and network access")
}
fn run_installer(tag: &str, had_llama: bool) -> Result<()> {
let script = http_get(INSTALL_URL).context("download the installer")?;
let tmp = std::env::temp_dir().join(format!("kintsugi-update-{}.sh", std::process::id()));
std::fs::write(&tmp, &script).with_context(|| format!("write {}", tmp.display()))?;
let mut cmd = std::process::Command::new("sh");
cmd.arg(&tmp).arg("--version").arg(tag);
if had_llama {
cmd.arg("--no-init").arg("--with-model");
} else {
cmd.arg("--bin-only");
}
let exe = std::env::current_exe().ok();
if let Some(parent) = exe.as_deref().and_then(|p| p.parent()) {
cmd.arg("--bin-dir").arg(parent);
}
let status = cmd.status().context("run the installer");
let _ = std::fs::remove_file(&tmp);
let status = status?;
if !status.success() {
anyhow::bail!("installer exited unsuccessfully ({status})");
}
Ok(())
}
fn confirm(prompt: &str) -> Result<bool> {
use std::io::Write;
if !std::io::stdin().is_terminal() {
return Ok(false);
}
print!("{prompt} [y/N] ");
std::io::stdout().flush().ok();
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
Ok(matches!(answer.trim(), "y" | "Y" | "yes" | "Yes"))
}
fn parse_version(s: &str) -> Option<(u64, u64, u64)> {
let core = s.trim().trim_start_matches('v');
let mut parts = core.split(['.', '-', '+']);
let major = parts.next()?.parse().ok()?;
let minor = parts.next().unwrap_or("0").parse().ok()?;
let patch = parts.next().unwrap_or("0").parse().ok()?;
Some((major, minor, patch))
}
fn version_is_newer(latest: &str, current: &str) -> bool {
match (parse_version(latest), parse_version(current)) {
(Some(l), Some(c)) => l > c,
_ => latest.trim_start_matches('v') != current.trim_start_matches('v'),
}
}
#[cfg(unix)]
fn kill_pid(pid: &str) {
let _ = std::process::Command::new("kill").arg(pid).status();
}
#[cfg(not(unix))]
fn kill_pid(pid: &str) {
let _ = std::process::Command::new("taskkill")
.args(["/PID", pid, "/F"])
.status();
}
pub(crate) fn start_daemon() -> Result<()> {
let daemon_bin = init::sibling_bin("kintsugi-daemon");
std::process::Command::new(&daemon_bin)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.with_context(|| format!("start daemon {}", daemon_bin.display()))?;
for _ in 0..150 {
if Client::is_daemon_running() {
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
Ok(())
}
fn describe_scorer(name: &str) -> String {
if let Some(model) = name.strip_prefix("llama:") {
format!("{model} (local model)")
} else if name == "heuristic" {
"heuristic fallback (no local model — set KINTSUGI_MODEL_FILE)".to_string()
} else {
name.to_string()
}
}
pub(crate) fn active_scorer_label() -> Option<String> {
Client::status_scorer().ok().map(|n| describe_scorer(&n))
}
fn cmd_status() -> Result<()> {
println!("kintsugi {}", env!("CARGO_PKG_VERSION"));
let running = Client::is_daemon_running();
println!(" daemon: {}", if running { "running" } else { "stopped" });
println!(" socket: {}", ipc::socket_path().display());
if running {
match Client::status_scorer() {
Ok(name) => println!(" model: {}", describe_scorer(&name)),
Err(_) => println!(" model: (daemon not answering)"),
}
}
if kintsugi_daemon::kill_switch_path().exists() {
println!(" KILL-SWITCH: ENGAGED — all actions denied (run `kintsugi resume`)");
}
let db = default_db_path();
println!(" log: {}", db.display());
if db.exists() {
match EventLog::open(&db) {
Ok(log) => {
let count = log.count().unwrap_or(0);
let chain = log.verify_chain()?;
println!(" events: {count}");
println!(
" chain: {}",
if chain.is_intact() {
"intact".to_string()
} else {
format!("BROKEN ({chain:?})")
}
);
}
Err(e) => println!(" events: (could not open log: {e})"),
}
} else {
println!(" events: 0 (no log yet)");
}
Ok(())
}
fn cmd_log(number: usize, page: usize, show_redacted: bool, filter: &FilterArgs) -> Result<()> {
let db = default_db_path();
if !db.exists() {
print!("{}", logview::render_log(&[], false));
return Ok(());
}
let log = EventLog::open(&db).with_context(|| format!("open log {}", db.display()))?;
let number = number.max(1);
let page = page.max(1);
let offset = (page - 1) * number;
let color = logview::use_color(
std::env::var_os("NO_COLOR").is_some(),
std::io::stdout().is_terminal(),
);
let f = filter.to_filter_paged(show_redacted, Some(number), Some(offset))?;
let mut events = log.query(&f)?;
events.reverse();
let total = log.count_matching(&filter.to_filter(show_redacted, None)?)? as usize;
if events.is_empty() {
if total == 0 {
print!("{}", logview::render_log(&events, color));
} else {
println!(" no events on page {page} — {total} total; newest is page 1.");
}
return Ok(());
}
print!("{}", logview::render_log(&events, color));
print!(
"{}",
logview::render_page_footer(page, offset, events.len(), total, color)
);
Ok(())
}
fn cmd_report(catastrophic_only: bool, number: usize, filter: &FilterArgs) -> Result<()> {
let db = default_db_path();
if !db.exists() {
println!("No events recorded yet — nothing to report.");
return Ok(());
}
let log = EventLog::open(&db).with_context(|| format!("open log {}", db.display()))?;
let color = logview::use_color(
std::env::var_os("NO_COLOR").is_some(),
std::io::stdout().is_terminal(),
);
let n = number.max(1);
let mut classes = vec![Class::Catastrophic];
if !catastrophic_only {
classes.push(Class::Ambiguous);
}
let mut events = Vec::new();
for c in classes {
let mut f = filter.to_filter(false, Some(n))?;
f.class = Some(c);
events.extend(log.query(&f)?);
}
events.sort_by_key(|e| std::cmp::Reverse(e.seq));
events.truncate(n);
if events.is_empty() {
let scope = if catastrophic_only {
"catastrophic"
} else {
"destructive"
};
println!("No {scope} commands in scope. The timeline is clean for this filter.");
return Ok(());
}
let band = if catastrophic_only {
"catastrophic"
} else {
"destructive (catastrophic + ambiguous)"
};
println!("Audit report — {band} commands, newest first:\n");
print!("{}", logview::render_log(&events, color));
println!(
"\n{} command(s) shown. Full chain integrity: `kintsugi status`.",
events.len()
);
Ok(())
}
fn cmd_redact(id: Option<String>, reason: &str, filter: &FilterArgs) -> Result<()> {
let db = default_db_path();
let log = EventLog::open(&db).with_context(|| format!("open log {}", db.display()))?;
if let Some(prefix) = id {
let full = resolve_event_id(&log, &prefix)?;
if log.redact(&full, reason)? {
println!("redacted {}", &full[..full.len().min(8)]);
} else {
println!("already redacted (or no such event)");
}
return Ok(());
}
if filter.is_empty() {
anyhow::bail!("refusing to redact everything: pass an ID or at least one filter");
}
let f = filter.to_filter(false, None)?;
let n = log.redact_matching(&f, reason)?;
println!(
"redacted {n} event(s) — hidden from views; chain intact (use `kintsugi purge` to erase)"
);
Ok(())
}
fn cmd_purge(yes: bool, reason: &str, filter: &FilterArgs) -> Result<()> {
let db = default_db_path();
let log = EventLog::open(&db).with_context(|| format!("open log {}", db.display()))?;
if filter.is_empty() {
anyhow::bail!(
"refusing to purge everything: pass at least one filter (--agent/--before/…)"
);
}
let f = filter.to_filter(true, None)?;
let count = log.count_matching(&f)?;
if count == 0 {
println!("nothing matched — nothing purged");
return Ok(());
}
if !yes {
anyhow::bail!(
"this will PERMANENTLY erase {count} event(s) and rewrite the chain for that span.\n \
Re-run with --yes to confirm."
);
}
let removed = log.purge_matching(&f, reason)?;
println!("purged {removed} event(s); chain rebuilt and a purge marker recorded");
Ok(())
}
fn resolve_event_id(log: &EventLog, prefix: &str) -> Result<String> {
let all = log.query(&kintsugi_core::Filter {
include_redacted: true,
..kintsugi_core::Filter::default()
})?;
let matches: Vec<String> = all
.iter()
.map(|e| e.id.to_string())
.filter(|id| id.starts_with(prefix))
.collect();
match matches.len() {
0 => anyhow::bail!("no event matches id '{prefix}'"),
1 => Ok(matches.into_iter().next().unwrap()),
n => anyhow::bail!("'{prefix}' is ambiguous ({n} events match) — use more characters"),
}
}
#[cfg(test)]
mod filter_tests {
use super::*;
#[test]
fn run_in_shell_propagates_exit_code() {
let tmp = tempfile::tempdir().unwrap();
assert!(run_in_shell(tmp.path(), "exit 0").unwrap().success());
let st = run_in_shell(tmp.path(), "exit 7").unwrap();
assert_eq!(st.code(), Some(7));
}
#[cfg(unix)]
#[test]
fn tty_code_is_short_and_hex() {
let c = tty_code();
assert_eq!(c.len(), 4);
assert!(c.chars().all(|ch| ch.is_ascii_hexdigit()), "got {c}");
}
#[test]
fn in_band_only_for_shim_and_mcp() {
assert!(is_in_band("shim"));
assert!(is_in_band("mcp"));
assert!(!is_in_band("claude-code"));
assert!(!is_in_band("cursor"));
assert!(!is_in_band("codex"));
}
#[test]
fn version_compare_handles_tags_and_suffixes() {
assert!(version_is_newer("v0.2.0", "0.1.0"));
assert!(version_is_newer("0.1.1", "0.1.0"));
assert!(version_is_newer("v1.0.0", "0.9.9"));
assert!(!version_is_newer("v0.1.0", "0.1.0"));
assert!(!version_is_newer("0.1.0", "0.2.0"));
assert_eq!(parse_version("v0.1.0-rc1"), Some((0, 1, 0)));
assert_eq!(parse_version("0.1"), Some((0, 1, 0)));
assert!(version_is_newer("nightly", "0.1.0"));
assert!(!version_is_newer("v0.1.0", "0.1.0"));
}
#[test]
fn describe_scorer_distinguishes_model_from_fallback() {
let m = describe_scorer("llama:Qwen3-4B-Instruct-2507-Q4_K_M");
assert!(m.contains("Qwen3-4B-Instruct-2507-Q4_K_M"));
assert!(m.contains("local model"));
assert!(!m.starts_with("llama:"), "the raw backend prefix is hidden");
let h = describe_scorer("heuristic");
assert!(h.contains("heuristic"));
assert!(h.contains("KINTSUGI_MODEL_FILE"));
assert_eq!(describe_scorer("future-backend"), "future-backend");
}
#[test]
fn parse_instant_relative_and_rfc3339() {
let now = time::OffsetDateTime::now_utc();
let wk = parse_instant("week").unwrap();
assert!(wk < now && (now - wk) >= time::Duration::days(6));
let h = parse_instant("12h").unwrap();
assert!((now - h) >= time::Duration::hours(11));
let d = parse_instant("3d").unwrap();
assert!((now - d) >= time::Duration::days(2));
assert!(parse_instant("2020-01-01T00:00:00Z").is_ok());
assert!(parse_instant("not-a-time").is_err());
}
#[test]
fn empty_filter_is_detected() {
let empty = FilterArgs {
agent: None,
session: None,
class: None,
grep: None,
since: None,
before: None,
};
assert!(empty.is_empty());
let set = FilterArgs {
agent: Some("shim".into()),
..empty.clone()
};
assert!(!set.is_empty());
}
#[test]
fn to_filter_maps_class_and_rejects_unknown() {
let f = FilterArgs {
agent: Some("cursor".into()),
session: None,
class: Some("catastrophic".into()),
grep: Some("rm".into()),
since: None,
before: None,
};
let core = f.to_filter(false, Some(10)).unwrap();
assert_eq!(core.agent.as_deref(), Some("cursor"));
assert_eq!(core.class, Some(kintsugi_core::Class::Catastrophic));
assert_eq!(core.limit, Some(10));
let bad = FilterArgs {
class: Some("nope".into()),
..f
};
assert!(bad.to_filter(false, None).is_err());
}
}