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 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-05 00:31:31 -05:00
parent a9a59bfc7f
commit 7580be75ea
5 changed files with 201 additions and 14 deletions

1
Cargo.lock generated
View file

@ -199,6 +199,7 @@ dependencies = [
"rustls",
"rustls-pemfile",
"semver",
"serde",
"serde_json",
"tempfile",
"tokio",

View file

@ -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<String>,
/// Model name (defaults per provider)
#[arg(long)]
pub model: Option<String>,
/// Permission level for tool execution
#[arg(long, default_value = "read-write", value_enum)]
pub permissions: PermissionLevel,
#[arg(long, value_enum)]
pub permissions: Option<PermissionLevel>,
/// Skip interactive prompts; deny tools outside permission level
#[arg(long)]
@ -46,8 +46,8 @@ pub struct AgentArgs {
pub skills_dir: Option<String>,
/// 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<OutputFormat>,
}
#[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<dyn crate::Sandbox> = 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 {

View file

@ -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

View file

@ -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<String>,
pub model: Option<String>,
pub permissions: Option<String>,
pub output_format: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
pub struct LlmDefaults {
pub model: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
pub struct CliConfig {
#[serde(default)]
pub agent: Option<AgentDefaults>,
#[serde(default)]
pub llm: Option<LlmDefaults>,
}
/// 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<CliConfig> {
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());
}
}

View file

@ -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 =