From 27438fe63695b54974349527b2df5c7a92e5a893 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Mon, 3 Aug 2026 14:54:56 +0700 Subject: [PATCH] feat(config): apply top-level runtime settings --- _docs/config/opencode-compatibility.mdx | 14 +- src/app.rs | 9 +- src/autocomplete/file.rs | 43 +++++- src/autocomplete/mod.rs | 11 +- src/config/configuration.rs | 191 +++++++++++++++++++++++- src/main.rs | 2 + src/model/discovery.rs | 44 +++++- src/prompt/mod.rs | 13 ++ src/tools/permission.rs | 12 ++ 9 files changed, 315 insertions(+), 24 deletions(-) diff --git a/_docs/config/opencode-compatibility.mdx b/_docs/config/opencode-compatibility.mdx index 5c0ea60..42f433a 100644 --- a/_docs/config/opencode-compatibility.mdx +++ b/_docs/config/opencode-compatibility.mdx @@ -40,13 +40,13 @@ Blank cells mean that runtime behavior is not supported by that project today. ` | `notifications` | | ✅ | crabcode-specific sounds, desktop notifications, and terminal alert signals such as Zed tab dots. | | `mcp` | ✅ | ✅ | Uses the OpenCode MCP config shape. Enabled servers are connected at runtime and their tools are exposed as crabcode tools. | | `permission` | ✅ | ✅ | Global tool permission rules are enforced during AI SDK tool execution. | -| `instructions` | ✅ | | Accepted at the top level, but config-driven instruction files are not loaded yet. | -| `tools` | ✅ | | Accepted at the top level, not used as global tool config yet. | -| `compaction` | ✅ | | Accepted at the top level, not used as config yet. | -| `watcher` | ✅ | | Accepted at the top level, not used as config yet. | -| `formatter` | ✅ | | Accepted at the top level, not used as config yet. | -| `disabled_providers` | ✅ | | Accepted at the top level, not applied yet. | -| `enabled_providers` | ✅ | | Accepted at the top level, not applied yet. | +| `instructions` | ✅ | ✅ | Loads the listed files relative to the project root (or from absolute and `~/` paths) and appends their contents to the system prompt. Unreadable files produce a config warning. | +| `tools` | ✅ | ✅ | Global tool enable/disable map. Disabled tools are removed before agent-specific tool policies are applied. | +| `compaction` | ✅ | | Accepted and parsed (`false` or `{ "auto", "prune" }`), but compaction behavior is not applied yet. | +| `watcher` | ✅ | ✅ | Controls file suggestions in command autocomplete. Use `false` to disable them or `{ "ignore": ["path"] }` to exclude paths. | +| `formatter` | ✅ | | Accepted and parsed by file extension, but configured formatter commands are not run yet. | +| `disabled_providers` | ✅ | ✅ | Removes the listed provider IDs from model discovery and selection. | +| `enabled_providers` | ✅ | ✅ | Restricts model discovery and selection to the listed provider IDs. `disabled_providers` takes precedence when both are set. | | `keybinds` | ✅ | ❌ | Ignored because crabcode does not use OpenCode keybind config. | | `share` | ✅ | ❌ | Ignored. | | `tui` | ✅ | ❌ | Ignored. crabcode owns its terminal UI. | diff --git a/src/app.rs b/src/app.rs index 8662c89..8a85848 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1017,8 +1017,13 @@ impl App { }) .collect(); input.autocomplete = Some( - AutoComplete::new_at(crate::autocomplete::CommandAuto::new(®istry), &cwd_path) - .with_agents(agent_suggestions), + AutoComplete::new_at_with_file_config( + crate::autocomplete::CommandAuto::new(®istry), + &cwd_path, + loaded_config.merged_config.watcher.is_enabled(), + loaded_config.merged_config.watcher.ignored_paths().to_vec(), + ) + .with_agents(agent_suggestions), ); if let Some(default_agent) = loaded_config.merged_config.default_agent.clone() { diff --git a/src/autocomplete/file.rs b/src/autocomplete/file.rs index 0477218..7a45a12 100644 --- a/src/autocomplete/file.rs +++ b/src/autocomplete/file.rs @@ -54,6 +54,14 @@ impl FileAuto { } pub fn new_at(root: impl Into) -> Self { + Self::new_at_with_config(root, true, Vec::new()) + } + + pub fn new_at_with_config( + root: impl Into, + watcher_enabled: bool, + ignored_paths: Vec, + ) -> Self { let root = root.into(); let (refresh_tx, refresh_rx) = mpsc::sync_channel(1); let inner = Arc::new(FileAutoInner { @@ -67,10 +75,19 @@ impl FileAuto { if thread::Builder::new() .name("crabcode-file-index".to_string()) - .spawn(move || run_indexer(root, weak_inner, refresh_tx, refresh_rx)) + .spawn(move || { + run_indexer( + root, + weak_inner, + refresh_tx, + refresh_rx, + watcher_enabled, + ignored_paths, + ) + }) .is_err() { - publish_entries(&fallback_inner, collect_entries(&fallback_root)); + publish_entries(&fallback_inner, collect_entries(&fallback_root, &[])); } Self { inner } @@ -173,8 +190,12 @@ fn run_indexer( inner: Weak, refresh_tx: SyncSender<()>, refresh_rx: Receiver<()>, + watcher_enabled: bool, + ignored_paths: Vec, ) { - let watcher = create_watcher(&root, refresh_tx); + let watcher = watcher_enabled + .then(|| create_watcher(&root, refresh_tx)) + .flatten(); let safety_refresh_interval = if watcher.is_some() { WATCHED_SAFETY_REFRESH_INTERVAL } else { @@ -182,7 +203,7 @@ fn run_indexer( }; let mut last_refresh = Instant::now(); - if !refresh_index(&root, &inner) { + if !refresh_index(&root, &inner, &ignored_paths) { return; } @@ -202,7 +223,7 @@ fn run_indexer( } if refresh_requested || last_refresh.elapsed() >= safety_refresh_interval { - if !refresh_index(&root, &inner) { + if !refresh_index(&root, &inner, &ignored_paths) { break; } last_refresh = Instant::now(); @@ -263,8 +284,8 @@ fn event_requires_refresh(event: &Event) -> bool { }) } -fn refresh_index(root: &Path, inner: &Weak) -> bool { - let entries = collect_entries(root); +fn refresh_index(root: &Path, inner: &Weak, ignored_paths: &[String]) -> bool { + let entries = collect_entries(root, ignored_paths); let Some(inner) = inner.upgrade() else { return false; }; @@ -282,7 +303,7 @@ fn publish_entries(inner: &FileAutoInner, entries: Vec) { inner.state_changed.notify_all(); } -fn collect_entries(root: &Path) -> Vec { +fn collect_entries(root: &Path, ignored_paths: &[String]) -> Vec { let mut builder = WalkBuilder::new(root); builder .hidden(false) @@ -310,6 +331,12 @@ fn collect_entries(root: &Path) -> Vec { if display.is_empty() { return None; } + if ignored_paths.iter().any(|pattern| { + let pattern = pattern.trim_end_matches('/'); + display == pattern || display.starts_with(&format!("{pattern}/")) + }) { + return None; + } if is_directory && !display.ends_with('/') { display.push('/'); } diff --git a/src/autocomplete/mod.rs b/src/autocomplete/mod.rs index 4d7590d..7e8c147 100644 --- a/src/autocomplete/mod.rs +++ b/src/autocomplete/mod.rs @@ -22,9 +22,18 @@ impl AutoComplete { } pub fn new_at(command_auto: CommandAuto, root: impl Into) -> Self { + Self::new_at_with_file_config(command_auto, root, true, Vec::new()) + } + + pub fn new_at_with_file_config( + command_auto: CommandAuto, + root: impl Into, + watcher_enabled: bool, + ignored_paths: Vec, + ) -> Self { Self { command_auto, - file_auto: FileAuto::new_at(root), + file_auto: FileAuto::new_at_with_config(root, watcher_enabled, ignored_paths), agents: Vec::new(), mode: AutoCompleteMode::Command, } diff --git a/src/config/configuration.rs b/src/config/configuration.rs index e6bce66..551c1f8 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -4,7 +4,7 @@ use crate::tools::{ use anyhow::{anyhow, Context, Result}; use regex::Regex; use serde_json::Value; -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -373,6 +373,53 @@ impl McpServerConfig { pub type McpConfig = BTreeMap; +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum CompactionConfig { + #[default] + Enabled, + Disabled, + Settings { + auto: bool, + prune: bool, + }, +} + +impl CompactionConfig { + pub fn is_enabled(&self) -> bool { + !matches!(self, Self::Disabled) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum WatcherConfig { + #[default] + Enabled, + Disabled, + Settings { + ignore: Vec, + }, +} + +impl WatcherConfig { + pub fn is_enabled(&self) -> bool { + !matches!(self, Self::Disabled) + } + + pub fn ignored_paths(&self) -> &[String] { + match self { + Self::Settings { ignore } => ignore, + _ => &[], + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum FormatterConfig { + #[default] + Disabled, + Command(String), +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProviderTimeout { Millis(u64), @@ -397,6 +444,23 @@ pub struct MergedConfig { pub images: ImagesConfig, pub websearch: WebsearchConfig, pub mcp: McpConfig, + pub instructions: Vec, + pub tools: HashMap, + pub compaction: CompactionConfig, + pub watcher: WatcherConfig, + pub formatter: HashMap, + pub disabled_providers: HashSet, + pub enabled_providers: Option>, +} + +impl MergedConfig { + pub fn provider_is_enabled(&self, provider_id: &str) -> bool { + !self.disabled_providers.contains(provider_id) + && self + .enabled_providers + .as_ref() + .is_none_or(|enabled| enabled.contains(provider_id)) + } } #[derive(Debug, Clone)] @@ -532,6 +596,8 @@ impl ConfigLoader { &mut diagnostics, ); let mut merged_config = parse_merged_config(&merged, &mut diagnostics); + merged_config.instructions = + load_instruction_files(&merged_config.instructions, &project_root, &mut diagnostics); let mut agent_definitions = crate::agent::definition::load_markdown_agent_definitions( &inventory.opencode_agents, &mut diagnostics.warnings, @@ -1159,6 +1225,29 @@ fn trim_trailing_newlines(s: &str) -> String { s.trim_end_matches(['\n', '\r']).to_string() } +fn load_instruction_files( + paths: &[String], + project_root: &Path, + diagnostics: &mut ConfigDiagnostics, +) -> Vec { + paths + .iter() + .filter_map(|configured_path| { + let path = expand_path(configured_path, project_root); + match fs::read_to_string(&path) { + Ok(contents) => Some(contents), + Err(error) => { + diagnostics.warnings.push(format!( + "Failed to read instruction file {}: {error}", + path.display() + )); + None + } + } + }) + .collect() +} + fn expand_path(arg: &str, base_dir: &Path) -> PathBuf { let arg = arg.trim(); if let Some(rest) = arg.strip_prefix("~/") { @@ -1229,6 +1318,76 @@ fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> M out.images = parse_images(obj.get("images"), diagnostics); out.websearch = parse_websearch(obj.get("websearch"), diagnostics); out.mcp = parse_mcp(obj.get("mcp"), diagnostics); + out.instructions = obj + .get("instructions") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|instruction| !instruction.is_empty()) + .map(ToOwned::to_owned) + .collect(); + out.tools = obj + .get("tools") + .and_then(Value::as_object) + .into_iter() + .flatten() + .filter_map(|(tool, enabled)| enabled.as_bool().map(|enabled| (tool.clone(), enabled))) + .collect(); + out.compaction = match obj.get("compaction") { + Some(Value::Bool(false)) => CompactionConfig::Disabled, + Some(Value::Object(settings)) => CompactionConfig::Settings { + auto: settings + .get("auto") + .and_then(Value::as_bool) + .unwrap_or(true), + prune: settings + .get("prune") + .and_then(Value::as_bool) + .unwrap_or(false), + }, + _ => CompactionConfig::Enabled, + }; + out.watcher = match obj.get("watcher") { + Some(Value::Bool(false)) => WatcherConfig::Disabled, + Some(Value::Object(settings)) => WatcherConfig::Settings { + ignore: settings + .get("ignore") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(ToOwned::to_owned) + .collect(), + }, + _ => WatcherConfig::Enabled, + }; + out.formatter = obj + .get("formatter") + .and_then(Value::as_object) + .into_iter() + .flatten() + .filter_map(|(extension, formatter)| match formatter { + Value::String(command) if !command.trim().is_empty() => Some(( + extension.trim_start_matches('.').to_string(), + FormatterConfig::Command(command.trim().to_string()), + )), + Value::Bool(false) => Some(( + extension.trim_start_matches('.').to_string(), + FormatterConfig::Disabled, + )), + _ => None, + }) + .collect(); + out.disabled_providers = parse_string_array(obj.get("disabled_providers")) + .into_iter() + .collect(); + out.enabled_providers = obj + .get("enabled_providers") + .map(|value| parse_string_array(Some(value)).into_iter().collect()); out } @@ -2404,6 +2563,36 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn parses_and_applies_top_level_runtime_configuration() { + let mut diagnostics = ConfigDiagnostics::default(); + let config = parse_merged_config( + &json!({ + "instructions": ["AGENTS.md"], + "tools": { "bash": false, "read": true }, + "compaction": false, + "watcher": { "ignore": ["generated", "tmp/cache"] }, + "formatter": { "rs": "rustfmt", ".md": false }, + "disabled_providers": ["openai"], + "enabled_providers": ["anthropic", "google"] + }), + &mut diagnostics, + ); + + assert_eq!(config.instructions, vec!["AGENTS.md"]); + assert_eq!(config.tools.get("bash"), Some(&false)); + assert!(!config.compaction.is_enabled()); + assert_eq!(config.watcher.ignored_paths(), ["generated", "tmp/cache"]); + assert_eq!( + config.formatter.get("rs"), + Some(&FormatterConfig::Command("rustfmt".into())) + ); + assert_eq!(config.formatter.get("md"), Some(&FormatterConfig::Disabled)); + assert!(!config.provider_is_enabled("openai")); + assert!(config.provider_is_enabled("anthropic")); + assert!(!config.provider_is_enabled("mistral")); + } + #[test] fn parses_small_model_aliases() { let mut diagnostics = ConfigDiagnostics::default(); diff --git a/src/main.rs b/src/main.rs index 4a09213..25e89df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -367,6 +367,7 @@ async fn run_print_mode( print_mode_permission_rules(loaded_config.merged_config.permission_rules.clone()); let tool_permissions = crate::tools::ToolPermissions::new(std::path::PathBuf::from(&cwd)) .with_agent_policies(agent_policies) + .with_global_tool_config(loaded_config.merged_config.tools.clone()) .with_permission_rules(permission_rules) .with_agent_permission_rules(agent_registry.permission_rules_map()) .dangerously_skip_permissions(dangerously_skip_permissions); @@ -402,6 +403,7 @@ async fn run_print_mode( ) .with_tool_registry(prompt_registry) .with_agent_registry(agent_registry.clone()) + .with_custom_instructions(loaded_config.merged_config.instructions.join("\n\n")) .with_print_mode(true); let system_prompt = composer.compose().await; let messages = vec![Message::system(system_prompt), Message::user(prompt)]; diff --git a/src/model/discovery.rs b/src/model/discovery.rs index 08098f0..24130eb 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -153,6 +153,8 @@ pub struct Discovery { cache_path: PathBuf, custom_providers: Option>, + disabled_providers: std::collections::HashSet, + enabled_providers: Option>, } pub fn is_model_selectable( @@ -296,17 +298,32 @@ impl Discovery { } pub fn new() -> Result { - // Try to load custom providers from config file - let custom_providers = crate::config::ConfigLoader::load() - .map(|loaded| loaded.merged_config.custom_providers) - .ok(); - Self::new_with_custom(custom_providers) + let loaded = crate::config::ConfigLoader::load().ok(); + let custom_providers = loaded + .as_ref() + .map(|loaded| loaded.merged_config.custom_providers.clone()); + let disabled_providers = loaded + .as_ref() + .map(|loaded| loaded.merged_config.disabled_providers.clone()) + .unwrap_or_default(); + let enabled_providers = loaded.and_then(|loaded| loaded.merged_config.enabled_providers); + Self::new_with_config(custom_providers, disabled_providers, enabled_providers) } pub fn new_with_custom( custom_providers: Option< std::collections::HashMap, >, + ) -> Result { + Self::new_with_config(custom_providers, Default::default(), None) + } + + fn new_with_config( + custom_providers: Option< + std::collections::HashMap, + >, + disabled_providers: std::collections::HashSet, + enabled_providers: Option>, ) -> Result { if cfg!(test) || env::var("CRABCODE_TEST_MODE").is_ok() { let cache_dir = PathBuf::from("/tmp/crabcode_test_cache"); @@ -318,6 +335,8 @@ impl Discovery { client: shared_http_client()?, cache_path, custom_providers, + disabled_providers, + enabled_providers, }) } else { crate::persistence::ensure_cache_dir().context("Failed to create cache directory")?; @@ -329,10 +348,20 @@ impl Discovery { client: shared_http_client()?, cache_path, custom_providers, + disabled_providers, + enabled_providers, }) } } + fn provider_is_enabled(&self, provider_id: &str) -> bool { + !self.disabled_providers.contains(provider_id) + && self + .enabled_providers + .as_ref() + .is_none_or(|enabled| enabled.contains(provider_id)) + } + pub fn cache_path(&self) -> &PathBuf { &self.cache_path } @@ -659,6 +688,7 @@ impl Discovery { pub async fn fetch_models(&self) -> Result> { let mut models = crate::model::extensions::ModelExtensions::runtime_models_from_cache(); + models.retain(|model| self.provider_is_enabled(&model.provider_id)); let cache_key = ( self.get_cache_path().clone(), self.custom_provider_dialog_signature(), @@ -670,6 +700,7 @@ impl Discovery { .filter(|cached| cached.cached_at.elapsed().as_secs() <= CACHE_TTL_SECONDS) { models.extend(cached.models); + models.retain(|model| self.provider_is_enabled(&model.provider_id)); return Ok(models); } @@ -682,6 +713,9 @@ impl Discovery { let mut persistent_models = Vec::new(); for (provider_id, provider) in providers { + if !self.provider_is_enabled(&provider_id) { + continue; + } if crate::model::extensions::ModelExtensions::is_runtime_provider(&provider_id) { continue; } diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index d507e56..afb883e 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -38,6 +38,7 @@ pub struct SystemPromptComposer { tool_registry: Option, agent_registry: Option, active_agent: Option, + custom_instructions: String, } impl SystemPromptComposer { @@ -56,6 +57,7 @@ impl SystemPromptComposer { tool_registry: None, agent_registry: None, active_agent: None, + custom_instructions: String::new(), } } @@ -82,6 +84,11 @@ impl SystemPromptComposer { self } + pub fn with_custom_instructions(mut self, instructions: String) -> Self { + self.custom_instructions = instructions; + self + } + pub async fn compose(&self) -> String { let mut parts = Vec::new(); @@ -91,6 +98,12 @@ impl SystemPromptComposer { parts.push(self.get_print_mode_context()); } parts.push(self.get_environment_context()); + if !self.custom_instructions.is_empty() { + parts.push(format!( + "\n# Custom Instructions\n{}", + self.custom_instructions + )); + } if let Some(ref registry) = self.tool_registry { parts.push(self.get_tools_context(registry).await); diff --git a/src/tools/permission.rs b/src/tools/permission.rs index c8adb22..0a11851 100644 --- a/src/tools/permission.rs +++ b/src/tools/permission.rs @@ -211,6 +211,7 @@ pub struct ToolPermissions { agent_policies: Arc, permission_rules: Arc, agent_permission_rules: Arc>, + global_tool_config: Arc>, dangerously_skip_permissions: bool, } @@ -224,6 +225,7 @@ impl ToolPermissions { agent_policies: Arc::new(AgentToolPolicies::default()), permission_rules: Arc::new(Vec::new()), agent_permission_rules: Arc::new(HashMap::new()), + global_tool_config: Arc::new(HashMap::new()), dangerously_skip_permissions: false, } } @@ -233,6 +235,11 @@ impl ToolPermissions { self } + pub fn with_global_tool_config(mut self, tools: HashMap) -> Self { + self.global_tool_config = Arc::new(tools); + self + } + pub fn with_permission_rules(mut self, rules: PermissionRules) -> Self { self.permission_rules = Arc::new(rules); self @@ -264,6 +271,11 @@ impl ToolPermissions { pub fn is_tool_allowed_for_agent(&self, agent_mode: &str, tool_id: &str) -> bool { self.agent_policies.is_allowed(agent_mode, tool_id) + && self + .global_tool_config + .get(tool_id) + .copied() + .unwrap_or(true) } pub fn is_tool_visible_for_agent(&self, agent_mode: &str, tool_id: &str) -> bool {