mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
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:
parent
a9a59bfc7f
commit
7580be75ea
5 changed files with 201 additions and 14 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -199,6 +199,7 @@ dependencies = [
|
|||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
122
crates/arc-cli/src/cli_config.rs
Normal file
122
crates/arc-cli/src/cli_config.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue