1mod auth;
2mod codex_auth;
3mod custom_provider_config;
4mod hooks;
5mod paths;
6pub(crate) mod release_notes;
7mod settings;
8mod settings_storage;
9
10use crate::thinking::ThinkingLevel;
11pub(crate) use settings::DEFAULT_TUI_SUBAGENT_CARD_ROWS;
12#[cfg(test)]
13use std::path::PathBuf;
14use std::{collections::BTreeMap, env, fmt, io::IsTerminal};
15
16#[cfg(test)]
17pub(crate) use auth::Auth;
18#[cfg(test)]
19pub(crate) use auth::write_auth;
20pub(crate) use auth::{
21 AuthProviderRecord, AuthState, ProviderCredential, read_auth, read_auth_store,
22 remove_provider_auth, resolve_provider_credential,
23};
24pub(crate) use auth::{
25 CredentialReadiness, classify_provider_auth_record, extract_chatgpt_account_id_from_jwt,
26};
27#[cfg(test)]
28pub(crate) use auth::{
29 classify_codex_oauth_record, custom_provider_auth_readiness, extract_oauth_account_id_from_jwt,
30};
31pub(crate) use codex_auth::{
32 NormalizedToken, OPENAI_CODEX_CLIENT_ID, OPENAI_CODEX_REDIRECT_URI,
33 OPENAI_CODEX_RELOGIN_GUIDANCE, codex_credential_from_store,
34 codex_credential_from_store_with_exchange, exchange_codex_code,
35 force_refresh_codex_credential_from_store, persist_codex_token, refresh_codex_token,
36 refreshed_codex_auth_state,
37};
38pub use custom_provider_config::{
39 CustomProviderConfig, CustomProviderFastMode, CustomProviderHeaderValue,
40 CustomReasoningProtocol,
41};
42pub(crate) use custom_provider_config::{
43 derive_custom_provider_id, looks_like_secret_value, make_custom_provider_config,
44 normalize_custom_provider_base_url, normalized_extra_models, validate_custom_provider_id,
45 validate_optional_env_var_name,
46};
47pub use hooks::{
48 HookDefinition, HookFailurePolicy, HookPayloadMode, HookSettings, InjectedContentSettings,
49 InjectedContentStyle,
50};
51pub use paths::McPaths;
52#[cfg(test)]
53pub(crate) use settings::EDITABLE_SETTINGS_PATHS;
54#[cfg(test)]
55pub(crate) use settings::ensure_settings_schema_files;
56#[cfg(test)]
57pub(crate) use settings::load_config_with_settings;
58#[cfg(test)]
59pub(crate) use settings::selected_primary_agent;
60pub(crate) use settings::set_selected_model;
61pub use settings::{
62 AnthropicCacheTtl, AstGrepToolSettings, AutoCompactionSettings, BashProtectionFailurePolicy,
63 BashProtectionLevel, BashProtectionSettings, BashToolSettings, CompactionSettings,
64 CompactionTimingSettings, CompletionVerificationFailurePolicy, CompletionVerificationSettings,
65 FastSettings, FindToolSettings, GrepToolSettings, HashEditToolSettings, HerdrSettings,
66 HumanizeProtectionFailurePolicy, HumanizeProtectionSettings, InstructionsSettings,
67 IntegrationsSettings, JevSettings, ListFilesToolSettings, LspServerConfig, LspServersSettings,
68 LspSettings, McpHttpServerConfig, McpOAuthConfig, McpServerConfig, McpServersSettings,
69 McpStdioServerConfig, ModelsSettings, OpenAiCodexSettings, OpenAiResponsesSettings,
70 PromptInjectionFailurePolicy, PromptInjectionProtectionLevel,
71 PromptInjectionProtectionSettings, ProviderStreamSettings, ReadToolSettings,
72 SelectedModelSettings, SessionTitleSettings, Settings, SideAgentSettings,
73 SkillSuggestionSettings, SkillsSettings, SubagentsSettings, SubagentsToolSettings,
74 SummarizerSettings, TextVerbosity, ToolSettings, TuiSettings, ViewImageToolSettings,
75 ViewImageVisionModelSettings, WriteToolSettings,
76};
77pub(crate) use settings::{AppearanceSettings, AutoCompactionLimit, set_appearance_theme};
78pub(crate) use settings::{
79 CompactionConfig, SessionTitleConfig, clamp_subagent_max_depth, validate_mcp_http_url_field,
80 validate_mcp_server_name, validate_view_image_identifier, validate_view_image_max_image_bytes,
81};
82pub(crate) use settings::{
83 DEFAULT_MCP_TIMEOUT_SECONDS, DEFAULT_SESSION_RETENTION_DAYS, SettingsListKind, SettingsScope,
84};
85pub(crate) use settings::{DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES, MAX_VIEW_IMAGE_MAX_IMAGE_BYTES};
86pub(crate) use settings::{
87 disabled_model_ids_from_settings, disabled_skill_names_from_settings,
88 disabled_subagent_profile_names_from_settings, disabled_tool_names_from_settings,
89 load_startup_config_with_settings,
90};
91pub(crate) use settings::{
92 disabled_names_for_modal_scope, fast_mode_enabled, read_settings, remove_custom_provider,
93 set_fast_mode, set_mcp_server_enabled, set_model_disabled_for_scope,
94 set_selected_primary_agent, set_skill_disabled_for_scope, set_subagent_profile_disabled,
95 set_thinking_level, set_tool_disabled, toggle_fast_mode, upsert_custom_provider,
96};
97#[cfg(test)]
98pub(crate) use settings::{
99 disabled_skill_names, set_skill_disabled, update_settings_preserving_unknown_top_level_fields,
100 write_settings,
101};
102pub(crate) use settings::{load_settings_editor, save_settings_editor};
103
104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
105pub(crate) enum ColorChoice {
106 #[default]
107 Auto,
108 Always,
109 Never,
110}
111
112#[derive(Debug, Clone, Default, PartialEq, Eq)]
113pub(crate) struct CliConfigOverrides {
114 pub(crate) provider: Option<String>,
115 pub(crate) model: Option<String>,
116 pub(crate) api_key: Option<String>,
117 pub(crate) color: Option<ColorChoice>,
118}
119
120#[derive(Clone, PartialEq, Eq)]
121pub(crate) struct EffectiveConfig {
122 pub(crate) provider: Option<String>,
123 pub(crate) model: Option<String>,
124 pub(crate) no_color: bool,
125 pub(crate) file_autocomplete_respects_gitignore: bool,
126 pub(crate) custom_providers: BTreeMap<String, CustomProviderConfig>,
127 pub(crate) thinking_level: ThinkingLevel,
128 pub(crate) auth: Option<ProviderCredential>,
129 pub(crate) paths: McPaths,
130}
131
132impl fmt::Debug for EffectiveConfig {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 f.debug_struct("EffectiveConfig")
135 .field("provider", &self.provider)
136 .field("model", &self.model)
137 .field("no_color", &self.no_color)
138 .field(
139 "file_autocomplete_respects_gitignore",
140 &self.file_autocomplete_respects_gitignore,
141 )
142 .field("custom_providers", &self.custom_providers)
143 .field("auth", &self.auth)
144 .field("thinking_level", &self.thinking_level)
145 .field("paths", &self.paths)
146 .finish()
147 }
148}
149
150impl EffectiveConfig {
151 #[cfg(test)]
152 pub(crate) fn load(paths: McPaths, cli: CliConfigOverrides) -> anyhow::Result<Self> {
153 let (config, _, _, _) = load_config_with_settings(paths, cli)?;
154 Ok(config)
155 }
156 pub(crate) fn selected_provider_model(settings: &Settings) -> (Option<String>, Option<String>) {
157 (
158 env::var("MC_PROVIDER")
159 .ok()
160 .or_else(|| settings.selected_model.provider.clone()),
161 env::var("MC_MODEL")
162 .ok()
163 .or_else(|| settings.selected_model.model.clone()),
164 )
165 }
166
167 pub(crate) fn from_loaded_settings(
168 paths: McPaths,
169 cli: CliConfigOverrides,
170 settings: Settings,
171 ) -> anyhow::Result<Self> {
172 let (color_enabled, _) = resolve_output_style(&settings, cli.color);
173 let (provider, model) = Self::selected_provider_model(&settings);
174 let provider = cli.provider.or(provider);
175 let model = cli.model.or(model);
176 let provider_id = provider
177 .as_deref()
178 .unwrap_or(crate::providers::OPENAI_CODEX_PROVIDER);
179 reject_retired_provider_selection(provider_id, model.as_deref().unwrap_or(""))?;
180 let auth_file = read_auth(&paths)?;
181 let auth = resolve_provider_credential(
182 provider_id,
183 &auth_file,
184 cli.api_key,
185 &settings.custom_providers,
186 )?;
187 Ok(Self {
188 provider,
189 model,
190 no_color: !color_enabled,
191 file_autocomplete_respects_gitignore: settings.file_autocomplete_respects_gitignore,
192 custom_providers: settings.custom_providers,
193 thinking_level: settings.selected_model.thinking_level.unwrap_or_default(),
194 auth,
195 paths,
196 })
197 }
198
199 #[cfg(test)]
200 pub(crate) fn validate_provider_ready(&self) -> Result<(), ConfigError> {
201 if self.provider.as_deref().unwrap_or_default().is_empty() {
202 return Err(ConfigError::MissingProvider {
203 settings_path: self.paths.settings_file.clone(),
204 });
205 }
206 if self.model.as_deref().unwrap_or_default().is_empty() {
207 return Err(ConfigError::MissingModel {
208 settings_path: self.paths.settings_file.clone(),
209 });
210 }
211 self.require_auth()?;
212 Ok(())
213 }
214
215 pub(crate) fn auth_state(&self) -> AuthState {
216 AuthState::for_provider_with_custom(
217 self.provider_id(),
218 self.auth.as_ref(),
219 &self.custom_providers,
220 )
221 }
222
223 pub(crate) fn require_auth(&self) -> Result<ProviderCredential, ConfigError> {
224 self.auth_state()
225 .credential()
226 .cloned()
227 .ok_or_else(|| self.missing_auth_error())
228 }
229
230 pub(crate) fn resolve_provider_auth_for_runtime(&self) -> anyhow::Result<ProviderCredential> {
231 self.resolve_provider_auth_for_runtime_with(codex_credential_from_store)
232 }
233
234 #[cfg(test)]
235 pub(crate) fn resolve_provider_auth_for_runtime_with_exchange(
236 &self,
237 exchange: impl FnOnce(&str) -> anyhow::Result<NormalizedToken>,
238 ) -> anyhow::Result<ProviderCredential> {
239 self.resolve_provider_auth_for_runtime_with(|paths| {
240 codex_credential_from_store_with_exchange(paths, exchange)
241 })
242 }
243
244 fn resolve_provider_auth_for_runtime_with(
245 &self,
246 prepare_codex_auth: impl FnOnce(&McPaths) -> anyhow::Result<ProviderCredential>,
247 ) -> anyhow::Result<ProviderCredential> {
248 if self.provider_id() == crate::providers::ANTHROPIC_PROVIDER {
249 return resolve_provider_credential(
251 self.provider_id(),
252 &read_auth(&self.paths)?,
253 None,
254 &self.custom_providers,
255 )?
256 .ok_or_else(|| self.missing_auth_error().into());
257 }
258
259 if self.provider_id() == crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER {
260 return resolve_provider_credential(
261 self.provider_id(),
262 &read_auth(&self.paths)?,
263 None,
264 &self.custom_providers,
265 )?
266 .ok_or_else(|| self.missing_auth_error().into());
267 }
268 if self.provider_id() != crate::providers::OPENAI_CODEX_PROVIDER {
269 return self.require_auth().map_err(Into::into);
270 }
271
272 prepare_codex_auth(&self.paths)
275 }
276
277 pub(crate) fn missing_auth_error(&self) -> ConfigError {
278 ConfigError::missing_auth_for_custom_providers(
279 self.provider_id(),
280 &self.custom_providers,
281 &self.paths.auth_file,
282 )
283 }
284
285 pub(crate) fn provider_id(&self) -> &str {
286 self.provider
287 .as_deref()
288 .unwrap_or(crate::providers::OPENAI_CODEX_PROVIDER)
289 }
290}
291
292pub(crate) fn reject_retired_provider_selection(provider: &str, model: &str) -> anyhow::Result<()> {
293 if provider != "claude-code" {
294 return Ok(());
295 }
296 let model = if model.trim().is_empty() {
297 "<model>"
298 } else {
299 model
300 };
301 anyhow::bail!(
302 "stale provider selection 'claude-code/{model}': the Claude Code provider was removed; select 'anthropic/{model}' instead, then set ANTHROPIC_API_KEY or configure a provider-keyed 'anthropic' API key; Claude Code OAuth/subscription credentials are not reused"
303 );
304}
305
306pub(crate) fn load_effective_provider_selection(
307 paths: &McPaths,
308 provider: &str,
309 model: &str,
310) -> anyhow::Result<EffectiveConfig> {
311 reject_retired_provider_selection(provider, model)?;
312 let settings = read_settings(paths)?;
313 let auth_file = read_auth(paths)?;
314 let auth = resolve_provider_credential(provider, &auth_file, None, &settings.custom_providers)?;
315 let (color_enabled, _) = resolve_output_style(&settings, None);
316 Ok(EffectiveConfig {
317 provider: Some(provider.to_string()),
318 model: Some(model.to_string()),
319 no_color: !color_enabled,
320 file_autocomplete_respects_gitignore: settings.file_autocomplete_respects_gitignore,
321 custom_providers: settings.custom_providers,
322 thinking_level: settings.selected_model.thinking_level.unwrap_or_default(),
323 auth,
324 paths: paths.clone(),
325 })
326}
327
328impl ConfigError {
329 #[cfg(test)]
330 pub(crate) fn missing_auth(provider: &str) -> Self {
331 missing_auth_error(provider, None, &PathBuf::from("~/.magi-code/auth.json"))
332 }
333
334 pub(crate) fn missing_auth_for_custom_providers(
335 provider: &str,
336 custom_providers: &BTreeMap<String, CustomProviderConfig>,
337 auth_file: &std::path::Path,
338 ) -> Self {
339 missing_auth_error(provider, custom_providers.get(provider), auth_file)
340 }
341}
342
343fn missing_auth_error(
344 provider: &str,
345 custom: Option<&CustomProviderConfig>,
346 auth_file: &std::path::Path,
347) -> ConfigError {
348 let auth_path = auth_file.display();
349 let message = if let Some(custom) = custom {
350 match &custom.api_key_env_var {
351 Some(env_var) => format!(
352 "missing auth: custom provider '{provider}' is configured but environment variable {env_var} is missing or empty"
353 ),
354 None => format!(
355 "missing auth: custom provider '{provider}' is configured for no-auth but could not be prepared"
356 ),
357 }
358 } else if provider == crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER {
359 "Claude subscription unavailable: on Unix, install Claude Code and run `claude auth login`; unset ANTHROPIC_API_KEY and other native backend overrides; API keys are not used".to_string()
360 } else if provider == crate::providers::OPENAI_CODEX_PROVIDER {
361 format!(
362 "missing auth: missing OAuth auth or expired OAuth credentials without refresh for provider 'openai-codex'; needs re-login with /login openai-codex; --api-key, MC_API_KEY, and OPENAI_API_KEY are unsupported for openai-codex; OAuth auth includes access token and accountId in {auth_path}"
363 )
364 } else if provider == crate::providers::ANTHROPIC_PROVIDER {
365 format!(
366 "missing auth: provider 'anthropic' requires an Anthropic API key; set ANTHROPIC_API_KEY or configure provider-keyed API-key auth in {auth_path}; OPENAI_API_KEY, MC_API_KEY, and --api-key are not used for Anthropic"
367 )
368 } else {
369 format!(
370 "missing auth for provider '{provider}'; configure provider-keyed auth in {auth_path}"
371 )
372 };
373 ConfigError::MissingAuth {
374 provider: provider.to_string(),
375 message,
376 }
377}
378
379fn resolve_output_style(settings: &Settings, cli_color: Option<ColorChoice>) -> (bool, bool) {
380 let stdout_is_tty = std::io::stdout().is_terminal();
381 resolve_output_style_for_stdout(settings, cli_color, stdout_is_tty)
382}
383
384pub(crate) fn resolve_output_style_for_stdout(
385 settings: &Settings,
386 cli_color: Option<ColorChoice>,
387 stdout_is_tty: bool,
388) -> (bool, bool) {
389 let policy = crate::appearance::resolve_color_policy_from_env(
390 settings.no_color,
391 cli_color,
392 stdout_is_tty,
393 env::var_os("NO_COLOR").is_some(),
394 env::var("COLORTERM").ok().as_deref(),
395 env::var("TERM").ok().as_deref(),
396 );
397 (policy.color_enabled, policy.unicode_enabled)
398}
399#[cfg_attr(test, expect(clippy::enum_variant_names))]
400#[derive(Debug, thiserror::Error, PartialEq, Eq)]
401pub(crate) enum ConfigError {
402 #[cfg(test)]
403 #[error(
404 "missing provider; set --provider, MC_PROVIDER, or selected_model.provider in {settings_path}"
405 )]
406 MissingProvider { settings_path: PathBuf },
407 #[cfg(test)]
408 #[error("missing model; set --model, MC_MODEL, or selected_model.model in {settings_path}")]
409 MissingModel { settings_path: PathBuf },
410 #[error("{message}")]
411 MissingAuth { provider: String, message: String },
412}
413
414pub(crate) fn load_context_budget(
415 config: &EffectiveConfig,
416) -> anyhow::Result<crate::context::ContextBudget> {
417 let settings = read_settings(&config.paths)?;
418 let mut budget = settings.context.unwrap_or_default();
419 let provider = config.provider_id();
420 let model = config
421 .model
422 .as_deref()
423 .unwrap_or_else(|| crate::providers::default_model_for_provider(provider));
424 if let Some(context_window) =
425 crate::model_catalog::cached_model_context_window(&config.paths, provider, model)
426 {
427 budget.max_tokens = context_window;
428 }
429 budget.apply_model_override(provider, model);
430 Ok(budget)
431}
432
433#[cfg(test)]
434mod tests;