2026-07-16 12:32:17 +07:00
|
|
|
|
//! JSON file–backed `AppConfigRepository`.
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! Path: `<base_dir>/app_config.json`
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! On load, auto-detects Claude credentials from the environment or
|
|
|
|
|
|
//! `~/.claude/settings.json` and merges them into the provider map.
|
|
|
|
|
|
|
|
|
|
|
|
#![allow(
|
|
|
|
|
|
clippy::cast_possible_truncation,
|
|
|
|
|
|
clippy::cast_sign_loss,
|
|
|
|
|
|
clippy::cast_precision_loss,
|
|
|
|
|
|
clippy::cast_possible_wrap
|
|
|
|
|
|
)]
|
|
|
|
|
|
|
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
|
|
|
|
|
|
use anyhow::{Context, Result};
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
|
|
|
|
|
|
use crate::domain::app_config::{AppConfig, ModelRole, ProviderConfig};
|
|
|
|
|
|
use crate::domain::repository::AppConfigRepository;
|
|
|
|
|
|
|
|
|
|
|
|
/// Persists `AppConfig` as pretty-printed JSON at `<base_dir>/app_config.json`.
|
|
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
|
|
|
|
pub struct JsonAppConfigRepository;
|
|
|
|
|
|
|
|
|
|
|
|
impl JsonAppConfigRepository {
|
|
|
|
|
|
/// Create a new repository instance.
|
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
|
Self
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Configuration structure inside `~/.claude/settings.json`.
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
|
struct ClaudeEnv {
|
|
|
|
|
|
#[serde(alias = "ANTHROPIC_BASE_URL")]
|
|
|
|
|
|
anthropic_base_url: Option<String>,
|
|
|
|
|
|
#[serde(alias = "ANTHROPIC_API_KEY")]
|
|
|
|
|
|
anthropic_api_key: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
|
struct ClaudeSettings {
|
|
|
|
|
|
env: Option<ClaudeEnv>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Try to read Claude credentials from `~/.claude/settings.json`'s `env` block.
|
|
|
|
|
|
fn claude_credentials_from_file() -> Option<(String, String)> {
|
|
|
|
|
|
let path = dirs::home_dir()?.join(".claude").join("settings.json");
|
|
|
|
|
|
let content = std::fs::read_to_string(&path).ok()?;
|
|
|
|
|
|
let settings: ClaudeSettings = serde_json::from_str(&content).ok()?;
|
|
|
|
|
|
let env = settings.env?;
|
|
|
|
|
|
let base_url = env.anthropic_base_url?;
|
|
|
|
|
|
let key = env.anthropic_api_key?;
|
|
|
|
|
|
Some((base_url, key))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Try to read Claude credentials from environment variables.
|
|
|
|
|
|
fn claude_credentials_from_env() -> Option<(String, String)> {
|
|
|
|
|
|
let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?;
|
|
|
|
|
|
let key = std::env::var("ANTHROPIC_API_KEY").ok()?;
|
|
|
|
|
|
Some((base_url, key))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Return a `ProviderConfig` for the Claude provider, checking both
|
|
|
|
|
|
/// `~/.claude/settings.json` and the process environment.
|
|
|
|
|
|
fn detect_claude_settings_provider() -> Option<ProviderConfig> {
|
|
|
|
|
|
let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?;
|
|
|
|
|
|
Some(ProviderConfig {
|
|
|
|
|
|
api_base: base_url,
|
|
|
|
|
|
api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
|
|
|
|
|
|
default_model: None,
|
|
|
|
|
|
default_api_key: Some(key),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl AppConfigRepository for JsonAppConfigRepository {
|
|
|
|
|
|
fn load(&self, base_dir: &Path) -> Result<AppConfig> {
|
|
|
|
|
|
let path = base_dir.join("app_config.json");
|
|
|
|
|
|
let mut cfg: AppConfig = match std::fs::read_to_string(&path) {
|
|
|
|
|
|
Ok(s) => serde_json::from_str(&s)
|
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("failed to parse app_config.json: {e}"))?,
|
|
|
|
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
|
|
|
|
tracing::info!("app_config.json not found, using defaults");
|
|
|
|
|
|
AppConfig::default()
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(e) => {
|
|
|
|
|
|
return Err(anyhow::anyhow!("failed to read app_config.json: {e}"));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Merge any default providers not present in the loaded config
|
|
|
|
|
|
let defaults = AppConfig::default();
|
|
|
|
|
|
for (name, provider) in defaults.providers {
|
|
|
|
|
|
cfg.providers.entry(name).or_insert(provider);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Auto-detect Claude provider
|
|
|
|
|
|
if let Some(claude_provider) = detect_claude_settings_provider() {
|
|
|
|
|
|
cfg.providers
|
|
|
|
|
|
.entry("claude".to_string())
|
|
|
|
|
|
.or_insert(claude_provider);
|
|
|
|
|
|
|
|
|
|
|
|
let claude_models: [(&str, &str); 3] = [
|
|
|
|
|
|
("claude-opus-4-8", "claude-opus-4-8"),
|
|
|
|
|
|
("claude-sonnet-5", "claude-sonnet-5"),
|
|
|
|
|
|
("claude-haiku-4-5", "claude-haiku-4-5-20251001"),
|
|
|
|
|
|
];
|
|
|
|
|
|
for (role_name, model_name) in &claude_models {
|
|
|
|
|
|
cfg.model_roles
|
|
|
|
|
|
.entry(role_name.to_string())
|
|
|
|
|
|
.or_insert(ModelRole {
|
|
|
|
|
|
provider: "claude".to_string(),
|
|
|
|
|
|
model: model_name.to_string(),
|
|
|
|
|
|
max_tokens: Some(8192),
|
|
|
|
|
|
context_window: Some(200_000),
|
|
|
|
|
|
temperature: Some(0.7),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Set as default provider only if user hasn't picked a custom default
|
|
|
|
|
|
if cfg.default_provider == defaults.default_provider {
|
|
|
|
|
|
cfg.default_provider = "claude".to_string();
|
|
|
|
|
|
cfg.default_model = "claude-opus-4-8".to_string();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Ok(cfg)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<()> {
|
|
|
|
|
|
std::fs::create_dir_all(base_dir)
|
|
|
|
|
|
.with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?;
|
|
|
|
|
|
let path = base_dir.join("app_config.json");
|
|
|
|
|
|
let tmp = base_dir.join("app_config.json.tmp");
|
2026-07-17 06:44:31 +07:00
|
|
|
|
let json =
|
|
|
|
|
|
serde_json::to_string_pretty(config).context("failed to serialize app config")?;
|
2026-07-16 12:32:17 +07:00
|
|
|
|
{
|
|
|
|
|
|
let mut f = std::fs::OpenOptions::new()
|
|
|
|
|
|
.create(true)
|
|
|
|
|
|
.truncate(true)
|
|
|
|
|
|
.write(true)
|
|
|
|
|
|
.open(&tmp)
|
|
|
|
|
|
.with_context(|| format!("failed to write temp file '{}'", tmp.display()))?;
|
|
|
|
|
|
f.write_all(json.as_bytes())?;
|
|
|
|
|
|
f.sync_all()?;
|
|
|
|
|
|
}
|
2026-07-17 06:44:31 +07:00
|
|
|
|
std::fs::rename(&tmp, &path).with_context(|| {
|
|
|
|
|
|
format!(
|
|
|
|
|
|
"failed to rename '{}' -> '{}'",
|
|
|
|
|
|
tmp.display(),
|
|
|
|
|
|
path.display()
|
|
|
|
|
|
)
|
|
|
|
|
|
})?;
|
2026-07-16 12:32:17 +07:00
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
|
if let Ok(d) = std::fs::File::open(parent) {
|
|
|
|
|
|
let _ = d.sync_all();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
tracing::debug!("app_config saved to '{}'", path.display());
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|