appctl 0.3.0

One command. Any app. Full AI control. The universal AI CLI for any web app, database, or service.
Documentation
use std::{
    collections::BTreeMap,
    fs,
    path::{Path, PathBuf},
};

use anyhow::{Context, Result};
use keyring::Entry;
use serde::{Deserialize, Serialize};

use crate::auth::provider::{
    ProviderAuthConfig, ProviderAuthStatus, ResolvedProviderAuth, inspect_provider_auth,
    resolve_provider_auth,
};
use crate::cloud::load_synced_provider_connection;

#[derive(Debug, Clone)]
pub struct ConfigPaths {
    pub root: PathBuf,
    pub config: PathBuf,
    pub schema: PathBuf,
    pub tools: PathBuf,
    pub history: PathBuf,
    pub provider_connections: PathBuf,
}

impl ConfigPaths {
    pub fn new(root: PathBuf) -> Self {
        Self {
            config: root.join("config.toml"),
            schema: root.join("schema.json"),
            tools: root.join("tools.json"),
            history: root.join("history.db"),
            provider_connections: root.join("provider-connections.json"),
            root,
        }
    }

    pub fn ensure(&self) -> Result<()> {
        fs::create_dir_all(&self.root)
            .with_context(|| format!("failed to create {}", self.root.display()))
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
    pub default: String,
    #[serde(default, rename = "provider")]
    pub providers: Vec<ProviderConfig>,
    #[serde(default)]
    pub target: TargetConfig,
    #[serde(default)]
    pub cloud: CloudConfig,
    #[serde(default)]
    pub behavior: BehaviorConfig,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
    pub name: String,
    pub kind: ProviderKind,
    pub base_url: String,
    pub model: String,
    #[serde(default)]
    pub auth: Option<ProviderAuthConfig>,
    #[serde(default)]
    pub api_key_ref: Option<String>,
    #[serde(default)]
    pub extra_headers: BTreeMap<String, String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderKind {
    Anthropic,
    OpenAiCompatible,
    GoogleGenai,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TargetConfig {
    #[serde(default)]
    pub base_url: Option<String>,
    #[serde(default)]
    pub auth_header: Option<String>,
    #[serde(default)]
    pub database_url: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BehaviorConfig {
    #[serde(default = "default_max_iterations")]
    pub max_iterations: usize,
    #[serde(default = "default_history_limit")]
    pub history_limit: usize,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CloudConfig {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default)]
    pub base_url: Option<String>,
    #[serde(default)]
    pub account_id: Option<String>,
    #[serde(default)]
    pub sync_token_ref: Option<String>,
}

#[derive(Debug, Clone)]
pub struct ResolvedProvider {
    pub name: String,
    pub kind: ProviderKind,
    pub base_url: String,
    pub model: String,
    pub auth: ResolvedProviderAuth,
    pub auth_status: ProviderAuthStatus,
    pub extra_headers: BTreeMap<String, String>,
}

fn default_max_iterations() -> usize {
    8
}

fn default_history_limit() -> usize {
    100
}

impl Default for BehaviorConfig {
    fn default() -> Self {
        Self {
            max_iterations: default_max_iterations(),
            history_limit: default_history_limit(),
        }
    }
}

impl Default for AppConfig {
    fn default() -> Self {
        Self {
            default: "ollama".to_string(),
            providers: vec![
                ProviderConfig {
                    name: "claude".to_string(),
                    kind: ProviderKind::Anthropic,
                    base_url: "https://api.anthropic.com".to_string(),
                    model: "claude-sonnet-4".to_string(),
                    auth: None,
                    api_key_ref: Some("anthropic".to_string()),
                    extra_headers: BTreeMap::new(),
                },
                ProviderConfig {
                    name: "ollama".to_string(),
                    kind: ProviderKind::OpenAiCompatible,
                    base_url: "http://localhost:11434/v1".to_string(),
                    model: "llama3.1".to_string(),
                    auth: Some(ProviderAuthConfig::None),
                    api_key_ref: None,
                    extra_headers: BTreeMap::new(),
                },
            ],
            target: TargetConfig::default(),
            cloud: CloudConfig::default(),
            behavior: BehaviorConfig::default(),
        }
    }
}

impl AppConfig {
    pub fn load_or_init(paths: &ConfigPaths) -> Result<Self> {
        paths.ensure()?;
        if !paths.config.exists() {
            let config = Self::default();
            config.save(paths)?;
            return Ok(config);
        }
        Self::load(paths)
    }

    pub fn load(paths: &ConfigPaths) -> Result<Self> {
        let raw = fs::read_to_string(&paths.config)
            .with_context(|| format!("failed to read {}", paths.config.display()))?;
        toml::from_str(&raw).with_context(|| format!("failed to parse {}", paths.config.display()))
    }

    pub fn save(&self, paths: &ConfigPaths) -> Result<()> {
        paths.ensure()?;
        let raw = toml::to_string_pretty(self)?;
        fs::write(&paths.config, raw)
            .with_context(|| format!("failed to write {}", paths.config.display()))
    }

    pub fn sample_toml() -> Result<String> {
        Ok(toml::to_string_pretty(&Self::default())?)
    }

    pub fn provider_statuses(&self) -> Vec<ResolvedProviderSummary> {
        self.providers
            .iter()
            .map(|provider| ResolvedProviderSummary {
                name: provider.name.clone(),
                kind: provider.kind.clone(),
                base_url: provider.base_url.clone(),
                model: provider.model.clone(),
                auth_status: inspect_provider_auth(&provider.name, provider, None),
            })
            .collect()
    }

    pub fn provider_statuses_with_paths(&self, paths: &ConfigPaths) -> Vec<ResolvedProviderSummary> {
        self.providers
            .iter()
            .map(|provider| {
                let cloud_auth = if self.cloud.enabled {
                    load_synced_provider_connection(paths, &provider.name)
                        .ok()
                        .flatten()
                        .map(|connection| connection.auth)
                } else {
                    None
                };

                ResolvedProviderSummary {
                    name: provider.name.clone(),
                    kind: provider.kind.clone(),
                    base_url: provider.base_url.clone(),
                    model: provider.model.clone(),
                    auth_status: inspect_provider_auth(
                        &provider.name,
                        provider,
                        cloud_auth.as_ref(),
                    ),
                }
            })
            .collect()
    }

    pub fn resolve_provider(
        &self,
        provider_name: Option<&str>,
        model_override: Option<&str>,
    ) -> Result<ResolvedProvider> {
        self.resolve_provider_with_paths(None, provider_name, model_override)
    }

    pub fn resolve_provider_with_paths(
        &self,
        paths: Option<&ConfigPaths>,
        provider_name: Option<&str>,
        model_override: Option<&str>,
    ) -> Result<ResolvedProvider> {
        let provider_name = provider_name.unwrap_or(&self.default);
        let provider = self
            .providers
            .iter()
            .find(|p| p.name == provider_name)
            .with_context(|| format!("provider '{}' not found in config", provider_name))?;
        let cloud_auth = if self.cloud.enabled {
            paths
                .and_then(|paths| load_synced_provider_connection(paths, provider_name).ok())
                .flatten()
                .map(|connection| connection.auth)
        } else {
            None
        };
        let auth = resolve_provider_auth(provider_name, provider, cloud_auth.as_ref())?;
        let auth_status = inspect_provider_auth(provider_name, provider, cloud_auth.as_ref());

        Ok(ResolvedProvider {
            name: provider.name.clone(),
            kind: provider.kind.clone(),
            base_url: provider.base_url.clone(),
            model: model_override.unwrap_or(&provider.model).to_string(),
            auth,
            auth_status,
            extra_headers: provider.extra_headers.clone(),
        })
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedProviderSummary {
    pub name: String,
    pub kind: ProviderKind,
    pub base_url: String,
    pub model: String,
    pub auth_status: ProviderAuthStatus,
}

pub fn load_secret(name: &str) -> Result<String> {
    Entry::new("appctl", name)?
        .get_password()
        .with_context(|| format!("failed to load secret '{}' from keychain", name))
}

pub fn save_secret(name: &str, value: &str) -> Result<()> {
    Entry::new("appctl", name)?
        .set_password(value)
        .with_context(|| format!("failed to save secret '{}' to keychain", name))
}

pub fn delete_secret(name: &str) -> Result<()> {
    Entry::new("appctl", name)?
        .delete_credential()
        .with_context(|| format!("failed to delete secret '{}' from keychain", name))
}

pub fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
    let payload = serde_json::to_string_pretty(value)?;
    fs::write(path, payload).with_context(|| format!("failed to write {}", path.display()))
}

pub fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
    let payload =
        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
    serde_json::from_str(&payload).with_context(|| format!("failed to parse {}", path.display()))
}