use std::ffi::OsString;
use clap::{Args, Parser, Subcommand, ValueEnum};
use crate::approval::MaxRisk;
#[derive(Parser, Debug)]
#[command(name = "sysknife", version, about = "Linux System Management CLI")]
pub struct Cli {
#[arg(long, global = true)]
pub yes: bool,
#[arg(long, value_name = "LEVEL", global = true)]
pub max_risk: Option<MaxRiskArg>,
#[arg(long, global = true)]
pub non_interactive: bool,
#[arg(long, global = true)]
pub dry_run: bool,
#[arg(long, global = true)]
pub json: bool,
#[arg(long, value_name = "SECS", global = true)]
pub timeout: Option<u64>,
#[arg(long, value_name = "FILE", global = true)]
pub log_to: Option<std::path::PathBuf>,
#[arg(long, global = true)]
pub step_by_step: bool,
#[arg(long, global = true)]
pub dangerously_skip_approval: bool,
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Subcommand, Debug)]
pub enum Command {
Doctor,
History(HistoryArgs),
Approve {
transaction_id: String,
},
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
#[command(name = "mcp-server")]
McpServer,
Audit {
#[command(subcommand)]
command: AuditCommand,
},
#[command(external_subcommand)]
Intent(Vec<OsString>),
}
impl Command {
pub fn intent_string(&self) -> Option<String> {
match self {
Command::Intent(words) => Some(
words
.iter()
.map(|w| w.to_string_lossy())
.collect::<Vec<_>>()
.join(" "),
),
_ => None,
}
}
}
#[derive(Subcommand, Debug, Clone)]
pub enum AuditCommand {
Export(AuditExportArgs),
Verify(AuditVerifyArgs),
Checkpoint(AuditCheckpointArgs),
}
#[derive(Args, Debug, Clone)]
pub struct AuditExportArgs {
#[arg(long, value_name = "DATETIME")]
pub since: Option<String>,
#[arg(long, value_name = "N")]
pub limit: Option<u32>,
}
#[derive(Args, Debug, Clone)]
pub struct AuditVerifyArgs {
#[arg(long)]
pub json: bool,
#[arg(long, value_name = "FILE")]
pub pubkey: Option<std::path::PathBuf>,
}
#[derive(Args, Debug, Clone)]
pub struct AuditCheckpointArgs {
#[arg(long, value_name = "URL")]
pub db: Option<String>,
}
#[derive(Args, Debug, Clone)]
pub struct HistoryArgs {
#[arg(long, value_name = "STATUS")]
pub status: Option<String>,
#[arg(long, value_name = "ACTION")]
pub action: Option<String>,
#[arg(long, value_name = "DATETIME")]
pub since: Option<String>,
#[arg(long, default_value = "20", value_name = "N")]
pub limit: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum MaxRiskArg {
Low,
Medium,
High,
}
impl From<MaxRiskArg> for MaxRisk {
fn from(arg: MaxRiskArg) -> MaxRisk {
match arg {
MaxRiskArg::Low => MaxRisk::Low,
MaxRiskArg::Medium => MaxRisk::Medium,
MaxRiskArg::High => MaxRisk::High,
}
}
}
pub const UNATTENDED_CONSENT_VAR: &str = "SYSKNIFE_I_ACCEPT_UNATTENDED_ROOT";
pub const UNATTENDED_CONSENT_VALUE: &str = "1";
#[derive(Debug, PartialEq, Eq)]
pub enum UnattendedConsent {
NotRequested,
Granted,
FlagWithoutEnv,
EnvWithoutFlag,
}
impl UnattendedConsent {
pub fn resolve(flag: bool, env_value: Option<&str>) -> Self {
let env_ok = env_value == Some(UNATTENDED_CONSENT_VALUE);
match (flag, env_ok) {
(true, true) => Self::Granted,
(true, false) => Self::FlagWithoutEnv,
(false, true) => Self::EnvWithoutFlag,
(false, false) => Self::NotRequested,
}
}
pub fn from_env(flag: bool) -> Self {
let raw = std::env::var(UNATTENDED_CONSENT_VAR).ok();
Self::resolve(flag, raw.as_deref())
}
pub fn is_granted(&self) -> bool {
matches!(self, Self::Granted)
}
pub fn refusal(&self) -> Option<String> {
match self {
Self::FlagWithoutEnv => Some(format!(
"--dangerously-skip-approval was passed but {UNATTENDED_CONSENT_VAR} is not set to \
{UNATTENDED_CONSENT_VALUE}.\n\nThis flag lets an LLM's plan execute HIGH-risk \
actions as root with nobody watching. It needs two keys on purpose, so that \
neither a flag left in a script nor a variable left in a profile is enough on its \
own.\n\nIf you meant it: {UNATTENDED_CONSENT_VAR}={UNATTENDED_CONSENT_VALUE} \
sysknife --dangerously-skip-approval ..."
)),
_ => None,
}
}
}
#[cfg(test)]
mod unattended_consent_tests {
use super::*;
#[test]
fn both_keys_grant() {
assert_eq!(
UnattendedConsent::resolve(true, Some("1")),
UnattendedConsent::Granted
);
assert!(UnattendedConsent::resolve(true, Some("1")).is_granted());
}
#[test]
fn the_flag_alone_refuses_and_says_why() {
let c = UnattendedConsent::resolve(true, None);
assert_eq!(c, UnattendedConsent::FlagWithoutEnv);
assert!(!c.is_granted());
let msg = c.refusal().expect("a refusal must carry a message");
assert!(
msg.contains(UNATTENDED_CONSENT_VAR),
"names the variable: {msg}"
);
assert!(msg.contains("as root"), "says what is at stake: {msg}");
}
#[test]
fn the_variable_alone_does_not_arm_anything() {
let c = UnattendedConsent::resolve(false, Some("1"));
assert_eq!(c, UnattendedConsent::EnvWithoutFlag);
assert!(
!c.is_granted(),
"an armed profile must not skip approval on its own"
);
assert!(c.refusal().is_none(), "it is not an error, only inert");
}
#[test]
fn neither_key_is_ordinary_operation() {
let c = UnattendedConsent::resolve(false, None);
assert_eq!(c, UnattendedConsent::NotRequested);
assert!(!c.is_granted());
assert!(c.refusal().is_none());
}
#[test]
fn only_the_exact_value_counts() {
for v in ["0", "false", "true", "yes", "", " 1", "1 ", "01", "TRUE"] {
assert_eq!(
UnattendedConsent::resolve(true, Some(v)),
UnattendedConsent::FlagWithoutEnv,
"{v:?} must not satisfy the environment half"
);
}
}
#[test]
fn the_flag_parses_and_defaults_off() {
let cli = Cli::parse_from(["sysknife", "check disk"]);
assert!(!cli.dangerously_skip_approval);
let cli = Cli::parse_from(["sysknife", "--dangerously-skip-approval", "check disk"]);
assert!(cli.dangerously_skip_approval);
}
#[test]
fn the_flag_has_no_short_form() {
assert!(Cli::try_parse_from(["sysknife", "-d", "check disk"]).is_err());
assert!(Cli::try_parse_from(["sysknife", "--skip-approval", "check disk"]).is_err());
assert!(Cli::try_parse_from(["sysknife", "--dangerously-skip", "check disk"]).is_err());
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn no_args_parses_with_no_command_and_no_flags() {
let cli = Cli::try_parse_from(["sysknife"]).unwrap();
assert!(cli.command.is_none());
assert!(!cli.yes);
assert!(!cli.dry_run);
assert!(!cli.non_interactive);
assert!(!cli.json);
assert!(!cli.step_by_step);
assert!(cli.timeout.is_none());
assert!(cli.max_risk.is_none());
assert!(cli.log_to.is_none());
}
#[test]
fn multi_word_intent_captured_as_intent_variant() {
let cli = Cli::try_parse_from(["sysknife", "check", "disk", "usage"]).unwrap();
let intent = cli.command.as_ref().unwrap().intent_string().unwrap();
assert_eq!(intent, "check disk usage");
}
#[test]
fn quoted_single_arg_intent_captured_verbatim() {
let cli = Cli::try_parse_from(["sysknife", "check disk usage"]).unwrap();
let intent = cli.command.as_ref().unwrap().intent_string().unwrap();
assert_eq!(intent, "check disk usage");
}
#[test]
fn yes_flag_parsed() {
let cli = Cli::try_parse_from(["sysknife", "--yes", "intent"]).unwrap();
assert!(cli.yes);
}
#[test]
fn max_risk_low_medium_high_parsed() {
for (input, expected) in [
("low", MaxRiskArg::Low),
("medium", MaxRiskArg::Medium),
("high", MaxRiskArg::High),
] {
let cli = Cli::try_parse_from(["sysknife", "--max-risk", input]).unwrap();
assert_eq!(cli.max_risk.unwrap(), expected);
}
}
#[test]
fn max_risk_arg_converts_to_max_risk() {
assert_eq!(MaxRisk::from(MaxRiskArg::Low), MaxRisk::Low);
assert_eq!(MaxRisk::from(MaxRiskArg::Medium), MaxRisk::Medium);
assert_eq!(MaxRisk::from(MaxRiskArg::High), MaxRisk::High);
}
#[test]
fn dry_run_and_non_interactive_parsed() {
let cli =
Cli::try_parse_from(["sysknife", "--dry-run", "--non-interactive", "intent"]).unwrap();
assert!(cli.dry_run);
assert!(cli.non_interactive);
}
#[test]
fn json_and_timeout_and_step_by_step_parsed() {
let cli = Cli::try_parse_from([
"sysknife",
"--json",
"--timeout",
"30",
"--step-by-step",
"intent",
])
.unwrap();
assert!(cli.json);
assert_eq!(cli.timeout, Some(30));
assert!(cli.step_by_step);
}
#[test]
fn log_to_path_parsed() {
let cli =
Cli::try_parse_from(["sysknife", "--log-to", "/tmp/sysknife.log", "intent"]).unwrap();
assert_eq!(
cli.log_to.as_deref(),
Some(std::path::Path::new("/tmp/sysknife.log"))
);
}
#[test]
fn doctor_subcommand_parsed() {
let cli = Cli::try_parse_from(["sysknife", "doctor"]).unwrap();
assert!(matches!(cli.command, Some(Command::Doctor)));
}
#[test]
fn history_subcommand_default_limit() {
let cli = Cli::try_parse_from(["sysknife", "history"]).unwrap();
match cli.command {
Some(Command::History(args)) => {
assert_eq!(args.limit, 20);
assert!(args.status.is_none());
assert!(args.action.is_none());
assert!(args.since.is_none());
}
other => panic!("expected Command::History, got {other:?}"),
}
}
#[test]
fn history_subcommand_with_all_flags() {
let cli = Cli::try_parse_from([
"sysknife",
"history",
"--limit",
"5",
"--status",
"succeeded",
"--action",
"InstallPackages",
"--since",
"2026-01-01T00:00:00Z",
])
.unwrap();
match cli.command {
Some(Command::History(args)) => {
assert_eq!(args.limit, 5);
assert_eq!(args.status.as_deref(), Some("succeeded"));
assert_eq!(args.action.as_deref(), Some("InstallPackages"));
assert_eq!(args.since.as_deref(), Some("2026-01-01T00:00:00Z"));
}
other => panic!("expected Command::History, got {other:?}"),
}
}
#[test]
fn audit_export_subcommand_with_filters() {
let cli = Cli::try_parse_from([
"sysknife",
"audit",
"export",
"--since",
"2026-01-01T00:00:00Z",
"--limit",
"5",
])
.unwrap();
match cli.command {
Some(Command::Audit {
command: AuditCommand::Export(args),
}) => {
assert_eq!(args.since.as_deref(), Some("2026-01-01T00:00:00Z"));
assert_eq!(args.limit, Some(5));
}
other => panic!("expected audit export, got {other:?}"),
}
}
#[test]
fn global_flags_work_before_subcommand() {
let cli = Cli::try_parse_from(["sysknife", "--yes", "--dry-run", "doctor"]).unwrap();
assert!(cli.yes);
assert!(cli.dry_run);
assert!(matches!(cli.command, Some(Command::Doctor)));
}
#[test]
fn intent_string_returns_none_for_non_intent_commands() {
let cli = Cli::try_parse_from(["sysknife", "doctor"]).unwrap();
assert!(cli.command.unwrap().intent_string().is_none());
}
#[test]
fn completions_subcommand_parsed() {
let cli = Cli::try_parse_from(["sysknife", "completions", "bash"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Completions {
shell: clap_complete::Shell::Bash
})
));
}
#[test]
fn completions_unknown_shell_fails() {
assert!(Cli::try_parse_from(["sysknife", "completions", "zsh-invalid"]).is_err());
}
#[test]
fn history_with_partial_flags() {
let cli =
Cli::try_parse_from(["sysknife", "history", "--status", "failed", "--limit", "10"])
.unwrap();
match cli.command {
Some(Command::History(args)) => {
assert_eq!(args.status.as_deref(), Some("failed"));
assert_eq!(args.limit, 10);
assert!(args.action.is_none());
assert!(args.since.is_none());
}
other => panic!("expected Command::History, got {other:?}"),
}
}
}