oy-cli 0.8.3

Local AI coding CLI for inspecting, editing, running commands, and auditing repositories
Documentation
use crate::tools::{Approval, FileAccess, ToolPolicy};
use anyhow::{Result, bail};
use serde::{Deserialize, Deserializer, Serialize};

const PLAN_SYSTEM: &str = r#"PLAN mode. Stay read-only. Use only list, read, search, sloc, todo for in-memory planning, ask when interactive, and webfetch when available. Keep files unchanged, skip shell commands, and describe changes as proposed rather than applied."#;
const ACCEPT_EDITS_SYSTEM: &str = r#"ACCEPT-EDITS mode. File edits may run without asking. Keep edits small and targeted, inspect before changing, and reach for `bash` only when genuinely necessary."#;
const AUTO_APPROVE_SYSTEM: &str = r#"AUTO-APPROVE mode. Tools may run without asking. Still avoid destructive commands, broad rewrites, credential exposure, persistence changes, and network/file/process expansion unless clearly needed. Treat shell and replacement tools as strict side effects: inspect first, then run the smallest command/edit."#;

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
pub enum SafetyMode {
    #[default]
    #[serde(rename = "default")]
    Default,
    #[serde(rename = "plan")]
    Plan,
    #[serde(rename = "accept-edits")]
    AutoEdits,
    #[serde(rename = "auto-approve")]
    AutoAll,
}

impl SafetyMode {
    pub fn parse(value: &str) -> Result<Self> {
        match value.trim().to_ascii_lowercase().replace('_', "-").as_str() {
            "" | "default" | "ask" => Ok(Self::Default),
            "plan" | "read-only" | "readonly" | "read" => Ok(Self::Plan),
            "accept-edits" | "edit" | "edits" | "auto-edits" | "write" => Ok(Self::AutoEdits),
            "auto-approve" | "auto" | "yolo" => Ok(Self::AutoAll),
            other => bail!("Unknown mode `{other}`. Available: plan, ask, edit, auto"),
        }
    }

    pub fn name(self) -> &'static str {
        match self {
            Self::Default => "default",
            Self::Plan => "plan",
            Self::AutoEdits => "accept-edits",
            Self::AutoAll => "auto-approve",
        }
    }

    pub(super) fn system_prompt_suffix(self) -> &'static str {
        match self {
            Self::Default => "",
            Self::Plan => PLAN_SYSTEM,
            Self::AutoEdits => ACCEPT_EDITS_SYSTEM,
            Self::AutoAll => AUTO_APPROVE_SYSTEM,
        }
    }

    pub fn policy(self) -> ToolPolicy {
        match self {
            Self::Plan => ToolPolicy::read_only(),
            Self::Default => ToolPolicy::with_write(Approval::Ask, Approval::Ask),
            Self::AutoEdits => ToolPolicy::with_write(Approval::Auto, Approval::Ask),
            Self::AutoAll => ToolPolicy::with_write(Approval::Auto, Approval::Auto),
        }
    }
}

impl std::str::FromStr for SafetyMode {
    type Err = anyhow::Error;

    fn from_str(value: &str) -> Result<Self> {
        Self::parse(value)
    }
}

impl<'de> Deserialize<'de> for SafetyMode {
    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let value = String::deserialize(deserializer)?;
        Self::parse(&value).map_err(serde::de::Error::custom)
    }
}

pub fn tool_policy(mode: SafetyMode) -> ToolPolicy {
    mode.policy()
}

pub fn policy_risk_label(policy: &ToolPolicy) -> &'static str {
    match (policy.files, policy.shell) {
        (FileAccess::ReadOnly, _) => "read-only: no file edits or shell",
        (_, Approval::Auto) => "high: auto shell",
        (FileAccess::Write(Approval::Auto), _) => "medium: auto edits",
        _ => "normal: asks before edits/shell",
    }
}