use std::io::{self, Write as _};
use std::path::PathBuf;
use clap::CommandFactory;
use serde_json::{json, Value};
use std::io::IsTerminal;
use sysknife_brain::config::BrainConfig;
use sysknife_brain::planner::{LlmPlanner, PlanRiskLevel};
use sysknife_brain::PlanEvent;
use sysknife_types::{
DistroHint, RiskLevel, DISTRO_FAMILY_DEBIAN, DISTRO_FAMILY_FEDORA, DISTRO_FAMILY_OTHER,
};
use sysknife_brain::state_client::StateClient as _;
use crate::approval::{ApprovalDecision, ApprovalPolicy, MaxRisk};
use crate::cli::{AuditVerifyArgs, Cli, HistoryArgs};
use crate::client::{DaemonClient, SocketTarget};
use crate::error::CliError;
pub fn distro_id_to_hint(distro: &sysknife_core::distro::DistroId) -> DistroHint {
use sysknife_core::distro::DistroFamily;
let family = match distro.family() {
DistroFamily::Fedora => DISTRO_FAMILY_FEDORA,
DistroFamily::Debian => DISTRO_FAMILY_DEBIAN,
DistroFamily::Other => DISTRO_FAMILY_OTHER,
};
DistroHint {
family,
version: Some(distro.to_string()),
}
}
pub fn resolve_socket_target() -> SocketTarget {
match std::env::var("SYSKNIFE_SOCKET") {
Ok(s) => SocketTarget::try_from_str(&s).unwrap_or_else(|e| {
eprintln!("sysknife: invalid SYSKNIFE_SOCKET: {e}");
std::process::exit(1);
}),
Err(_) => SocketTarget::Unix(PathBuf::from("/run/sysknife/daemon.sock")),
}
}
pub fn since_to_hours(s: &str) -> Option<u32> {
let epoch = rfc3339_to_unix(s)?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs() as i64;
if epoch > now {
return None;
}
u32::try_from((now - epoch) / 3600).ok()
}
fn rfc3339_to_unix(s: &str) -> Option<i64> {
let s = s.strip_suffix('Z').or_else(|| s.strip_suffix("+00:00"))?;
let (date_part, time_and_frac) = s.split_once('T')?;
let time_part = &time_and_frac[..time_and_frac.find('.').unwrap_or(time_and_frac.len())];
if time_part.len() < 8 {
return None;
}
let mut date_iter = date_part.splitn(4, '-');
let y: i64 = date_iter.next()?.parse().ok()?;
let m: i64 = date_iter.next()?.parse().ok()?;
let d: i64 = date_iter.next()?.parse().ok()?;
if date_iter.next().is_some() {
return None; }
let mut time_iter = time_part.splitn(4, ':');
let h: i64 = time_iter.next()?.parse().ok()?;
let mn: i64 = time_iter.next()?.parse().ok()?;
let sec: i64 = time_iter.next()?.parse().ok()?;
if time_iter.next().is_some() {
return None; }
if !(1..=12).contains(&m) || !(1..=31).contains(&d) || h > 23 || mn > 59 || sec > 60
{
return None;
}
let z = if m > 2 { y } else { y - 1 };
let era = (if z >= 0 { z } else { z - 399 }) / 400;
let yoe = z - era * 400; let m_adj = if m > 2 { m - 3 } else { m + 9 }; let doy = (153 * m_adj + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; let days = era * 146097 + doe - 719468;
Some(days * 86_400 + h * 3600 + mn * 60 + sec)
}
pub fn highest_risk(plan: &sysknife_brain::planner::Plan) -> Option<&PlanRiskLevel> {
plan.steps()
.iter()
.max_by_key(|s| match s.risk_level() {
PlanRiskLevel::Low => 0u8,
PlanRiskLevel::Medium => 1,
PlanRiskLevel::High => 2,
})
.map(|s| s.risk_level())
}
fn plan_risk_of(risk: RiskLevel) -> PlanRiskLevel {
match risk {
RiskLevel::Low => PlanRiskLevel::Low,
RiskLevel::Medium => PlanRiskLevel::Medium,
RiskLevel::High => PlanRiskLevel::High,
}
}
fn plan_risk_rank(risk: &PlanRiskLevel) -> u8 {
match risk {
PlanRiskLevel::Low => 0,
PlanRiskLevel::Medium => 1,
PlanRiskLevel::High => 2,
}
}
fn authoritative_plan_risk(action_name: &str) -> PlanRiskLevel {
plan_risk_of(sysknife_daemon::preview::gate_risk(action_name))
}
fn daemon_risk_within_approved(approved: &PlanRiskLevel, daemon: &PlanRiskLevel) -> bool {
plan_risk_rank(daemon) <= plan_risk_rank(approved)
}
pub async fn run_approve(
transaction_id: &str,
socket: SocketTarget,
json: bool,
log: &Logger,
) -> Result<(), CliError> {
if !std::io::stdin().is_terminal() {
return Err(CliError::NonInteractive);
}
let client = DaemonClient::new(socket);
let details = client.approval_details(transaction_id).await?;
log.print_stderr(&format!(
"Action: {}\nRisk: {:?}\nSummary: {}\nProposed change:\n{}",
details.action_name,
details.preview.risk_level,
details.preview.summary,
serde_json::to_string_pretty(&details.preview.proposed_change)
.unwrap_or_else(|_| "<unavailable>".to_string())
));
let approved = if details.preview.risk_level == RiskLevel::High {
prompt_exact(
"High-risk action. Type the exact action name to approve",
&details.action_name,
)
.await
} else {
prompt_confirm(&format!("Approve transaction {}?", details.transaction_id)).await
};
if !approved {
return Err(CliError::Rejected);
}
let receipt = client.approve(transaction_id).await?;
if json {
log.println(
&serde_json::json!({
"transaction_id": transaction_id,
"approval_receipt": receipt,
})
.to_string(),
);
} else {
log.println(&format!("Approval receipt: {receipt}"));
}
Ok(())
}
pub(crate) fn build_history_params(
limit: u32,
status: Option<&str>,
action: Option<&str>,
since_hours: Option<u32>,
) -> Value {
let mut params = json!({ "limit": limit });
if let Some(s) = status {
params["status_filter"] = json!(s);
}
if let Some(a) = action {
params["action_filter"] = json!(a);
}
if let Some(h) = since_hours {
params["since_hours"] = json!(h);
}
params
}
pub struct Logger {
file: std::sync::Mutex<Option<std::fs::File>>,
}
impl Logger {
pub fn new(path: Option<&std::path::Path>) -> Result<Self, CliError> {
let file = match path {
None => None,
Some(p) => Some(
std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(p)
.map_err(|e| CliError::ConfigOrDaemon(format!("open log file: {e}")))?,
),
};
Ok(Self {
file: std::sync::Mutex::new(file),
})
}
pub fn println(&self, line: &str) {
println!("{line}");
let mut guard = self.file.lock().expect("Logger mutex poisoned");
if let Some(f) = guard.as_mut() {
if let Err(e) = writeln!(f, "{line}") {
eprintln!("sysknife: log write failed ({e}); --log-to tee disabled");
*guard = None;
}
}
}
pub fn print_stderr(&self, line: &str) {
eprintln!("{line}");
}
}
pub fn run_completions(shell: clap_complete::Shell) {
clap_complete::generate(shell, &mut Cli::command(), "sysknife", &mut io::stdout());
}
pub async fn run_doctor(
socket: SocketTarget,
json_out: bool,
log: &Logger,
) -> Result<(), CliError> {
let config = BrainConfig::from_env().map_err(|e| CliError::ConfigOrDaemon(e.to_string()))?;
let socket_label = format!("{socket:?}");
let client = DaemonClient::new(socket);
let distro_label = match sysknife_core::distro::detect() {
Ok(d) => d.to_string(),
Err(e) => format!("unknown ({})", e),
};
let state_result = tokio::task::spawn_blocking(move || client.curated_state())
.await
.map_err(|e| CliError::ConfigOrDaemon(format!("join: {e}")))?;
match state_result {
Ok(state) => {
if json_out {
let out = json!({
"ok": true,
"socket": socket_label,
"host": state.host_name(),
"provider": config.provider_name(),
"model": config.model_name(),
"distro": distro_label,
});
log.println(&serde_json::to_string(&out).expect("static JSON"));
} else {
crate::render::print_doctor_ok(
&socket_label,
state.host_name(),
config.provider_name(),
config.model_name(),
&distro_label,
log,
);
}
Ok(())
}
Err(e) => {
if json_out {
let out = json!({ "ok": false, "error": e.to_string() });
log.println(&serde_json::to_string(&out).expect("static JSON"));
} else {
crate::render::print_doctor_fail(&e.to_string());
}
Err(CliError::ConfigOrDaemon(e.to_string()))
}
}
}
pub async fn run_history(
args: HistoryArgs,
socket: SocketTarget,
log: &Logger,
) -> Result<(), CliError> {
let since_hours = match args.since.as_deref() {
None => None,
Some(s) => {
if rfc3339_to_unix(s).is_none() {
return Err(CliError::ConfigOrDaemon(format!(
"--since: {s:?} is not a valid UTC RFC 3339 timestamp \
(accepted formats: 2026-01-15T10:30:00Z or 2026-01-15T10:30:00+00:00)"
)));
}
match since_to_hours(s) {
Some(h) => Some(h),
None => {
return Err(CliError::ConfigOrDaemon(format!(
"--since: {s:?} is in the future"
)));
}
}
}
};
let params = build_history_params(
args.limit,
args.status.as_deref(),
args.action.as_deref(),
since_hours,
);
let client = DaemonClient::new(socket);
let output =
tokio::task::spawn_blocking(move || client.query_action("ListJobHistory", ¶ms))
.await
.map_err(|e| CliError::ConfigOrDaemon(format!("join: {e}")))?
.map_err(|e| CliError::ConfigOrDaemon(e.to_string()))?;
log.println(&output);
Ok(())
}
pub struct RunOpts {
pub socket: SocketTarget,
pub yes: bool,
pub max_risk: Option<MaxRisk>,
pub non_interactive: bool,
pub dry_run: bool,
pub json: bool,
pub step_by_step: bool,
}
impl RunOpts {
pub fn approval_policy(&self) -> ApprovalPolicy {
ApprovalPolicy::new(self.yes, self.max_risk, self.non_interactive, self.dry_run)
}
}
pub async fn run_audit_verify(args: AuditVerifyArgs, log: &Logger) -> Result<(), CliError> {
use sysknife_core::config::LacsConfig;
use sysknife_daemon::audit_chain::{self, AuditKey, VerifyOutcome};
let lacs_config = LacsConfig::load();
let label_for_diag = match lacs_config.storage.as_ref() {
Some(s) if s.backend.eq_ignore_ascii_case("postgres") => "postgres".to_string(),
_ => sysknife_core::default_database_path().display().to_string(),
};
let db_path = sysknife_core::default_database_path();
let verifier = if let Some(pubkey_path) = args.pubkey.as_ref() {
match std::fs::read_to_string(pubkey_path) {
Ok(contents) => Verifier::Public(contents.trim().to_string()),
Err(e) => {
let reason = format!(
"public key file {} could not be read: {e}",
pubkey_path.display()
);
emit_verify_outcome(
&args,
log,
&VerifyOutcome::CannotVerify { reason },
&label_for_diag,
);
return Err(CliError::Exit(2));
}
}
} else {
let key_path = sysknife_daemon::audit_chain::resolve_audit_key_path(&db_path);
if !key_path.exists() {
let reason = format!(
"audit key not found at {}; the daemon generates this on first run, \
set $SYSKNIFE_AUDIT_KEY_PATH, or pass --pubkey <FILE> to verify with \
the exported public key",
key_path.display()
);
emit_verify_outcome(
&args,
log,
&VerifyOutcome::CannotVerify { reason },
&label_for_diag,
);
return Err(CliError::Exit(2));
}
match AuditKey::load_or_generate(&key_path) {
Ok(k) => Verifier::Private(Box::new(k)),
Err(e) => {
let reason = format!("audit key load failed: {e}");
emit_verify_outcome(
&args,
log,
&VerifyOutcome::CannotVerify { reason },
&label_for_diag,
);
return Err(CliError::Exit(2));
}
}
};
let outcome = match lacs_config.storage.as_ref() {
Some(s) if s.backend.eq_ignore_ascii_case("postgres") => {
verify_postgres(s, &verifier).await
}
_ => verify_sqlite(&db_path, &verifier).await,
};
let exit_code = audit_chain::outcome_to_exit_code(&outcome);
emit_verify_outcome(&args, log, &outcome, &label_for_diag);
if exit_code == 0 {
Ok(())
} else {
Err(CliError::Exit(exit_code))
}
}
pub async fn run_audit_checkpoint(
args: crate::cli::AuditCheckpointArgs,
_log: &Logger,
) -> Result<(), CliError> {
use sysknife_daemon::audit_chain::{
checkpoint_outcome_to_exit_code, outcome_to_exit_code, verify_chain, verify_checkpoints,
AuditKey, CheckpointOutcome, VerifyOutcome,
};
use sysknife_daemon::checkpoint_sink::{CheckpointSink, PostgresCheckpointSink};
use sysknife_daemon::transactions::TransactionStore;
let db_url = match args
.db
.clone()
.or_else(|| std::env::var("SYSKNIFE_CHECKPOINT_DB").ok())
{
Some(u) => u,
None => {
eprintln!(
"no checkpoint database configured; pass --db <URL> or set \
SYSKNIFE_CHECKPOINT_DB (preferred, keeps credentials off argv)"
);
return Err(CliError::Exit(2));
}
};
let db_path = sysknife_core::default_database_path();
let key_path = sysknife_daemon::audit_chain::resolve_audit_key_path(&db_path);
if !key_path.exists() {
eprintln!("audit key not found at {}", key_path.display());
return Err(CliError::Exit(2));
}
let key = AuditKey::load_or_generate(&key_path).map_err(|e| {
eprintln!("audit key load failed: {e}");
CliError::Exit(2)
})?;
if !db_path.exists() {
eprintln!("audit database not found at {}", db_path.display());
return Err(CliError::Exit(2));
}
let store = TransactionStore::open_read_only(&db_path).map_err(|e| {
eprintln!("opening audit database failed: {e}");
CliError::Exit(2)
})?;
let rows = store.fetch_chain_rows().map_err(|e| {
eprintln!("reading audit chain failed: {e}");
CliError::Exit(2)
})?;
let tip = match rows.last() {
Some(t) => t,
None => {
eprintln!("audit chain is empty; nothing to checkpoint");
return Err(CliError::Exit(2));
}
};
match verify_chain(&key, &rows) {
VerifyOutcome::Intact { .. } => {}
broken => {
eprintln!("refusing to anchor: local audit chain does not verify: {broken:?}");
return Err(CliError::Exit(outcome_to_exit_code(&broken)));
}
}
let created_at = chrono::Utc::now().to_rfc3339();
let checkpoint = key.sign_checkpoint(tip.seq, &tip.chain_hash, &created_at);
let sink = PostgresCheckpointSink::connect(&db_url)
.await
.map_err(|e| {
eprintln!("connecting to checkpoint database failed: {e}");
CliError::Exit(2)
})?;
sink.append(&checkpoint).await.map_err(|e| {
eprintln!("anchoring checkpoint failed: {e}");
CliError::Exit(2)
})?;
let tip_prefix = &checkpoint.chain_tip[..checkpoint.chain_tip.len().min(12)];
println!(
"anchored checkpoint: seq={} tip={tip_prefix}… -> external database",
checkpoint.seq
);
let anchored = sink.load_all().await.map_err(|e| {
eprintln!("loading anchored checkpoints failed: {e}");
CliError::Exit(2)
})?;
if !anchored.contains(&checkpoint) {
eprintln!(
"anchored checkpoint not found on read-back; the checkpoint database \
may be a lagging replica or a different database than the write hit"
);
return Err(CliError::Exit(2));
}
match verify_checkpoints(&key.verifying_key_hex(), &rows, &anchored) {
CheckpointOutcome::Consistent {
checkpoints_checked,
} => {
println!("checkpoints consistent ({checkpoints_checked} verified)");
Ok(())
}
other => {
eprintln!("checkpoint verification FAILED: {other:?}");
Err(CliError::Exit(checkpoint_outcome_to_exit_code(&other)))
}
}
}
pub(crate) enum Verifier {
Private(Box<sysknife_daemon::audit_chain::AuditKey>),
Public(String),
}
pub(crate) async fn verify_sqlite(
db_path: &std::path::Path,
verifier: &Verifier,
) -> sysknife_daemon::audit_chain::VerifyOutcome {
use sysknife_daemon::audit_chain::VerifyOutcome;
use sysknife_daemon::transactions::TransactionStore;
if !db_path.exists() {
return VerifyOutcome::CannotVerify {
reason: format!(
"audit database not found at {}; set $SYSKNIFE_DATABASE_PATH \
or run the daemon first",
db_path.display()
),
};
}
let store = match TransactionStore::open_read_only(db_path) {
Ok(s) => s,
Err(e) => {
return VerifyOutcome::CannotVerify {
reason: format!("opening audit database failed: {e}"),
};
}
};
let result = match verifier {
Verifier::Private(key) => store.verify_audit_chain(key),
Verifier::Public(vk_hex) => store.verify_audit_chain_with_pubkey(vk_hex),
};
match result {
Ok(o) => o,
Err(e) => VerifyOutcome::CannotVerify {
reason: format!("audit chain query failed: {e}"),
},
}
}
pub(crate) async fn verify_postgres(
storage: &sysknife_core::config::StorageSection,
verifier: &Verifier,
) -> sysknife_daemon::audit_chain::VerifyOutcome {
use sysknife_core::config::StorageBackend;
use sysknife_daemon::audit_chain::VerifyOutcome;
use sysknife_daemon::store::postgres::{PostgresConfig, PostgresStore};
use sysknife_daemon::store::AuditStore;
let parsed = match storage.parsed() {
Ok(p) => p,
Err(reason) => return VerifyOutcome::CannotVerify { reason },
};
let (url, pool) =
match parsed {
StorageBackend::Sqlite => return VerifyOutcome::CannotVerify {
reason:
"verify_postgres called with backend = \"sqlite\" — caller picks the wrong path"
.to_string(),
},
StorageBackend::Postgres { url, pool } => (url, pool),
};
let mut cfg = PostgresConfig {
url,
..PostgresConfig::default()
};
{
if let Some(n) = pool.max_connections {
cfg.max_connections = n;
}
if let Some(s) = pool.acquire_timeout_secs {
cfg.acquire_timeout = std::time::Duration::from_secs(s);
}
if let Some(c) = pool.statement_cache_capacity {
cfg.statement_cache_capacity = c;
}
}
match verifier {
Verifier::Private(key) => {
let store =
match PostgresStore::connect(&cfg, std::sync::Arc::new((**key).clone())).await {
Ok(s) => s,
Err(e) => {
return VerifyOutcome::CannotVerify {
reason: format!("postgres connect failed: {e}"),
};
}
};
match store.verify_audit_chain(key).await {
Ok(outcome) => outcome,
Err(e) => VerifyOutcome::CannotVerify {
reason: format!("postgres audit chain query failed: {e}"),
},
}
}
Verifier::Public(verifying_key_hex) => {
match PostgresStore::verify_with_pubkey(&cfg, verifying_key_hex).await {
Ok(outcome) => outcome,
Err(e) => VerifyOutcome::CannotVerify {
reason: format!("postgres audit chain query failed: {e}"),
},
}
}
}
}
fn emit_verify_outcome(
args: &AuditVerifyArgs,
log: &Logger,
outcome: &sysknife_daemon::audit_chain::VerifyOutcome,
backend_label: &str,
) {
use sysknife_daemon::audit_chain::VerifyOutcome;
if args.json {
let payload = match outcome {
VerifyOutcome::Intact { rows_checked } => json!({
"status": "intact",
"rows_checked": rows_checked,
"backend": backend_label,
}),
VerifyOutcome::Broken {
rows_checked,
first_broken_seq,
first_broken_transaction_id,
expected,
actual,
} => json!({
"status": "broken",
"rows_checked": rows_checked,
"first_broken_seq": first_broken_seq,
"first_broken_transaction_id": first_broken_transaction_id,
"expected": expected,
"actual": actual,
"backend": backend_label,
}),
VerifyOutcome::CannotVerify { reason } => json!({
"status": "cannot_verify",
"reason": reason,
"backend": backend_label,
}),
};
log.println(
&serde_json::to_string_pretty(&payload)
.expect("verify outcome payload is serializable"),
);
} else {
match outcome {
VerifyOutcome::Intact { rows_checked } => {
log.println(&format!(
"OK: {rows_checked} row(s) verified in {backend_label}"
));
}
VerifyOutcome::Broken {
rows_checked,
first_broken_seq,
first_broken_transaction_id,
expected,
actual,
} => {
log.println(&format!(
"BROKEN: chain intact for first {rows_checked} row(s); \
row seq={first_broken_seq} (transaction {first_broken_transaction_id}) \
does not chain.\n expected: {expected}\n actual: {actual}"
));
}
VerifyOutcome::CannotVerify { reason } => {
log.println(&format!("CANNOT VERIFY: {reason}"));
}
}
}
}
pub async fn run_intent(intent: String, opts: &RunOpts, log: &Logger) -> Result<(), CliError> {
let config = BrainConfig::from_env().map_err(|e| CliError::ConfigOrDaemon(e.to_string()))?;
let distro = sysknife_core::distro::detect().ok();
let plan_client = DaemonClient::new(opts.socket.clone());
let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel::<PlanEvent>();
let mut planner = LlmPlanner::from_config(config, Box::new(plan_client))
.map_err(CliError::ConfigOrDaemon)?
.with_prefs_path(sysknife_core::config::prefs_path())
.with_progress(progress_tx);
if let Some(ref d) = distro {
planner = planner.with_distro(distro_id_to_hint(d));
}
let spinner = if !opts.json {
Some(crate::render::make_spinner(format!(
"Planning \"{intent}\"…"
)))
} else {
None
};
let spinner_for_task = spinner.clone();
let event_task = tokio::spawn(async move {
while let Some(event) = progress_rx.recv().await {
if let Some(ref pb) = spinner_for_task {
match event {
PlanEvent::Thinking => pb.set_message("Thinking…"),
PlanEvent::QueryingTool(ref name) => {
pb.set_message(format!("Querying {name}…"))
}
PlanEvent::ProposingPlan => pb.set_message("Proposing plan…"),
}
}
}
});
let plan_result = planner.plan_intent(&intent).await;
drop(planner);
if let Err(e) = event_task.await {
eprintln!("sysknife: event task panicked: {e}");
}
if let Some(ref pb) = spinner {
pb.finish_and_clear();
}
let plan = plan_result.map_err(|e| CliError::PlanningFailed(e.to_string()))?;
for step in plan.steps() {
let authoritative = authoritative_plan_risk(step.action_name());
if *step.risk_level() != authoritative {
log.print_stderr(&format!(
"sysknife: {} — planner rated {} risk; using {} (ActionSpec-derived)",
step.action_name(),
step.risk_level().as_str(),
authoritative.as_str(),
));
}
}
let plan = plan.with_authoritative_risks(authoritative_plan_risk);
if opts.json {
let steps: Vec<Value> = plan
.steps()
.iter()
.map(|s| {
json!({
"action": s.action_name(),
"summary": s.summary(),
"risk": s.risk_level().as_str(),
"params": s.params(),
})
})
.collect();
log.println(
&serde_json::to_string(&json!({
"plan": { "intent": plan.intent(), "summary": plan.summary(), "steps": steps }
}))
.expect("static JSON"),
);
} else {
crate::render::print_plan(&plan, log);
}
if opts.dry_run {
if opts.step_by_step {
log.print_stderr("warning: --step-by-step has no effect with --dry-run");
}
return Ok(());
}
let policy = opts.approval_policy();
if !opts.step_by_step {
match policy.decide_plan(&plan) {
ApprovalDecision::AutoApproved => {}
ApprovalDecision::RequiresPrompt => {
let n = plan.steps().len();
let highest = highest_risk(&plan).expect("plan has steps");
let msg = if opts.json {
"Execute this plan?".to_owned()
} else {
format!(
" {} step{}, {} risk — execute?",
n,
if n == 1 { "" } else { "s" },
crate::render::risk_colored(highest),
)
};
if !prompt_confirm(&msg).await {
return Err(CliError::Rejected);
}
}
ApprovalDecision::RequiresInteraction => return Err(CliError::NonInteractive),
ApprovalDecision::ExceedsCeiling => {
let highest =
highest_risk(&plan).expect("ExceedsCeiling implies at least one step");
return Err(CliError::RiskCeilingExceeded {
highest: highest.clone(),
ceiling: opts
.max_risk
.expect("ExceedsCeiling implies --max-risk was set"),
});
}
}
}
if !opts.dry_run {
for step in plan.steps() {
if let Err(msg) =
crate::distro_routing::check_action_distro(step.action_name(), distro.as_ref())
{
return Err(CliError::ConfigOrDaemon(msg));
}
}
}
let exec_client = DaemonClient::new(opts.socket.clone());
let start = std::time::Instant::now();
for step in plan.steps() {
if opts.step_by_step {
match policy.decide_step(step.risk_level()) {
ApprovalDecision::AutoApproved => {}
ApprovalDecision::RequiresPrompt => {
let msg = if opts.json {
format!("Execute {} ({})?", step.action_name(), step.summary())
} else {
format!(
"Execute {} ({} risk)?",
step.action_name(),
crate::render::risk_colored(step.risk_level()),
)
};
if !prompt_confirm(&msg).await {
return Err(CliError::Rejected);
}
}
ApprovalDecision::RequiresInteraction => return Err(CliError::NonInteractive),
ApprovalDecision::ExceedsCeiling => {
return Err(CliError::RiskCeilingExceeded {
highest: step.risk_level().clone(),
ceiling: opts
.max_risk
.expect("ExceedsCeiling implies --max-risk was set"),
});
}
}
}
let prepared = exec_client
.preview(step.action_name(), step.params())
.await?;
let preview = &prepared.preview;
let daemon_risk = plan_risk_of(preview.risk_level);
if !daemon_risk_within_approved(step.risk_level(), &daemon_risk) {
return Err(CliError::ConfigOrDaemon(format!(
"{}: the running daemon rates this {} risk, above the {} the CLI approved — \
the CLI and daemon builds may differ. Aborting without executing; upgrade the \
CLI (or re-run with a matching --max-risk) so risk gating agrees.",
step.action_name(),
daemon_risk.as_str(),
step.risk_level().as_str(),
)));
}
if opts.json {
log.println(&serde_json::to_string(preview).expect("PreviewEnvelope is Serialize"));
} else {
crate::render::print_step_header(step.action_name(), preview);
}
let exec_spinner: Option<indicatif::ProgressBar> = if !opts.json {
Some(crate::render::make_spinner(format!(
"Executing {}…",
step.action_name()
)))
} else {
None
};
let exec_spinner_ref = exec_spinner.clone();
let mut first_line = true;
let approval_receipt = exec_client.approve(&prepared.transaction_id).await?;
let exec_result = exec_client
.execute(
&prepared.transaction_id,
step.action_name(),
step.params(),
&approval_receipt,
|line| {
if first_line {
if let Some(ref pb) = exec_spinner_ref {
pb.finish_and_clear();
}
first_line = false;
}
if opts.json {
log.println(line);
} else {
crate::render::print_output_line(line, log);
}
},
)
.await;
if let Some(ref pb) = exec_spinner {
pb.finish_and_clear();
}
exec_result.map(|result| {
if opts.json {
log.println(&serde_json::to_string(&result).expect("ResultEnvelope is Serialize"));
} else {
crate::render::print_step_done(&result, log);
}
})?;
}
if !opts.json {
crate::render::print_success(start.elapsed().as_secs_f32(), log);
}
Ok(())
}
pub async fn run_repl(opts: &RunOpts, log: &Logger) -> Result<(), CliError> {
use rustyline::{error::ReadlineError, DefaultEditor};
let history_path =
std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share/sysknife/history"));
let mut rl = DefaultEditor::new()
.map_err(|e| CliError::ExecutionFailed(format!("readline init: {e}")))?;
if let Some(ref p) = history_path {
if let Some(parent) = p.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!(
"sysknife: failed to create history directory {}: {e}",
parent.display()
);
}
}
match rl.load_history(p) {
Ok(()) => {}
Err(rustyline::error::ReadlineError::Io(e))
if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
eprintln!("sysknife: failed to load history from {}: {e}", p.display());
}
}
}
loop {
let readline_result = tokio::task::block_in_place(|| rl.readline("sysknife> "));
match readline_result {
Ok(line) => {
let intent = line.trim().to_string();
let _ = rl.add_history_entry(line.as_str());
if intent.is_empty() {
continue;
}
if matches!(intent.as_str(), "exit" | "quit") {
break;
}
if let Err(e) = run_intent(intent, opts, log).await {
log.print_stderr(&format!("error: {e}"));
}
}
Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => break,
Err(e) => {
log.print_stderr(&format!("readline error: {e}"));
break;
}
}
}
if let Some(ref p) = history_path {
if let Err(e) = rl.save_history(p) {
eprintln!("sysknife: failed to save history to {}: {e}", p.display());
}
}
Ok(())
}
async fn prompt_confirm(msg: &str) -> bool {
use tokio::io::AsyncBufReadExt as _;
eprint!("{msg} [y/N] ");
let _ = io::stderr().flush();
let stdin = tokio::io::stdin();
let mut reader = tokio::io::BufReader::new(stdin);
let mut buf = String::new();
match reader.read_line(&mut buf).await {
Ok(0) => {
eprintln!("\nsysknife: stdin closed (EOF) — treating as 'no'");
false
}
Err(e) => {
eprintln!("\nsysknife: stdin read error ({e}) — treating as 'no'");
false
}
Ok(_) => matches!(buf.trim().to_ascii_lowercase().as_str(), "y" | "yes"),
}
}
async fn prompt_exact(msg: &str, expected: &str) -> bool {
use tokio::io::AsyncBufReadExt as _;
eprint!("{msg} ({expected}): ");
let _ = io::stderr().flush();
let mut reader = tokio::io::BufReader::new(tokio::io::stdin());
let mut buf = String::new();
match reader.read_line(&mut buf).await {
Ok(0) | Err(_) => false,
Ok(_) => buf.trim() == expected,
}
}
#[cfg(test)]
mod tests {
use super::*;
use sysknife_brain::action_name::ActionName;
use sysknife_brain::planner::{Plan, PlanStep};
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn rfc3339_unix_epoch_z() {
assert_eq!(rfc3339_to_unix("1970-01-01T00:00:00Z"), Some(0));
}
#[test]
fn rfc3339_unix_epoch_plus00() {
assert_eq!(rfc3339_to_unix("1970-01-01T00:00:00+00:00"), Some(0));
}
#[test]
fn rfc3339_unix_one_day() {
assert_eq!(rfc3339_to_unix("1970-01-02T00:00:00Z"), Some(86_400));
}
#[test]
fn rfc3339_unix_y2k() {
assert_eq!(rfc3339_to_unix("2000-01-01T00:00:00Z"), Some(946_684_800));
}
#[test]
fn rfc3339_unix_leap_day_2000() {
assert_eq!(rfc3339_to_unix("2000-02-29T00:00:00Z"), Some(951_782_400));
}
#[test]
fn rfc3339_unix_with_subseconds() {
assert_eq!(
rfc3339_to_unix("2000-01-01T00:00:00.123456Z"),
Some(946_684_800)
);
}
#[test]
fn rfc3339_unix_non_utc_returns_none() {
assert!(rfc3339_to_unix("2000-01-01T00:00:00+05:00").is_none());
}
#[test]
fn rfc3339_unix_no_suffix_returns_none() {
assert!(rfc3339_to_unix("2000-01-01T00:00:00").is_none());
}
#[test]
fn rfc3339_unix_garbage_returns_none() {
assert!(rfc3339_to_unix("not-a-date").is_none());
assert!(rfc3339_to_unix("").is_none());
}
#[test]
fn rfc3339_unix_invalid_month_returns_none() {
assert!(rfc3339_to_unix("2000-13-01T00:00:00Z").is_none());
}
#[test]
fn rfc3339_unix_invalid_hour_returns_none() {
assert!(rfc3339_to_unix("2000-01-01T25:00:00Z").is_none());
}
#[test]
fn rfc3339_unix_day_zero_returns_none() {
assert!(rfc3339_to_unix("2000-01-00T00:00:00Z").is_none());
}
#[test]
fn since_to_hours_y2k_is_many_hours_ago() {
let h = since_to_hours("2000-01-01T00:00:00Z").expect("should parse");
assert!(h > 200_000, "expected >200000 hours, got {h}");
}
#[test]
fn since_to_hours_far_future_returns_none() {
assert!(since_to_hours("9999-12-31T23:59:59Z").is_none());
}
#[test]
fn since_to_hours_garbage_returns_none() {
assert!(since_to_hours("not-a-date").is_none());
}
#[test]
fn since_to_hours_epoch_returns_many_hours() {
let h = since_to_hours("1970-01-01T00:00:00Z").expect("should parse");
assert!(h > 486_000, "expected >486000, got {h}");
}
#[test]
fn since_to_hours_integer_division_not_modulo() {
let h0 = since_to_hours("1970-01-01T00:00:00Z").unwrap();
let h1 = since_to_hours("1970-01-01T01:00:00Z").unwrap();
assert_eq!(h0, h1 + 1, "timestamps 1 h apart must differ by exactly 1");
}
fn make_step(risk: PlanRiskLevel) -> PlanStep {
PlanStep::new(
ActionName::parse("GetDiskUsage").unwrap(),
"test".into(),
risk,
serde_json::json!({}),
)
.unwrap()
}
fn make_plan(risks: &[PlanRiskLevel]) -> Plan {
Plan::new(
"test".into(),
"test plan".into(),
"explanation".into(),
risks.iter().map(|r| make_step(r.clone())).collect(),
)
.unwrap()
}
#[test]
fn highest_risk_single_low() {
let plan = make_plan(&[PlanRiskLevel::Low]);
assert_eq!(highest_risk(&plan), Some(&PlanRiskLevel::Low));
}
#[test]
fn highest_risk_all_high() {
let plan = make_plan(&[PlanRiskLevel::High, PlanRiskLevel::High]);
assert_eq!(highest_risk(&plan), Some(&PlanRiskLevel::High));
}
#[test]
fn highest_risk_mixed_picks_highest() {
let plan = make_plan(&[
PlanRiskLevel::Low,
PlanRiskLevel::High,
PlanRiskLevel::Medium,
]);
assert_eq!(highest_risk(&plan), Some(&PlanRiskLevel::High));
}
#[test]
fn highest_risk_low_medium_picks_medium() {
let plan = make_plan(&[PlanRiskLevel::Low, PlanRiskLevel::Medium]);
assert_eq!(highest_risk(&plan), Some(&PlanRiskLevel::Medium));
}
#[test]
fn authoritative_plan_risk_maps_spec_gate_risk() {
assert_eq!(authoritative_plan_risk("GetDiskUsage"), PlanRiskLevel::Low);
assert_eq!(
authoritative_plan_risk("RestartService"),
PlanRiskLevel::Medium
);
assert_eq!(authoritative_plan_risk("RebootSystem"), PlanRiskLevel::High);
assert_eq!(
authoritative_plan_risk("DefinitelyNotARealAction"),
PlanRiskLevel::High
);
}
#[test]
fn authoritative_risks_close_the_llm_under_rating_gate() {
let mk = |name: &str, risk| {
PlanStep::new(
ActionName::parse(name).unwrap(),
"s".into(),
risk,
serde_json::json!({}),
)
.unwrap()
};
let under_rated = Plan::new(
"i".into(),
"s".into(),
"e".into(),
vec![mk("RebootSystem", PlanRiskLevel::Low)],
)
.unwrap();
let policy = ApprovalPolicy::new(true, Some(MaxRisk::Medium), false, false);
assert_eq!(
policy.decide_plan(&under_rated),
ApprovalDecision::AutoApproved,
"sanity: the LLM's Low rating alone would have auto-approved"
);
let corrected = under_rated.with_authoritative_risks(authoritative_plan_risk);
assert_eq!(
policy.decide_plan(&corrected),
ApprovalDecision::ExceedsCeiling,
"spec-derived High must not auto-approve under --max-risk medium"
);
}
#[test]
fn authoritative_risks_close_the_under_rating_gate_for_bare_yes() {
let mk = |name: &str, risk| {
PlanStep::new(
ActionName::parse(name).unwrap(),
"s".into(),
risk,
serde_json::json!({}),
)
.unwrap()
};
let plan = Plan::new(
"i".into(),
"s".into(),
"e".into(),
vec![mk("RestartService", PlanRiskLevel::Low)],
)
.unwrap();
let policy = ApprovalPolicy::new(true, None, false, false);
assert_eq!(
policy.decide_plan(&plan),
ApprovalDecision::AutoApproved,
"sanity: bare --yes would auto-approve the Low the planner claimed"
);
let corrected = plan.with_authoritative_risks(authoritative_plan_risk);
assert_eq!(
policy.decide_plan(&corrected),
ApprovalDecision::RequiresPrompt,
"spec-derived Medium must prompt under bare --yes"
);
}
#[test]
fn authoritative_risks_force_interaction_for_under_rated_action_non_interactive() {
let mk = |name: &str, risk| {
PlanStep::new(
ActionName::parse(name).unwrap(),
"s".into(),
risk,
serde_json::json!({}),
)
.unwrap()
};
let plan = Plan::new(
"i".into(),
"s".into(),
"e".into(),
vec![mk("RestartService", PlanRiskLevel::Low)],
)
.unwrap();
let policy = ApprovalPolicy::new(true, None, true, false);
assert_eq!(
policy.decide_plan(&plan),
ApprovalDecision::AutoApproved,
"sanity: the planner's Low would have auto-run unattended"
);
let corrected = plan.with_authoritative_risks(authoritative_plan_risk);
assert_eq!(
policy.decide_plan(&corrected),
ApprovalDecision::RequiresInteraction,
"under-rated Medium must abort a non-interactive run"
);
}
#[test]
fn authoritative_risks_open_the_over_rating_gate() {
let mk = |name: &str, risk| {
PlanStep::new(
ActionName::parse(name).unwrap(),
"s".into(),
risk,
serde_json::json!({}),
)
.unwrap()
};
let plan = Plan::new(
"i".into(),
"s".into(),
"e".into(),
vec![mk("GetDiskUsage", PlanRiskLevel::High)],
)
.unwrap();
let policy = ApprovalPolicy::new(true, Some(MaxRisk::Low), false, false);
assert_eq!(
policy.decide_plan(&plan),
ApprovalDecision::ExceedsCeiling,
"sanity: the planner's inflated High would block a safe read-only action"
);
let corrected = plan.with_authoritative_risks(authoritative_plan_risk);
assert_eq!(
policy.decide_plan(&corrected),
ApprovalDecision::AutoApproved,
"spec-derived Low must auto-approve under --max-risk low"
);
}
#[test]
fn daemon_risk_within_approved_fails_closed_on_upward_skew() {
use PlanRiskLevel::{High, Low, Medium};
assert!(daemon_risk_within_approved(&Medium, &Medium));
assert!(daemon_risk_within_approved(&High, &Low));
assert!(daemon_risk_within_approved(&Medium, &Low));
assert!(!daemon_risk_within_approved(&Medium, &High));
assert!(!daemon_risk_within_approved(&Low, &Medium));
assert!(!daemon_risk_within_approved(&Low, &High));
}
#[test]
fn build_history_params_minimal() {
let p = build_history_params(20, None, None, None);
assert_eq!(p["limit"], json!(20));
assert!(p.get("status_filter").is_none());
assert!(p.get("action_filter").is_none());
assert!(p.get("since_hours").is_none());
}
#[test]
fn build_history_params_all_fields() {
let p = build_history_params(5, Some("succeeded"), Some("InstallPackages"), Some(48));
assert_eq!(p["limit"], json!(5));
assert_eq!(p["status_filter"], json!("succeeded"));
assert_eq!(p["action_filter"], json!("InstallPackages"));
assert_eq!(p["since_hours"], json!(48));
}
#[test]
fn build_history_params_status_only() {
let p = build_history_params(10, Some("failed"), None, None);
assert_eq!(p["limit"], json!(10));
assert_eq!(p["status_filter"], json!("failed"));
assert!(p.get("action_filter").is_none());
assert!(p.get("since_hours").is_none());
}
#[tokio::test]
async fn run_history_invalid_since_returns_config_error() {
let args = HistoryArgs {
status: None,
action: None,
since: Some("not-a-date".into()),
limit: 20,
};
let log = Logger::new(None).unwrap();
let result = run_history(
args,
SocketTarget::Unix(PathBuf::from("/nonexistent.sock")),
&log,
)
.await;
match result {
Err(CliError::ConfigOrDaemon(msg)) => {
assert!(
msg.contains("--since"),
"error message must reference --since"
);
}
other => panic!("expected ConfigOrDaemon, got {other:?}"),
}
}
#[tokio::test]
async fn run_history_future_since_returns_config_error() {
let args = HistoryArgs {
status: None,
action: None,
since: Some("9999-12-31T23:59:59Z".into()),
limit: 20,
};
let log = Logger::new(None).unwrap();
let result = run_history(
args,
SocketTarget::Unix(PathBuf::from("/nonexistent.sock")),
&log,
)
.await;
match result {
Err(CliError::ConfigOrDaemon(msg)) => {
assert!(msg.contains("future"), "error must say 'future'");
}
other => panic!("expected ConfigOrDaemon, got {other:?}"),
}
}
#[test]
fn resolve_socket_target_defaults_to_unix_when_unset() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::remove_var("SYSKNIFE_SOCKET") };
let t = resolve_socket_target();
assert_eq!(
t,
crate::client::SocketTarget::Unix(PathBuf::from("/run/sysknife/daemon.sock"))
);
}
#[test]
fn resolve_socket_target_parses_unix_uri() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("SYSKNIFE_SOCKET", "unix:///tmp/custom.sock") };
let t = resolve_socket_target();
unsafe { std::env::remove_var("SYSKNIFE_SOCKET") };
assert_eq!(
t,
crate::client::SocketTarget::Unix(PathBuf::from("/tmp/custom.sock"))
);
}
#[cfg(target_os = "linux")]
#[test]
fn resolve_socket_target_parses_vsock_uri() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("SYSKNIFE_SOCKET", "vsock://3:7777") };
let t = resolve_socket_target();
unsafe { std::env::remove_var("SYSKNIFE_SOCKET") };
assert_eq!(t, crate::client::SocketTarget::Vsock { cid: 3, port: 7777 });
}
}