From 7580be75eac0ec647c782cb1b18c6c1018f8d88c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 5 Mar 2026 00:31:31 -0500 Subject: [PATCH] Add ~/.arc/cli.toml config for persistent CLI defaults Users who always use the same provider/model/permissions no longer need to pass flags every time. Precedence: CLI flag > cli.toml > hardcoded default. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + crates/arc-agent/src/cli.rs | 54 ++++++++++++-- crates/arc-cli/Cargo.toml | 3 +- crates/arc-cli/src/cli_config.rs | 122 +++++++++++++++++++++++++++++++ crates/arc-cli/src/main.rs | 35 +++++++-- 5 files changed, 201 insertions(+), 14 deletions(-) create mode 100644 crates/arc-cli/src/cli_config.rs diff --git a/Cargo.lock b/Cargo.lock index c593a2c98..ee8780065 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -199,6 +199,7 @@ dependencies = [ "rustls", "rustls-pemfile", "semver", + "serde", "serde_json", "tempfile", "tokio", diff --git a/crates/arc-agent/src/cli.rs b/crates/arc-agent/src/cli.rs index 27c445f2b..2ddddfac6 100644 --- a/crates/arc-agent/src/cli.rs +++ b/crates/arc-agent/src/cli.rs @@ -18,16 +18,16 @@ pub struct AgentArgs { pub prompt: String, /// LLM provider (anthropic, openai, gemini, kimi, zai, minimax, inception) - #[arg(long, default_value = "anthropic")] - pub provider: String, + #[arg(long)] + pub provider: Option, /// Model name (defaults per provider) #[arg(long)] pub model: Option, /// Permission level for tool execution - #[arg(long, default_value = "read-write", value_enum)] - pub permissions: PermissionLevel, + #[arg(long, value_enum)] + pub permissions: Option, /// Skip interactive prompts; deny tools outside permission level #[arg(long)] @@ -46,8 +46,8 @@ pub struct AgentArgs { pub skills_dir: Option, /// Output format (text for human-readable, json for NDJSON event stream) - #[arg(long, default_value = "text", value_enum)] - pub output_format: OutputFormat, + #[arg(long, value_enum)] + pub output_format: Option, } #[derive(Parser)] @@ -70,6 +70,41 @@ pub enum PermissionLevel { Full, } +impl AgentArgs { + /// Fill `None` fields from cli.toml values, then hardcoded defaults. + pub fn apply_cli_defaults( + &mut self, + provider: Option<&str>, + model: Option<&str>, + permissions: Option<&str>, + output_format: Option<&str>, + ) { + if self.provider.is_none() { + self.provider = Some( + provider + .map(String::from) + .unwrap_or_else(|| "anthropic".to_string()), + ); + } + if self.model.is_none() { + self.model = model.map(String::from); + } + if self.permissions.is_none() { + self.permissions = Some(match permissions { + Some("read-only") => PermissionLevel::ReadOnly, + Some("full") => PermissionLevel::Full, + _ => PermissionLevel::ReadWrite, + }); + } + if self.output_format.is_none() { + self.output_format = Some(match output_format { + Some("json") => OutputFormat::Json, + _ => OutputFormat::Text, + }); + } + } +} + pub fn default_model(provider: Provider) -> &'static str { match provider { Provider::OpenAi => "gpt-5.2-codex", @@ -345,6 +380,8 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> { // Parse provider string to enum early for compile-time safety let provider: Provider = args .provider + .as_deref() + .unwrap_or("anthropic") .parse() .map_err(|e: String| anyhow::anyhow!("{e}"))?; @@ -378,8 +415,9 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> { let env: Arc = Arc::new(LocalSandbox::new(cwd)); // Build tool approval callback + let permissions = args.permissions.unwrap_or(PermissionLevel::ReadWrite); let is_interactive = std::io::stdin().is_terminal() && !args.auto_approve; - let tool_approval = build_tool_approval(args.permissions, is_interactive, styles); + let tool_approval = build_tool_approval(permissions, is_interactive, styles); let config = SessionConfig { tool_approval: Some(tool_approval), @@ -446,7 +484,7 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> { // Subscribe to events let verbose = args.verbose; - let output_format = args.output_format; + let output_format = args.output_format.unwrap_or(OutputFormat::Text); let mut rx = session.subscribe(); tokio::spawn(async move { match output_format { diff --git a/crates/arc-cli/Cargo.toml b/crates/arc-cli/Cargo.toml index b0a2afa0d..1dd4fedf8 100644 --- a/crates/arc-cli/Cargo.toml +++ b/crates/arc-cli/Cargo.toml @@ -26,6 +26,8 @@ tracing-subscriber.workspace = true tracing-appender.workspace = true chrono.workspace = true dirs.workspace = true +serde.workspace = true +toml.workspace = true futures.workspace = true regex.workspace = true semver.workspace = true @@ -44,4 +46,3 @@ predicates = "3" tempfile = "3" serde_json.workspace = true httpmock = "0.8" -toml.workspace = true diff --git a/crates/arc-cli/src/cli_config.rs b/crates/arc-cli/src/cli_config.rs new file mode 100644 index 000000000..5cee5392d --- /dev/null +++ b/crates/arc-cli/src/cli_config.rs @@ -0,0 +1,122 @@ +use std::path::Path; + +use serde::Deserialize; +use tracing::debug; + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +pub struct AgentDefaults { + pub provider: Option, + pub model: Option, + pub permissions: Option, + pub output_format: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +pub struct LlmDefaults { + pub model: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +pub struct CliConfig { + #[serde(default)] + pub agent: Option, + #[serde(default)] + pub llm: Option, +} + +/// Load CLI config from an explicit path or `~/.arc/cli.toml`, returning defaults if the +/// default file doesn't exist. An explicit path that doesn't exist is an error. +pub fn load_cli_config(path: Option<&Path>) -> anyhow::Result { + if let Some(explicit) = path { + debug!(path = %explicit.display(), "Loading CLI config from explicit path"); + let contents = std::fs::read_to_string(explicit)?; + return Ok(toml::from_str(&contents)?); + } + + let Some(home) = dirs::home_dir() else { + debug!("No home directory found, using default CLI config"); + return Ok(CliConfig::default()); + }; + let default_path = home.join(".arc").join("cli.toml"); + debug!(path = %default_path.display(), "Loading CLI config"); + match std::fs::read_to_string(&default_path) { + Ok(contents) => Ok(toml::from_str(&contents)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(CliConfig::default()), + Err(e) => Err(e.into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_empty_config_defaults() { + let config: CliConfig = toml::from_str("").unwrap(); + assert_eq!(config, CliConfig::default()); + } + + #[test] + fn parse_full_config() { + let toml = r#" +[agent] +provider = "anthropic" +model = "claude-opus-4-6" +permissions = "read-write" +output_format = "text" + +[llm] +model = "claude-sonnet-4-5" +"#; + let config: CliConfig = toml::from_str(toml).unwrap(); + let agent = config.agent.unwrap(); + assert_eq!(agent.provider.as_deref(), Some("anthropic")); + assert_eq!(agent.model.as_deref(), Some("claude-opus-4-6")); + assert_eq!(agent.permissions.as_deref(), Some("read-write")); + assert_eq!(agent.output_format.as_deref(), Some("text")); + let llm = config.llm.unwrap(); + assert_eq!(llm.model.as_deref(), Some("claude-sonnet-4-5")); + } + + #[test] + fn parse_partial_agent_config() { + let toml = r#" +[agent] +provider = "openai" +"#; + let config: CliConfig = toml::from_str(toml).unwrap(); + let agent = config.agent.unwrap(); + assert_eq!(agent.provider.as_deref(), Some("openai")); + assert_eq!(agent.model, None); + assert_eq!(agent.permissions, None); + assert_eq!(agent.output_format, None); + assert_eq!(config.llm, None); + } + + #[test] + fn load_cli_config_from_explicit_path() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("custom.toml"); + std::fs::write( + &path, + r#" +[agent] +provider = "gemini" +model = "gemini-pro" +"#, + ) + .unwrap(); + let config = load_cli_config(Some(&path)).unwrap(); + let agent = config.agent.unwrap(); + assert_eq!(agent.provider.as_deref(), Some("gemini")); + assert_eq!(agent.model.as_deref(), Some("gemini-pro")); + } + + #[test] + fn load_cli_config_explicit_path_missing_is_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nonexistent.toml"); + let result = load_cli_config(Some(&path)); + assert!(result.is_err()); + } +} diff --git a/crates/arc-cli/src/main.rs b/crates/arc-cli/src/main.rs index 89c02bf59..59a9e0660 100644 --- a/crates/arc-cli/src/main.rs +++ b/crates/arc-cli/src/main.rs @@ -1,3 +1,4 @@ +mod cli_config; mod doctor; mod logging; mod setup; @@ -92,6 +93,8 @@ async fn main() -> Result<()> { eprintln!("Warning: failed to initialize logging: {err:#}"); } + let cli_config = cli_config::load_cli_config(None)?; + let command_name = match &cli.command { Command::Llm { .. } => "llm", Command::Agent(_) => "agent", @@ -105,11 +108,33 @@ async fn main() -> Result<()> { debug!(command = %command_name, "CLI command started"); match cli.command { - Command::Llm { command } => match command { - LlmCommand::Prompt(args) => arc_llm::cli::run_prompt(args).await?, - LlmCommand::Chat(args) => arc_llm::cli::run_chat(args).await?, - }, - Command::Agent(args) => arc_agent::cli::run_with_args(args).await?, + Command::Llm { command } => { + let llm_defaults = cli_config.llm.as_ref(); + match command { + LlmCommand::Prompt(mut args) => { + if args.model.is_none() { + args.model = llm_defaults.and_then(|l| l.model.clone()); + } + arc_llm::cli::run_prompt(args).await? + } + LlmCommand::Chat(mut args) => { + if args.model.is_none() { + args.model = llm_defaults.and_then(|l| l.model.clone()); + } + arc_llm::cli::run_chat(args).await? + } + } + } + Command::Agent(mut args) => { + let agent_defaults = cli_config.agent.as_ref(); + args.apply_cli_defaults( + agent_defaults.and_then(|a| a.provider.as_deref()), + agent_defaults.and_then(|a| a.model.as_deref()), + agent_defaults.and_then(|a| a.permissions.as_deref()), + agent_defaults.and_then(|a| a.output_format.as_deref()), + ); + arc_agent::cli::run_with_args(args).await? + } Command::Run { command } => match command { RunCommand::Start(args) => { let styles: &'static arc_util::terminal::Styles =