From 27438fe63695b54974349527b2df5c7a92e5a893 Mon Sep 17 00:00:00 2001 From: Yanuar Date: Mon, 3 Aug 2026 14:54:56 +0700 Subject: [PATCH 1/3] 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 { From 5cf9be15a0620eea67f2aa4f7e1bc87ab864c0a5 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 6 Aug 2026 13:03:09 +0800 Subject: [PATCH 2/3] fix(config): apply top-level tools/instructions/providers in TUI Wire global tool config, custom instructions, and provider filters into the interactive App path so they match print-mode behavior. --- src/app.rs | 17 +++++++++++++---- src/config/configuration.rs | 7 +++++++ src/model/discovery.rs | 2 +- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/app.rs b/src/app.rs index 8a85848..1190ad0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -859,6 +859,7 @@ pub struct App { pub websearch: crate::config::configuration::WebsearchConfig, pub mcp: crate::config::configuration::McpConfig, pub config_raw_merged: serde_json::Value, + custom_instructions: String, terminal_focused: bool, pub tool_permissions: crate::tools::ToolPermissions, pub skills_dirs: Vec, @@ -1127,13 +1128,17 @@ impl App { } let tool_permissions = crate::tools::ToolPermissions::new(cwd_path.clone()) .with_agent_policies(agent_policies) + .with_global_tool_config(loaded_config.merged_config.tools.clone()) .with_permission_rules(loaded_config.merged_config.permission_rules.clone()) .with_agent_permission_rules(agent_registry.permission_rules_map()); - let discovery = crate::model::discovery::Discovery::new_with_custom(Some( - loaded_config.merged_config.custom_providers.clone(), - )) + let discovery = crate::model::discovery::Discovery::new_with_config( + Some(loaded_config.merged_config.custom_providers.clone()), + loaded_config.merged_config.disabled_providers.clone(), + loaded_config.merged_config.enabled_providers.clone(), + ) .ok(); + let custom_instructions = loaded_config.merged_config.instructions.join("\n\n"); let now = std::time::Instant::now(); Ok(Self { @@ -1209,6 +1214,7 @@ impl App { websearch: loaded_config.merged_config.websearch, mcp: mcp_config, config_raw_merged: loaded_config.raw_merged, + custom_instructions, terminal_focused: true, tool_permissions, skills_dirs: loaded_config.inventory.opencode_skills_dirs, @@ -9411,6 +9417,7 @@ impl App { let agent_registry = self.agent_registry.clone(); let websearch_config = self.websearch.clone(); let mcp_config = self.mcp.clone(); + let custom_instructions = self.custom_instructions.clone(); let cwd = self.cwd.clone(); let is_git_repo = crate::utils::git::is_git_repo(&cwd).unwrap_or(false); @@ -9456,7 +9463,8 @@ impl App { ) .with_tool_registry(prompt_registry) .with_agent_registry(agent_registry.clone()) - .with_active_agent(agent_mode.clone()); + .with_active_agent(agent_mode.clone()) + .with_custom_instructions(custom_instructions); let system_prompt = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { composer.compose().await }) }); @@ -11002,6 +11010,7 @@ mod tests { websearch: crate::config::configuration::WebsearchConfig::default(), mcp: crate::config::configuration::McpConfig::default(), config_raw_merged: serde_json::json!({}), + custom_instructions: String::new(), terminal_focused: true, tool_permissions: crate::tools::ToolPermissions::new(".".to_string()), skills_dirs: Vec::new(), diff --git a/src/config/configuration.rs b/src/config/configuration.rs index 551c1f8..4fc91b8 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -2540,6 +2540,13 @@ fn collect_unimplemented_keys(merged: &Value) -> Vec { "notifications", "images", "websearch", + "instructions", + "tools", + "watcher", + "disabled_providers", + "enabled_providers", + "permission", + "mcp", ] .into_iter() .collect(); diff --git a/src/model/discovery.rs b/src/model/discovery.rs index 24130eb..646c397 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -318,7 +318,7 @@ impl Discovery { Self::new_with_config(custom_providers, Default::default(), None) } - fn new_with_config( + pub fn new_with_config( custom_providers: Option< std::collections::HashMap, >, From ac7da1f61008809787f9e717f5d65c41e403ae89 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 6 Aug 2026 13:22:25 +0800 Subject: [PATCH 3/3] feat(config): centralize runtime setup for tool permissions and discovery - add `ConfigRuntime`/`ConfigRuntimeOptions` to build tool permissions, discovery, and custom instructions from merged config in one place - switch interactive app and print mode to use shared runtime construction and remove duplicated setup logic - move print-mode interactive tool denies (`question`, `update_plan`) into shared runtime path - expose `Discovery::provider_is_enabled` for tests and extend runtime tests for shared wiring behavior --- src/app.rs | 25 ++---- src/config/mod.rs | 2 + src/config/runtime.rs | 195 +++++++++++++++++++++++++++++++++++++++++ src/main.rs | 71 +++++++-------- src/model/discovery.rs | 2 +- 5 files changed, 236 insertions(+), 59 deletions(-) create mode 100644 src/config/runtime.rs diff --git a/src/app.rs b/src/app.rs index 1190ad0..cb8c723 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1122,23 +1122,14 @@ impl App { let chat_state = init_chat(chat, &agent, &colors); let session_rename_dialog_state = init_session_rename_dialog(colors); - let mut agent_policies = crate::tools::AgentToolPolicies::default(); - for (mode, tools) in agent_registry.tool_policy_map() { - agent_policies = agent_policies.with_custom_tools(mode.clone(), tools.clone()); - } - let tool_permissions = crate::tools::ToolPermissions::new(cwd_path.clone()) - .with_agent_policies(agent_policies) - .with_global_tool_config(loaded_config.merged_config.tools.clone()) - .with_permission_rules(loaded_config.merged_config.permission_rules.clone()) - .with_agent_permission_rules(agent_registry.permission_rules_map()); - - let discovery = crate::model::discovery::Discovery::new_with_config( - Some(loaded_config.merged_config.custom_providers.clone()), - loaded_config.merged_config.disabled_providers.clone(), - loaded_config.merged_config.enabled_providers.clone(), - ) - .ok(); - let custom_instructions = loaded_config.merged_config.instructions.join("\n\n"); + let runtime = crate::config::ConfigRuntime::from_merged( + &loaded_config.merged_config, + cwd_path.clone(), + crate::config::ConfigRuntimeOptions::default(), + ); + let tool_permissions = runtime.tool_permissions; + let discovery = runtime.discovery; + let custom_instructions = runtime.custom_instructions; let now = std::time::Instant::now(); Ok(Self { diff --git a/src/config/mod.rs b/src/config/mod.rs index ae3beaa..0b01b66 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,10 +1,12 @@ pub mod configuration; +pub mod runtime; pub use configuration::{ ConfigLoader, CustomProviderConfig, ImageOpenCommandConfig, ImageOpenWith, ImagesConfig, McpConfig, McpServerConfig, NotificationEventConfig, NotificationsConfig, ProviderTimeout, TerminalNotificationCondition, TerminalNotificationMode, }; +pub use runtime::{ConfigRuntime, ConfigRuntimeOptions}; #[cfg(test)] pub use configuration::McpLocalConfig; diff --git a/src/config/runtime.rs b/src/config/runtime.rs new file mode 100644 index 0000000..a509ea9 --- /dev/null +++ b/src/config/runtime.rs @@ -0,0 +1,195 @@ +//! Shared config → runtime wiring used by both print mode and the interactive TUI. +//! +//! Keep permission, discovery, and instruction construction here so new top-level +//! settings cannot be applied in only one entrypoint. + +use std::path::{Path, PathBuf}; + +use crate::config::configuration::MergedConfig; +use crate::model::discovery::Discovery; +use crate::tools::{ + AgentToolPolicies, PermissionPolicyAction, PermissionRule, PermissionRules, ToolPermissions, +}; + +/// Options that differ between print mode and the interactive app. +#[derive(Debug, Clone, Default)] +pub struct ConfigRuntimeOptions { + /// When true, deny interactive-only tools (`question`, `update_plan`). + pub print_mode: bool, + /// Skip permission prompts (print-mode `--dangerously-skip-permissions`). + pub dangerously_skip_permissions: bool, +} + +/// Runtime pieces derived from merged config. +pub struct ConfigRuntime { + pub tool_permissions: ToolPermissions, + pub discovery: Option, + pub custom_instructions: String, +} + +impl ConfigRuntime { + /// Build permissions, discovery, and instructions from already-loaded config. + pub fn from_merged( + merged: &MergedConfig, + cwd: impl Into, + options: ConfigRuntimeOptions, + ) -> Self { + let cwd = cwd.into(); + let custom_instructions = merged.instructions.join("\n\n"); + + let mut agent_policies = AgentToolPolicies::default(); + for (mode, tools) in merged.agent_registry.tool_policy_map() { + agent_policies = agent_policies.with_custom_tools(mode, tools); + } + + let mut permission_rules = merged.permission_rules.clone(); + if options.print_mode { + permission_rules = deny_print_mode_interactive_tools(permission_rules); + } + + let tool_permissions = ToolPermissions::new(cwd) + .with_agent_policies(agent_policies) + .with_global_tool_config(merged.tools.clone()) + .with_permission_rules(permission_rules) + .with_agent_permission_rules(merged.agent_registry.permission_rules_map()) + .dangerously_skip_permissions(options.dangerously_skip_permissions); + + let discovery = Discovery::new_with_config( + Some(merged.custom_providers.clone()), + merged.disabled_providers.clone(), + merged.enabled_providers.clone(), + ) + .ok(); + + Self { + tool_permissions, + discovery, + custom_instructions, + } + } + + /// Convenience overload taking a `Path`. + pub fn from_merged_at( + merged: &MergedConfig, + cwd: &Path, + options: ConfigRuntimeOptions, + ) -> Self { + Self::from_merged(merged, cwd.to_path_buf(), options) + } +} + +fn deny_print_mode_interactive_tools(mut rules: PermissionRules) -> PermissionRules { + for tool_id in ["question", "update_plan"] { + rules.push(PermissionRule { + permission: tool_id.to_string(), + pattern: "*".to_string(), + action: PermissionPolicyAction::Deny, + }); + } + rules +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::configuration::MergedConfig; + use std::collections::HashSet; + + #[test] + fn applies_global_tool_disable() { + let mut merged = MergedConfig::default(); + merged.tools.insert("bash".into(), false); + + let rt = + ConfigRuntime::from_merged(&merged, "/tmp/workspace", ConfigRuntimeOptions::default()); + + assert!(!rt + .tool_permissions + .is_tool_allowed_for_agent("build", "bash")); + assert!(!rt + .tool_permissions + .is_tool_visible_for_agent("build", "bash")); + // Unmentioned tools remain available. + assert!(rt + .tool_permissions + .is_tool_allowed_for_agent("build", "read")); + } + + #[test] + fn joins_custom_instructions() { + let mut merged = MergedConfig::default(); + merged.instructions = vec!["first".into(), "second".into()]; + + let rt = + ConfigRuntime::from_merged(&merged, "/tmp/workspace", ConfigRuntimeOptions::default()); + + assert_eq!(rt.custom_instructions, "first\n\nsecond"); + } + + #[test] + fn print_mode_denies_interactive_only_tools() { + let merged = MergedConfig::default(); + + let print_rt = ConfigRuntime::from_merged( + &merged, + "/tmp/workspace", + ConfigRuntimeOptions { + print_mode: true, + ..Default::default() + }, + ); + let tui_rt = + ConfigRuntime::from_merged(&merged, "/tmp/workspace", ConfigRuntimeOptions::default()); + + assert!(!print_rt + .tool_permissions + .is_tool_visible_for_agent("build", "question")); + assert!(!print_rt + .tool_permissions + .is_tool_visible_for_agent("build", "update_plan")); + assert!(tui_rt + .tool_permissions + .is_tool_visible_for_agent("build", "question")); + } + + #[test] + fn threads_provider_filters_into_discovery() { + let mut merged = MergedConfig::default(); + merged.disabled_providers = HashSet::from(["openai".into()]); + merged.enabled_providers = Some(HashSet::from(["anthropic".into()])); + + let rt = + ConfigRuntime::from_merged(&merged, "/tmp/workspace", ConfigRuntimeOptions::default()); + + let discovery = rt.discovery.expect("discovery should construct"); + assert!(!discovery.provider_is_enabled("openai")); + assert!(!discovery.provider_is_enabled("other")); // not in allowlist + assert!(discovery.provider_is_enabled("anthropic")); + } + + #[test] + fn tui_and_print_share_same_tool_and_instruction_wiring() { + let mut merged = MergedConfig::default(); + merged.tools.insert("bash".into(), false); + merged.instructions = vec!["Always begin with CUSTOM-INSTRUCTION.".into()]; + + let tui = + ConfigRuntime::from_merged(&merged, "/tmp/workspace", ConfigRuntimeOptions::default()); + let print = ConfigRuntime::from_merged( + &merged, + "/tmp/workspace", + ConfigRuntimeOptions { + print_mode: true, + ..Default::default() + }, + ); + + assert_eq!(tui.custom_instructions, print.custom_instructions); + assert!(!tui + .tool_permissions + .is_tool_allowed_for_agent("build", "bash")); + assert!(!print + .tool_permissions + .is_tool_allowed_for_agent("build", "bash")); + } +} diff --git a/src/main.rs b/src/main.rs index 25e89df..f38baf0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -337,7 +337,20 @@ async fn run_print_mode( }) .flatten(); let requested_reasoning = reasoning_override.or(saved_reasoning); - let discovery = crate::model::discovery::Discovery::new().ok(); + + let cwd = loaded_config.cwd.to_string_lossy().to_string(); + let runtime = crate::config::ConfigRuntime::from_merged( + &loaded_config.merged_config, + std::path::PathBuf::from(&cwd), + crate::config::ConfigRuntimeOptions { + print_mode: true, + dangerously_skip_permissions, + }, + ); + let discovery = runtime.discovery; + let tool_permissions = runtime.tool_permissions; + let custom_instructions = runtime.custom_instructions; + let reasoning_effort = discovery .as_ref() .and_then(|discovery| discovery.get_model_reasoning_capability(&provider_name, &model_id)) @@ -350,8 +363,6 @@ async fn run_print_mode( } }); - let cwd = loaded_config.cwd.to_string_lossy().to_string(); - let is_git_repo = crate::utils::git::is_git_repo(&cwd).unwrap_or(false); let (sender, mut receiver) = mpsc::unbounded_channel(); @@ -359,18 +370,6 @@ async fn run_print_mode( let agent_registry = loaded_config.merged_config.agent_registry.clone(); let websearch_config = loaded_config.merged_config.websearch.clone(); let mcp_config = loaded_config.merged_config.mcp.clone(); - let mut agent_policies = crate::tools::AgentToolPolicies::default(); - for (mode, tools) in agent_registry.tool_policy_map() { - agent_policies = agent_policies.with_custom_tools(mode, tools); - } - let permission_rules = - 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); let agent_max_steps = agent_registry .get(&agent_mode) .and_then(|agent| agent.max_steps); @@ -403,7 +402,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_custom_instructions(custom_instructions) .with_print_mode(true); let system_prompt = composer.compose().await; let messages = vec![Message::system(system_prompt), Message::user(prompt)]; @@ -560,19 +559,6 @@ fn estimate_text_tokens(content: &str) -> usize { content.chars().count().max(1) / 4 } -fn print_mode_permission_rules( - mut rules: crate::tools::PermissionRules, -) -> crate::tools::PermissionRules { - for tool_id in ["question", "update_plan"] { - rules.push(crate::tools::PermissionRule { - permission: tool_id.to_string(), - pattern: "*".to_string(), - action: crate::tools::PermissionPolicyAction::Deny, - }); - } - rules -} - fn parse_reasoning_effort_arg( value: &str, ) -> Result { @@ -1142,18 +1128,21 @@ mod tests { #[test] fn print_mode_denies_interactive_tools() { - let rules = print_mode_permission_rules(Vec::new()); - - assert!(rules.iter().any(|rule| { - rule.permission == "question" - && rule.pattern == "*" - && rule.action == crate::tools::PermissionPolicyAction::Deny - })); - assert!(rules.iter().any(|rule| { - rule.permission == "update_plan" - && rule.pattern == "*" - && rule.action == crate::tools::PermissionPolicyAction::Deny - })); + let rt = crate::config::ConfigRuntime::from_merged( + &crate::config::configuration::MergedConfig::default(), + "/tmp/workspace", + crate::config::ConfigRuntimeOptions { + print_mode: true, + ..Default::default() + }, + ); + + assert!(!rt + .tool_permissions + .is_tool_visible_for_agent("build", "question")); + assert!(!rt + .tool_permissions + .is_tool_visible_for_agent("build", "update_plan")); } } diff --git a/src/model/discovery.rs b/src/model/discovery.rs index 646c397..4ebf54f 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -354,7 +354,7 @@ impl Discovery { } } - fn provider_is_enabled(&self, provider_id: &str) -> bool { + pub fn provider_is_enabled(&self, provider_id: &str) -> bool { !self.disabled_providers.contains(provider_id) && self .enabled_providers