summaryrefslogtreecommitdiff
path: root/makima/daemon/src/config.rs
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-06 04:08:11 +0000
committersoryu <soryu@soryu.co>2026-01-11 03:01:13 +0000
commit8b17a175c3e7e27b789812eba4e3cd760beadb10 (patch)
tree7864dcaa2fa9db47fdfd4e8bfdb0b1dde832aa33 /makima/daemon/src/config.rs
parentf79c416c58557d2f946aa5332989afdfa8c021cd (diff)
downloadsoryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz
soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip
Initial Control system
Diffstat (limited to 'makima/daemon/src/config.rs')
-rw-r--r--makima/daemon/src/config.rs536
1 files changed, 536 insertions, 0 deletions
diff --git a/makima/daemon/src/config.rs b/makima/daemon/src/config.rs
new file mode 100644
index 0000000..28c7fea
--- /dev/null
+++ b/makima/daemon/src/config.rs
@@ -0,0 +1,536 @@
+//! Configuration management for the makima daemon.
+
+use config::{Config, Environment, File};
+use serde::Deserialize;
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+/// Root daemon configuration.
+#[derive(Debug, Clone, Deserialize)]
+pub struct DaemonConfig {
+ /// Server connection settings.
+ pub server: ServerConfig,
+
+ /// Worktree settings.
+ #[serde(default)]
+ pub worktree: WorktreeConfig,
+
+ /// Process settings.
+ #[serde(default)]
+ pub process: ProcessConfig,
+
+ /// Local database settings.
+ #[serde(default)]
+ pub local_db: LocalDbConfig,
+
+ /// Logging settings.
+ #[serde(default)]
+ pub logging: LoggingConfig,
+
+ /// Repositories to auto-clone on startup.
+ #[serde(default)]
+ pub repos: ReposConfig,
+}
+
+/// Server connection configuration.
+#[derive(Debug, Clone, Deserialize)]
+pub struct ServerConfig {
+ /// WebSocket URL of makima server (e.g., ws://localhost:8080 or wss://makima.example.com).
+ #[serde(default)]
+ pub url: String,
+
+ /// API key for authentication.
+ #[serde(default, alias = "apikey")]
+ pub api_key: String,
+
+ /// Heartbeat interval in seconds.
+ #[serde(default = "default_heartbeat_interval", alias = "heartbeatintervalsecs")]
+ pub heartbeat_interval_secs: u64,
+
+ /// Reconnect interval in seconds after connection loss.
+ #[serde(default = "default_reconnect_interval", alias = "reconnectintervalsecs")]
+ pub reconnect_interval_secs: u64,
+
+ /// Maximum reconnect attempts before giving up (0 = infinite).
+ #[serde(default, alias = "maxreconnectattempts")]
+ pub max_reconnect_attempts: u32,
+}
+
+fn default_heartbeat_interval() -> u64 {
+ 30
+}
+
+fn default_reconnect_interval() -> u64 {
+ 5
+}
+
+/// Worktree configuration for task isolation.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default)]
+pub struct WorktreeConfig {
+ /// Base directory for worktrees (~/.makima/worktrees).
+ #[serde(default = "default_worktree_base_dir", alias = "basedir")]
+ pub base_dir: PathBuf,
+
+ /// Base directory for cloned repositories (~/.makima/repos).
+ #[serde(default = "default_repos_base_dir", alias = "reposdir")]
+ pub repos_dir: PathBuf,
+
+ /// Branch prefix for task branches.
+ #[serde(default = "default_branch_prefix", alias = "branchprefix")]
+ pub branch_prefix: String,
+
+ /// Clean up worktrees on daemon start.
+ #[serde(default, alias = "cleanuponstart")]
+ pub cleanup_on_start: bool,
+
+ /// Default target repository path for pushing completed branches.
+ /// Used when task.target_repo_path is not set.
+ #[serde(default, alias = "defaulttargetrepo")]
+ pub default_target_repo: Option<PathBuf>,
+}
+
+fn default_worktree_base_dir() -> PathBuf {
+ dirs::home_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join(".makima")
+ .join("worktrees")
+}
+
+fn default_repos_base_dir() -> PathBuf {
+ dirs::home_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join(".makima")
+ .join("repos")
+}
+
+fn default_branch_prefix() -> String {
+ "makima/task-".to_string()
+}
+
+impl Default for WorktreeConfig {
+ fn default() -> Self {
+ Self {
+ base_dir: default_worktree_base_dir(),
+ repos_dir: default_repos_base_dir(),
+ branch_prefix: default_branch_prefix(),
+ cleanup_on_start: false,
+ default_target_repo: None,
+ }
+ }
+}
+
+/// Process configuration for Claude Code subprocess execution.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default)]
+pub struct ProcessConfig {
+ /// Path or command for Claude Code CLI.
+ #[serde(default = "default_claude_command", alias = "claudecommand")]
+ pub claude_command: String,
+
+ /// Additional arguments to pass to Claude Code.
+ /// These are added after the default arguments.
+ #[serde(default, alias = "claudeargs")]
+ pub claude_args: Vec<String>,
+
+ /// Arguments to pass before the default arguments.
+ /// Useful for overriding defaults.
+ #[serde(default, alias = "claudepreargs")]
+ pub claude_pre_args: Vec<String>,
+
+ /// Skip the --dangerously-skip-permissions flag (default: false).
+ /// Set to true if you want to use Claude's permission system.
+ #[serde(default, alias = "enablepermissions")]
+ pub enable_permissions: bool,
+
+ /// Skip the --verbose flag (default: false).
+ #[serde(default, alias = "disableverbose")]
+ pub disable_verbose: bool,
+
+ /// Maximum concurrent tasks.
+ #[serde(default = "default_max_tasks", alias = "maxconcurrenttasks")]
+ pub max_concurrent_tasks: u32,
+
+ /// Default timeout for tasks in seconds (0 = no timeout).
+ #[serde(default, alias = "defaulttimeoutsecs")]
+ pub default_timeout_secs: u64,
+
+ /// Additional environment variables to pass to Claude Code.
+ #[serde(default, alias = "envvars")]
+ pub env_vars: HashMap<String, String>,
+}
+
+fn default_claude_command() -> String {
+ "claude".to_string()
+}
+
+fn default_max_tasks() -> u32 {
+ 4
+}
+
+impl Default for ProcessConfig {
+ fn default() -> Self {
+ Self {
+ claude_command: default_claude_command(),
+ claude_args: Vec::new(),
+ claude_pre_args: Vec::new(),
+ enable_permissions: false,
+ disable_verbose: false,
+ max_concurrent_tasks: default_max_tasks(),
+ default_timeout_secs: 0,
+ env_vars: HashMap::new(),
+ }
+ }
+}
+
+/// Local database configuration.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default)]
+pub struct LocalDbConfig {
+ /// Path to local SQLite database.
+ #[serde(default = "default_db_path")]
+ pub path: PathBuf,
+}
+
+impl Default for LocalDbConfig {
+ fn default() -> Self {
+ Self {
+ path: default_db_path(),
+ }
+ }
+}
+
+fn default_db_path() -> PathBuf {
+ dirs::home_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join(".makima")
+ .join("daemon.db")
+}
+
+/// Logging configuration.
+#[derive(Debug, Clone, Deserialize, Default)]
+pub struct LoggingConfig {
+ /// Log level: "trace", "debug", "info", "warn", "error".
+ #[serde(default = "default_log_level")]
+ pub level: String,
+
+ /// Log format: "pretty" or "json".
+ #[serde(default = "default_log_format")]
+ pub format: String,
+}
+
+fn default_log_level() -> String {
+ "info".to_string()
+}
+
+fn default_log_format() -> String {
+ "pretty".to_string()
+}
+
+/// Repository auto-clone configuration.
+#[derive(Debug, Clone, Deserialize, Default)]
+pub struct ReposConfig {
+ /// Directory to clone repositories into (default: ~/.makima/home).
+ #[serde(default = "default_home_dir")]
+ pub home_dir: PathBuf,
+
+ /// List of repositories to auto-clone on startup.
+ /// Each entry can be a URL (e.g., "https://github.com/user/repo.git")
+ /// or a shorthand (e.g., "github:user/repo").
+ #[serde(default, alias = "autoclone")]
+ pub auto_clone: Vec<RepoEntry>,
+}
+
+/// A repository entry for auto-cloning.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(untagged)]
+pub enum RepoEntry {
+ /// Simple URL string.
+ Url(String),
+ /// Detailed configuration.
+ Config {
+ /// Repository URL.
+ url: String,
+ /// Custom directory name (defaults to repo name from URL).
+ #[serde(default)]
+ name: Option<String>,
+ /// Branch to checkout after cloning (defaults to default branch).
+ #[serde(default)]
+ branch: Option<String>,
+ /// Whether to do a shallow clone (default: false).
+ #[serde(default)]
+ shallow: bool,
+ },
+}
+
+impl RepoEntry {
+ /// Get the URL for this repo entry.
+ pub fn url(&self) -> &str {
+ match self {
+ RepoEntry::Url(url) => url,
+ RepoEntry::Config { url, .. } => url,
+ }
+ }
+
+ /// Get the custom name, if any.
+ pub fn name(&self) -> Option<&str> {
+ match self {
+ RepoEntry::Url(_) => None,
+ RepoEntry::Config { name, .. } => name.as_deref(),
+ }
+ }
+
+ /// Get the branch to checkout, if any.
+ pub fn branch(&self) -> Option<&str> {
+ match self {
+ RepoEntry::Url(_) => None,
+ RepoEntry::Config { branch, .. } => branch.as_deref(),
+ }
+ }
+
+ /// Whether to do a shallow clone.
+ pub fn shallow(&self) -> bool {
+ match self {
+ RepoEntry::Url(_) => false,
+ RepoEntry::Config { shallow, .. } => *shallow,
+ }
+ }
+
+ /// Get the directory name to use (either custom name or derived from URL).
+ pub fn dir_name(&self) -> Option<String> {
+ if let Some(name) = self.name() {
+ return Some(name.to_string());
+ }
+
+ // Derive from URL
+ let url = self.url();
+
+ // Handle shorthand formats
+ let url = if url.starts_with("github:") {
+ url.strip_prefix("github:").unwrap_or(url)
+ } else if url.starts_with("gitlab:") {
+ url.strip_prefix("gitlab:").unwrap_or(url)
+ } else {
+ url
+ };
+
+ // Extract repo name from URL
+ url.trim_end_matches('/')
+ .trim_end_matches(".git")
+ .rsplit('/')
+ .next()
+ .map(|s| s.to_string())
+ }
+
+ /// Expand the URL (e.g., convert shorthand to full URL).
+ pub fn expanded_url(&self) -> String {
+ let url = self.url();
+
+ if url.starts_with("github:") {
+ format!("https://github.com/{}.git", url.strip_prefix("github:").unwrap_or(""))
+ } else if url.starts_with("gitlab:") {
+ format!("https://gitlab.com/{}.git", url.strip_prefix("gitlab:").unwrap_or(""))
+ } else {
+ url.to_string()
+ }
+ }
+}
+
+fn default_home_dir() -> PathBuf {
+ dirs::home_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join(".makima")
+ .join("home")
+}
+
+impl DaemonConfig {
+ /// Load configuration from files and environment variables.
+ ///
+ /// Configuration sources (in order of precedence):
+ /// 1. Environment variables (MAKIMA_API_KEY, MAKIMA_DAEMON_SERVER_URL, etc.)
+ /// 2. ./makima-daemon.toml (current directory)
+ /// 3. ~/.config/makima-daemon/config.toml
+ /// 4. /etc/makima-daemon/config.toml (Linux only)
+ ///
+ /// Environment variable examples:
+ /// - MAKIMA_API_KEY=your-api-key (preferred)
+ /// - MAKIMA_DAEMON_SERVER_URL=ws://localhost:8080
+ /// - MAKIMA_DAEMON_PROCESS_MAXCONCURRENTTASKS=4
+ pub fn load() -> Result<Self, config::ConfigError> {
+ Self::load_from_path(None)
+ }
+
+ /// Load configuration from a specific path plus standard sources.
+ fn load_from_path(config_path: Option<&std::path::Path>) -> Result<Self, config::ConfigError> {
+ let mut builder = Config::builder();
+
+ // System-wide config (Linux only)
+ #[cfg(target_os = "linux")]
+ {
+ builder = builder.add_source(
+ File::with_name("/etc/makima-daemon/config").required(false),
+ );
+ }
+
+ // User config
+ if let Some(config_dir) = dirs::config_dir() {
+ let user_config = config_dir.join("makima-daemon").join("config");
+ builder = builder.add_source(
+ File::with_name(user_config.to_str().unwrap_or("")).required(false),
+ );
+ }
+
+ // Local config
+ builder = builder.add_source(File::with_name("makima-daemon").required(false));
+
+ // Custom config file (if provided)
+ if let Some(path) = config_path {
+ builder = builder.add_source(
+ File::with_name(path.to_str().unwrap_or("")).required(true),
+ );
+ }
+
+ // Environment variables with underscore separator for nesting
+ // e.g., MAKIMA_DAEMON_SERVER_URL -> server.url
+ // MAKIMA_DAEMON_SERVER_APIKEY -> server.api_key
+ builder = builder.add_source(
+ Environment::with_prefix("MAKIMA_DAEMON")
+ .separator("_")
+ .try_parsing(true),
+ );
+
+ let config = builder.build()?;
+ let mut config: DaemonConfig = config.try_deserialize()?;
+
+ // Check for MAKIMA_API_KEY environment variable (preferred over MAKIMA_DAEMON_SERVER_APIKEY)
+ if let Ok(api_key) = std::env::var("MAKIMA_API_KEY") {
+ config.server.api_key = api_key;
+ }
+
+ // Validate required fields (don't validate here - let load_with_cli do final validation)
+ Ok(config)
+ }
+
+ /// Validate that required configuration fields are set.
+ pub fn validate(&self) -> Result<(), config::ConfigError> {
+ if self.server.url.is_empty() {
+ return Err(config::ConfigError::Message(
+ "server.url is required. Set via config file, MAKIMA_DAEMON_SERVER_URL, or --server-url".to_string()
+ ));
+ }
+ if self.server.api_key.is_empty() {
+ return Err(config::ConfigError::Message(
+ "API key is required. Set via MAKIMA_API_KEY, config file, or --api-key".to_string()
+ ));
+ }
+ Ok(())
+ }
+
+ /// Load configuration with CLI argument overrides.
+ ///
+ /// Configuration sources (in order of precedence, highest first):
+ /// 1. CLI arguments
+ /// 2. Environment variables
+ /// 3. Custom config file (if --config specified)
+ /// 4. ./makima-daemon.toml (current directory)
+ /// 5. ~/.config/makima-daemon/config.toml
+ /// 6. /etc/makima-daemon/config.toml (Linux only)
+ /// 7. Default values
+ pub fn load_with_cli(cli: &crate::cli::Cli) -> Result<Self, config::ConfigError> {
+ // Load base config (with optional custom config file)
+ let mut config = Self::load_from_path(cli.config.as_deref())?;
+
+ // Apply CLI overrides (highest priority)
+ if let Some(ref repos_dir) = cli.repos_dir {
+ config.worktree.repos_dir = repos_dir.clone();
+ }
+ if let Some(ref worktrees_dir) = cli.worktrees_dir {
+ config.worktree.base_dir = worktrees_dir.clone();
+ }
+ if let Some(ref server_url) = cli.server_url {
+ config.server.url = server_url.clone();
+ }
+ if let Some(ref api_key) = cli.api_key {
+ config.server.api_key = api_key.clone();
+ }
+ if let Some(max_tasks) = cli.max_tasks {
+ config.process.max_concurrent_tasks = max_tasks;
+ }
+ // Log level is always set (has default)
+ config.logging.level = cli.log_level.clone();
+
+ // Validate required fields after all sources are merged
+ config.validate()?;
+
+ Ok(config)
+ }
+
+ /// Create a minimal config for testing.
+ #[cfg(test)]
+ pub fn test_config() -> Self {
+ Self {
+ server: ServerConfig {
+ url: "ws://localhost:8080".to_string(),
+ api_key: "test-key".to_string(),
+ heartbeat_interval_secs: 30,
+ reconnect_interval_secs: 5,
+ max_reconnect_attempts: 0,
+ },
+ worktree: WorktreeConfig {
+ base_dir: PathBuf::from("/tmp/makima-daemon-test/worktrees"),
+ repos_dir: PathBuf::from("/tmp/makima-daemon-test/repos"),
+ branch_prefix: "makima/task-".to_string(),
+ cleanup_on_start: true,
+ default_target_repo: None,
+ },
+ process: ProcessConfig {
+ claude_command: "claude".to_string(),
+ claude_args: Vec::new(),
+ claude_pre_args: Vec::new(),
+ enable_permissions: false,
+ disable_verbose: false,
+ max_concurrent_tasks: 2,
+ default_timeout_secs: 0,
+ env_vars: HashMap::new(),
+ },
+ local_db: LocalDbConfig {
+ path: PathBuf::from("/tmp/makima-daemon-test/state.db"),
+ },
+ logging: LoggingConfig::default(),
+ repos: ReposConfig::default(),
+ }
+ }
+}
+
+/// Helper module for dirs crate (minimal subset).
+mod dirs {
+ use std::path::PathBuf;
+
+ pub fn home_dir() -> Option<PathBuf> {
+ std::env::var("HOME").ok().map(PathBuf::from)
+ }
+
+ pub fn config_dir() -> Option<PathBuf> {
+ #[cfg(target_os = "macos")]
+ {
+ std::env::var("HOME")
+ .ok()
+ .map(|h| PathBuf::from(h).join("Library").join("Application Support"))
+ }
+ #[cfg(target_os = "linux")]
+ {
+ std::env::var("XDG_CONFIG_HOME")
+ .ok()
+ .map(PathBuf::from)
+ .or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".config")))
+ }
+ #[cfg(target_os = "windows")]
+ {
+ std::env::var("APPDATA").ok().map(PathBuf::from)
+ }
+ #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
+ {
+ None
+ }
+ }
+}