Make standalone CLI read only cli.toml, never server.toml

Move GitHub App credentials (app_id, slug) and run defaults (llm,
sandbox, pull_request) into cli.toml so standalone commands (run, pr
create, llm) no longer require server.toml. The server.toml loading is
now limited to `arc serve` and server-mode doctor checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-10 13:12:30 -04:00
parent 20b446d1e5
commit c32e74fbb4
3 changed files with 75 additions and 41 deletions

View file

@ -200,9 +200,9 @@ pub fn check_config(path: Option<PathBuf>) -> CheckResult {
status: CheckStatus::Warning,
summary: "no config file found".to_string(),
details: vec![CheckDetail {
text: "Create ~/.arc/server.toml to configure Arc".to_string(),
text: "Create ~/.arc/cli.toml to configure Arc".to_string(),
}],
remediation: Some("Create ~/.arc/server.toml".to_string()),
remediation: Some("Create ~/.arc/cli.toml".to_string()),
},
}
}
@ -989,7 +989,9 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
let styles = Styles::detect_stdout();
// Gather state
let config_path = dirs::home_dir().map(|h| h.join(".arc").join("server.toml"));
let cli_config = arc_config::cli::load_cli_config(None).unwrap_or_default();
let config_path = dirs::home_dir().map(|h| h.join(".arc").join("cli.toml"));
let config_exists = config_path.as_ref().is_some_and(|p| p.exists());
let llm_statuses: Vec<(Provider, bool)> = Provider::ALL
@ -1001,6 +1003,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
let daytona_configured = std::env::var("DAYTONA_API_KEY").is_ok();
#[cfg(feature = "server")]
let server_config = arc_config::server::load_server_config(None).unwrap_or_default();
#[cfg(feature = "server")]
@ -1016,7 +1019,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
allowed_usernames_count: server_config.web.auth.allowed_usernames.len(),
};
let git_app_id = server_config.git.app_id.clone();
let git_app_id = cli_config.git.as_ref().and_then(|g| g.app_id.clone());
let private_key_raw = std::env::var("GITHUB_APP_PRIVATE_KEY").ok();
let sign_result = match (&git_app_id, &private_key_raw) {
(Some(app_id), Some(raw)) => {
@ -1043,7 +1046,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 {
};
let github_status = GithubAppStatus {
app_id: git_app_id,
slug: server_config.git.slug.clone(),
slug: cli_config.git.as_ref().and_then(|g| g.slug.clone()),
private_key_set: private_key_raw.is_some(),
sign_result,
#[cfg(feature = "server")]
@ -1219,9 +1222,9 @@ mod tests {
#[test]
fn check_config_pass_with_path() {
let result = check_config(Some(PathBuf::from("/home/user/.arc/server.toml")));
let result = check_config(Some(PathBuf::from("/home/user/.arc/cli.toml")));
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.summary.contains(".arc/server.toml"));
assert!(result.summary.contains(".arc/cli.toml"));
}
#[test]

View file

@ -127,10 +127,8 @@ enum LlmCommand {
Chat(arc_llm::cli::ChatArgs),
}
fn build_github_app_credentials(
config: &arc_config::server::ServerConfig,
) -> Option<arc_github::GitHubAppCredentials> {
let app_id = config.git.app_id.as_ref()?;
fn build_github_app_credentials(app_id: Option<&str>) -> Option<arc_github::GitHubAppCredentials> {
let app_id = app_id?;
let raw = std::env::var("GITHUB_APP_PRIVATE_KEY").ok()?;
let private_key_pem = if raw.starts_with("-----") {
raw
@ -140,7 +138,7 @@ fn build_github_app_credentials(
String::from_utf8(pem_bytes).ok()?
};
Some(arc_github::GitHubAppCredentials {
app_id: app_id.clone(),
app_id: app_id.to_string(),
private_key_pem,
})
}
@ -231,7 +229,7 @@ async fn main_inner() -> Result<()> {
match cli.command {
Command::Llm { command } => {
let cli_config = cli_config::load_cli_config(None)?;
let llm_defaults = cli_config.llm.as_ref();
let llm_defaults = cli_config.run_defaults.llm.as_ref();
match command {
LlmCommand::Prompt(mut args) => {
if args.model.is_none() {
@ -324,29 +322,21 @@ async fn main_inner() -> Result<()> {
Command::Run(mut args) => {
let styles: &'static arc_util::terminal::Styles =
Box::leak(Box::new(arc_util::terminal::Styles::detect_stderr()));
let server_config = arc_config::server::load_server_config(None)?;
let cli_config = cli_config::load_cli_config(None)?;
args.verbose = args.verbose || cli_config.verbose;
let github_app = build_github_app_credentials(&server_config);
let github_app = build_github_app_credentials(
cli_config.git.as_ref().and_then(|g| g.app_id.as_deref()),
);
let cli_author = cli_config.git.as_ref().map(|g| &g.author);
let git_author = arc_workflows::git::GitAuthor::from_options(
cli_author
.and_then(|a| a.name.clone())
.or_else(|| server_config.git.author.name.clone()),
cli_author
.and_then(|a| a.email.clone())
.or_else(|| server_config.git.author.email.clone()),
cli_author.and_then(|a| a.name.clone()),
cli_author.and_then(|a| a.email.clone()),
);
let mut run_defaults = server_config.run_defaults;
if cli_config.pull_request.is_some() {
run_defaults.pull_request = cli_config.pull_request;
}
arc_workflows::cli::run::run_command(
args,
run_defaults,
cli_config.run_defaults,
styles,
github_app,
git_author,
@ -411,8 +401,10 @@ async fn main_inner() -> Result<()> {
}
Command::Pr { command } => match command {
PrCommand::Create(args) => {
let server_config = arc_config::server::load_server_config(None)?;
let github_app = build_github_app_credentials(&server_config);
let cli_config = cli_config::load_cli_config(None)?;
let github_app = build_github_app_credentials(
cli_config.git.as_ref().and_then(|g| g.app_id.as_deref()),
);
arc_workflows::cli::pr::pr_create_command(args, github_app).await?;
}
},

View file

@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use arc_agent::cli::{OutputFormat, PermissionLevel};
use arc_mcp::config::{McpServerConfig, McpTransport};
use arc_workflows::cli::run_config::PullRequestConfig;
use arc_workflows::cli::run_config::RunDefaults;
use serde::Deserialize;
use tracing::debug;
@ -36,13 +36,10 @@ pub struct ExecDefaults {
pub output_format: Option<OutputFormat>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
pub struct LlmDefaults {
pub model: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
pub struct CliGitConfig {
pub app_id: Option<String>,
pub slug: Option<String>,
#[serde(default)]
pub author: crate::server::GitAuthorConfig,
}
@ -73,13 +70,13 @@ pub struct CliConfig {
pub mode: Option<ExecutionMode>,
pub server: Option<ServerDefaults>,
pub exec: Option<ExecDefaults>,
pub llm: Option<LlmDefaults>,
pub git: Option<CliGitConfig>,
#[serde(default)]
pub verbose: bool,
#[serde(default)]
pub log: crate::server::LogConfig,
pub pull_request: Option<PullRequestConfig>,
#[serde(flatten)]
pub run_defaults: RunDefaults,
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerEntry>,
}
@ -134,7 +131,7 @@ model = "claude-sonnet-4-5"
assert_eq!(exec.model.as_deref(), Some("claude-opus-4-6"));
assert_eq!(exec.permissions, Some(PermissionLevel::ReadWrite));
assert_eq!(exec.output_format, Some(OutputFormat::Text));
let llm = config.llm.unwrap();
let llm = config.run_defaults.llm.unwrap();
assert_eq!(llm.model.as_deref(), Some("claude-sonnet-4-5"));
}
@ -150,7 +147,7 @@ provider = "openai"
assert_eq!(exec.model, None);
assert_eq!(exec.permissions, None);
assert_eq!(exec.output_format, None);
assert_eq!(config.llm, None);
assert_eq!(config.run_defaults.llm, None);
}
#[test]
@ -272,14 +269,56 @@ email = "me@local"
enabled = true
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
let pr = config.pull_request.unwrap();
let pr = config.run_defaults.pull_request.unwrap();
assert!(pr.enabled);
}
#[test]
fn parse_pull_request_absent() {
let config: CliConfig = toml::from_str("").unwrap();
assert_eq!(config.pull_request, None);
assert_eq!(config.run_defaults.pull_request, None);
}
#[test]
fn parse_git_config_with_app_id() {
let toml = r#"
[git]
app_id = "12345"
slug = "my-app"
[git.author]
name = "arc-bot"
email = "arc@test.com"
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
let git = config.git.unwrap();
assert_eq!(git.app_id.as_deref(), Some("12345"));
assert_eq!(git.slug.as_deref(), Some("my-app"));
assert_eq!(git.author.name.as_deref(), Some("arc-bot"));
}
#[test]
fn parse_llm_with_provider_and_fallbacks() {
let toml = r#"
[llm]
model = "claude-sonnet-4-5"
provider = "anthropic"
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
let llm = config.run_defaults.llm.unwrap();
assert_eq!(llm.model.as_deref(), Some("claude-sonnet-4-5"));
assert_eq!(llm.provider.as_deref(), Some("anthropic"));
}
#[test]
fn parse_sandbox_config() {
let toml = r#"
[sandbox]
provider = "daytona"
"#;
let config: CliConfig = toml::from_str(toml).unwrap();
let sandbox = config.run_defaults.sandbox.unwrap();
assert_eq!(sandbox.provider.as_deref(), Some("daytona"));
}
#[test]