1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Default, Serialize, Deserialize)]
8pub struct Config {
9 #[serde(default)]
10 pub provider: ProviderConfig,
11 #[serde(default)]
12 pub models: ModelsConfig,
13 #[serde(default)]
14 pub tui: TuiConfig,
15 #[serde(default)]
16 pub agent: AgentSettings,
17 #[serde(default)]
18 pub mcp: McpConfig,
19 #[serde(default)]
20 pub external_notify: ExternalNotifyConfig,
21 #[serde(default)]
22 pub shell: ShellConfig,
23 #[serde(default)]
24 pub browser: BrowserConfig,
25 #[serde(default)]
26 pub memory: MemoryConfig,
27 #[serde(default)]
28 pub update: UpdateConfig,
29 #[serde(default)]
30 pub index: IndexConfig,
31}
32
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34pub struct ShellConfig {
35 #[serde(default)]
36 pub path: Option<String>,
37 #[serde(default)]
38 pub env: HashMap<String, String>,
39 #[serde(default)]
40 pub startup_commands: Vec<String>,
41 #[serde(default)]
42 pub sandbox: SandboxSettings,
43}
44
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
46pub struct SandboxSettings {
47 #[serde(default)]
48 pub enabled: bool,
49 #[serde(default)]
50 pub allow_network: Vec<String>,
51 #[serde(default)]
52 pub allow_read: Vec<String>,
53 #[serde(default)]
54 pub allow_write: Vec<String>,
55 #[serde(default = "default_true")]
56 pub block_dotfiles: bool,
57}
58
59#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60pub struct BrowserConfig {
61 #[serde(default)]
62 pub enabled: bool,
63 #[serde(default)]
64 pub executable_path: Option<String>,
65 #[serde(default = "default_true")]
66 pub headless: bool,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct MemoryConfig {
71 #[serde(default = "default_true")]
72 pub auto_memory: bool,
73}
74
75impl Default for MemoryConfig {
76 fn default() -> Self {
77 Self { auto_memory: true }
78 }
79}
80
81fn default_embedding_mode() -> String {
82 "auto".to_string()
83}
84
85fn default_embedding_model() -> String {
86 String::new()
87}
88
89fn default_auto_context_chunks() -> usize {
90 5
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct IndexConfig {
95 #[serde(default = "default_true")]
96 pub enabled: bool,
97 #[serde(default = "default_embedding_mode")]
99 pub embedding: String,
100 #[serde(default = "default_embedding_model")]
103 pub embedding_model: String,
104 #[serde(default = "default_true")]
105 pub auto_context: bool,
106 #[serde(default = "default_auto_context_chunks")]
107 pub auto_context_chunks: usize,
108 #[serde(default)]
109 pub exclude: Vec<String>,
110}
111
112impl Default for IndexConfig {
113 fn default() -> Self {
114 Self {
115 enabled: true,
116 embedding: default_embedding_mode(),
117 embedding_model: default_embedding_model(),
118 auto_context: true,
119 auto_context_chunks: default_auto_context_chunks(),
120 exclude: vec![],
121 }
122 }
123}
124
125fn default_check_interval_hours() -> u32 {
126 4
127}
128
129fn default_release_url() -> String {
130 "https://get.quavil.com".to_string()
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct UpdateConfig {
135 #[serde(default = "default_true")]
136 pub enabled: bool,
137 #[serde(default = "default_check_interval_hours")]
138 pub check_interval_hours: u32,
139 #[serde(default = "default_release_url")]
140 pub release_url: String,
141}
142
143impl Default for UpdateConfig {
144 fn default() -> Self {
145 Self {
146 enabled: true,
147 check_interval_hours: default_check_interval_hours(),
148 release_url: default_release_url(),
149 }
150 }
151}
152
153#[derive(Debug, Clone, Default, Serialize, Deserialize)]
154pub struct ExternalNotifyConfig {
155 #[serde(default)]
156 pub webhook_url: Option<String>,
157 #[serde(default)]
158 pub telegram_bot_token: Option<String>,
159 #[serde(default)]
160 pub telegram_chat_id: Option<String>,
161 #[serde(default)]
162 pub discord_webhook_url: Option<String>,
163 #[serde(default)]
164 pub slack_webhook_url: Option<String>,
165}
166
167#[derive(Debug, Clone, Default, Serialize, Deserialize)]
168pub struct McpConfig {
169 #[serde(default)]
170 pub servers: HashMap<String, McpServerConfig>,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174#[serde(untagged)]
175pub enum McpServerConfig {
176 Stdio {
177 command: String,
178 #[serde(default)]
179 args: Vec<String>,
180 #[serde(default)]
181 env: HashMap<String, String>,
182 },
183 Http {
184 url: String,
185 #[serde(default)]
186 headers: HashMap<String, String>,
187 },
188}
189
190#[derive(Debug, Clone, Default, Serialize, Deserialize)]
191pub struct AgentSettings {
192 #[serde(default)]
193 pub max_steps: Option<u32>,
194 #[serde(default)]
195 pub max_tokens: Option<u32>,
196 #[serde(default)]
197 pub custom_instructions: Option<String>,
198 #[serde(default)]
199 pub trust: TrustConfig,
200 #[serde(default)]
201 pub retry: RetrySettings,
202 #[serde(default)]
203 pub hooks: Vec<HookConfig>,
204 #[serde(default)]
205 pub commands: Vec<CommandConfig>,
206 #[serde(default)]
207 pub routing: RoutingConfig,
208 #[serde(default)]
209 pub auto_compact_threshold: Option<f64>,
210 #[serde(default)]
211 pub compact_instructions: Option<String>,
212 #[serde(default)]
213 pub enforce_todos: bool,
214 #[serde(default)]
215 pub auto_simplify: bool,
216 #[serde(default)]
217 pub verify: VerifyConfig,
218 #[serde(default)]
219 pub agents: AgentManagerConfig,
220 #[serde(default)]
221 pub auto_commit: bool,
222 #[serde(default)]
223 pub model_profile: Option<String>,
224 #[serde(default)]
226 pub subagent_model: Option<String>,
227 #[serde(default)]
229 pub sharing: SharingConfig,
230 #[serde(default)]
232 pub voice: VoiceConfig,
233}
234
235#[derive(Debug, Clone, Default, Serialize, Deserialize)]
236pub struct SharingConfig {
237 #[serde(default)]
238 pub enabled: bool,
239 #[serde(default)]
240 pub pages_project: Option<String>,
241 #[serde(default)]
242 pub domain: Option<String>,
243 #[serde(default)]
244 pub redact_patterns: Vec<String>,
245}
246
247#[derive(Debug, Clone, Default, Serialize, Deserialize)]
248pub struct VoiceConfig {
249 #[serde(default)]
250 pub enabled: bool,
251 #[serde(default)]
252 pub api_key_env: Option<String>,
253 #[serde(default)]
254 pub model: Option<String>,
255}
256
257fn default_max_agents() -> usize {
258 4
259}
260
261fn default_max_agent_depth() -> u32 {
262 2
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct AgentManagerConfig {
267 #[serde(default = "default_max_agents")]
268 pub max_threads: usize,
269 #[serde(default = "default_max_agent_depth")]
270 pub max_depth: u32,
271 #[serde(default)]
272 pub roles: HashMap<String, AgentRoleToml>,
273}
274
275impl Default for AgentManagerConfig {
276 fn default() -> Self {
277 Self {
278 max_threads: default_max_agents(),
279 max_depth: default_max_agent_depth(),
280 roles: HashMap::new(),
281 }
282 }
283}
284
285#[derive(Debug, Clone, Default, Serialize, Deserialize)]
286pub struct AgentRoleToml {
287 #[serde(default)]
288 pub description: Option<String>,
289 #[serde(default)]
290 pub config_file: Option<String>,
291 #[serde(default)]
292 pub system_prompt: Option<String>,
293 #[serde(default)]
294 pub model: Option<String>,
295 #[serde(default)]
296 pub max_steps: Option<u32>,
297 #[serde(default)]
298 pub read_only: Option<bool>,
299 #[serde(default)]
300 pub allowed_tools: Option<Vec<String>>,
301 #[serde(default)]
302 pub disallowed_tools: Option<Vec<String>>,
303}
304
305#[derive(Debug, Clone, Default, Serialize, Deserialize)]
306pub struct VerifyConfig {
307 #[serde(default)]
308 pub checks: Vec<VerifyCheckConfig>,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct VerifyCheckConfig {
313 pub kind: String,
314 pub command: String,
315}
316
317#[derive(Debug, Clone, Default, Serialize, Deserialize)]
318pub struct RoutingConfig {
319 #[serde(default)]
320 pub enabled: bool,
321 #[serde(default)]
322 pub low_keywords: Vec<String>,
323 #[serde(default)]
324 pub high_keywords: Vec<String>,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct CommandConfig {
329 pub name: String,
330 pub prompt: String,
331 #[serde(default)]
332 pub description: Option<String>,
333}
334
335fn default_hook_timeout() -> u64 {
336 30
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct HookConfig {
341 pub event: HookEvent,
342 #[serde(default)]
343 pub command: String,
344 #[serde(default)]
345 pub hook_type: HookType,
346 #[serde(default)]
347 pub prompt: Option<String>,
348 #[serde(default)]
349 pub instructions: Option<String>,
350 #[serde(default)]
351 pub tools: Option<Vec<String>>,
352 #[serde(default)]
353 pub model: Option<String>,
354 #[serde(default)]
355 pub pattern: Option<String>,
356 #[serde(default)]
357 pub tool_name: Option<String>,
358 #[serde(default)]
359 pub block: bool,
360 #[serde(default = "default_hook_timeout")]
361 pub timeout: u64,
362}
363
364#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
365#[serde(rename_all = "snake_case")]
366pub enum HookType {
367 #[default]
368 Command,
369 Prompt,
370 Agent,
371}
372
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
374#[serde(rename_all = "snake_case")]
375pub enum HookEvent {
376 SessionStart,
377 UserPromptSubmit,
378 PreToolUse,
379 PostToolUse,
380 PostToolUseFailure,
381 PermissionRequest,
382 Notification,
383 AfterEdit,
384 AfterTurn,
385 SubagentStart,
386 SubagentEnd,
387 CompactContext,
388 WorktreeCreate,
389 WorktreeRemove,
390 ConfigChange,
391 TeammateIdle,
392 TaskCompleted,
393}
394
395impl std::fmt::Display for HookEvent {
396 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397 match self {
398 HookEvent::SessionStart => write!(f, "session_start"),
399 HookEvent::UserPromptSubmit => write!(f, "user_prompt_submit"),
400 HookEvent::PreToolUse => write!(f, "pre_tool_use"),
401 HookEvent::PostToolUse => write!(f, "post_tool_use"),
402 HookEvent::PostToolUseFailure => write!(f, "post_tool_use_failure"),
403 HookEvent::PermissionRequest => write!(f, "permission_request"),
404 HookEvent::Notification => write!(f, "notification"),
405 HookEvent::AfterEdit => write!(f, "after_edit"),
406 HookEvent::AfterTurn => write!(f, "after_turn"),
407 HookEvent::SubagentStart => write!(f, "subagent_start"),
408 HookEvent::SubagentEnd => write!(f, "subagent_end"),
409 HookEvent::CompactContext => write!(f, "compact_context"),
410 HookEvent::WorktreeCreate => write!(f, "worktree_create"),
411 HookEvent::WorktreeRemove => write!(f, "worktree_remove"),
412 HookEvent::ConfigChange => write!(f, "config_change"),
413 HookEvent::TeammateIdle => write!(f, "teammate_idle"),
414 HookEvent::TaskCompleted => write!(f, "task_completed"),
415 }
416 }
417}
418
419fn default_max_retries() -> u32 {
420 3
421}
422
423fn default_initial_backoff_ms() -> u64 {
424 1000
425}
426
427fn default_max_backoff_ms() -> u64 {
428 30000
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct RetrySettings {
433 #[serde(default = "default_max_retries")]
434 pub max_retries: u32,
435 #[serde(default = "default_initial_backoff_ms")]
436 pub initial_backoff_ms: u64,
437 #[serde(default = "default_max_backoff_ms")]
438 pub max_backoff_ms: u64,
439}
440
441impl Default for RetrySettings {
442 fn default() -> Self {
443 Self {
444 max_retries: default_max_retries(),
445 initial_backoff_ms: default_initial_backoff_ms(),
446 max_backoff_ms: default_max_backoff_ms(),
447 }
448 }
449}
450
451#[derive(Debug, Clone, Default, Serialize, Deserialize)]
452pub struct TrustConfig {
453 #[serde(default)]
454 pub mode: TrustMode,
455 #[serde(default)]
456 pub allow_tools: Vec<String>,
457 #[serde(default)]
458 pub allow_paths: Vec<String>,
459 #[serde(default)]
460 pub deny_tools: Vec<String>,
461 #[serde(default)]
462 pub deny_paths: Vec<String>,
463 #[serde(default)]
464 pub auto_approve: Vec<String>,
465 #[serde(default)]
466 pub always_ask: Vec<String>,
467 #[serde(default)]
468 pub remember_approvals: bool,
469}
470
471#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
472#[serde(rename_all = "lowercase")]
473pub enum TrustMode {
474 #[default]
475 Off,
476 Limited,
477 AutoEdit,
478 Full,
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
482#[serde(rename_all = "kebab-case")]
483pub enum SandboxLevel {
484 ReadOnly,
485 #[default]
486 WorkspaceWrite,
487 FullAccess,
488}
489
490impl std::fmt::Display for SandboxLevel {
491 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
492 match self {
493 SandboxLevel::ReadOnly => write!(f, "read-only"),
494 SandboxLevel::WorkspaceWrite => write!(f, "workspace-write"),
495 SandboxLevel::FullAccess => write!(f, "full-access"),
496 }
497 }
498}
499
500impl std::str::FromStr for SandboxLevel {
501 type Err = String;
502 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
503 match s.to_lowercase().as_str() {
504 "read-only" | "readonly" => Ok(SandboxLevel::ReadOnly),
505 "workspace-write" | "workspace" => Ok(SandboxLevel::WorkspaceWrite),
506 "full-access" | "full" | "danger-full-access" => Ok(SandboxLevel::FullAccess),
507 other => Err(format!(
508 "unknown sandbox level: {other} (use read-only, workspace-write, full-access)"
509 )),
510 }
511 }
512}
513
514impl std::fmt::Display for TrustMode {
515 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516 match self {
517 TrustMode::Off => write!(f, "off"),
518 TrustMode::Limited => write!(f, "limited"),
519 TrustMode::AutoEdit => write!(f, "autoedit"),
520 TrustMode::Full => write!(f, "full"),
521 }
522 }
523}
524
525impl std::str::FromStr for TrustMode {
526 type Err = String;
527 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
528 match s.to_lowercase().as_str() {
529 "off" => Ok(TrustMode::Off),
530 "limited" => Ok(TrustMode::Limited),
531 "autoedit" | "auto_edit" | "auto-edit" => Ok(TrustMode::AutoEdit),
532 "full" => Ok(TrustMode::Full),
533 other => Err(format!(
534 "unknown trust mode: {other} (use off, limited, autoedit, or full)"
535 )),
536 }
537 }
538}
539
540#[derive(Debug, Clone)]
541pub struct ProviderDef {
542 pub id: &'static str,
543 pub name: &'static str,
544 pub env_var: &'static str,
545 pub default_base_url: &'static str,
546 pub api_style: &'static str,
547 pub category: &'static str,
548 pub supports_oauth: bool,
549}
550
551pub const BUILT_IN_PROVIDERS: &[ProviderDef] = &[
552 ProviderDef {
553 id: "openai",
554 name: "OpenAI",
555 env_var: "OPENAI_API_KEY",
556 default_base_url: "https://api.openai.com/v1",
557 api_style: "openai",
558 category: "popular",
559 supports_oauth: true,
560 },
561 ProviderDef {
562 id: "anthropic",
563 name: "Anthropic",
564 env_var: "ANTHROPIC_API_KEY",
565 default_base_url: "https://api.anthropic.com/v1",
566 api_style: "anthropic",
567 category: "popular",
568 supports_oauth: true,
569 },
570 ProviderDef {
571 id: "gemini",
572 name: "Google Gemini",
573 env_var: "GEMINI_API_KEY",
574 default_base_url: "https://generativelanguage.googleapis.com/v1beta",
575 api_style: "gemini",
576 category: "popular",
577 supports_oauth: true,
578 },
579 ProviderDef {
580 id: "cursor",
581 name: "Cursor",
582 env_var: "CURSOR_API_KEY",
583 default_base_url: "https://api2.cursor.sh",
584 api_style: "cursor",
585 category: "popular",
586 supports_oauth: true,
587 },
588 ProviderDef {
589 id: "github-copilot",
590 name: "GitHub Copilot",
591 env_var: "GITHUB_COPILOT_TOKEN",
592 default_base_url: "https://api.githubcopilot.com",
593 api_style: "copilot",
594 category: "popular",
595 supports_oauth: true,
596 },
597 ProviderDef {
598 id: "openrouter",
599 name: "OpenRouter",
600 env_var: "OPENROUTER_API_KEY",
601 default_base_url: "https://openrouter.ai/api/v1",
602 api_style: "openai",
603 category: "popular",
604 supports_oauth: false,
605 },
606 ProviderDef {
607 id: "claude-sdk",
608 name: "Claude SDK Preset",
609 env_var: "ANTHROPIC_API_KEY",
610 default_base_url: "",
611 api_style: "claude-sdk",
612 category: "agents",
613 supports_oauth: false,
614 },
615 ProviderDef {
616 id: "codex",
617 name: "OpenAI Codex CLI",
618 env_var: "CODEX_API_KEY",
619 default_base_url: "",
620 api_style: "codex",
621 category: "agents",
622 supports_oauth: true,
623 },
624 ProviderDef {
625 id: "groq",
626 name: "Groq",
627 env_var: "GROQ_API_KEY",
628 default_base_url: "https://api.groq.com/openai/v1",
629 api_style: "openai",
630 category: "other",
631 supports_oauth: false,
632 },
633 ProviderDef {
634 id: "together",
635 name: "Together AI",
636 env_var: "TOGETHER_API_KEY",
637 default_base_url: "https://api.together.xyz/v1",
638 api_style: "openai",
639 category: "other",
640 supports_oauth: false,
641 },
642 ProviderDef {
643 id: "deepseek",
644 name: "DeepSeek",
645 env_var: "DEEPSEEK_API_KEY",
646 default_base_url: "https://api.deepseek.com/v1",
647 api_style: "openai",
648 category: "other",
649 supports_oauth: false,
650 },
651 ProviderDef {
652 id: "ollama",
653 name: "Ollama (local)",
654 env_var: "OLLAMA_API_KEY",
655 default_base_url: "http://localhost:11434/v1",
656 api_style: "openai",
657 category: "other",
658 supports_oauth: false,
659 },
660 ProviderDef {
661 id: "kimi",
662 name: "Kimi (Moonshot)",
663 env_var: "MOONSHOT_API_KEY",
664 default_base_url: "https://api.moonshot.ai/v1",
665 api_style: "openai",
666 category: "other",
667 supports_oauth: false,
668 },
669 ProviderDef {
670 id: "kimi-coding",
671 name: "Kimi Coding Plan",
672 env_var: "KIMI_CODING_API_KEY",
673 default_base_url: "https://api.kimi.com/coding",
674 api_style: "anthropic",
675 category: "other",
676 supports_oauth: false,
677 },
678 ProviderDef {
679 id: "minimax",
680 name: "MiniMax",
681 env_var: "MINIMAX_API_KEY",
682 default_base_url: "https://api.minimax.io/v1",
683 api_style: "openai",
684 category: "other",
685 supports_oauth: false,
686 },
687 ProviderDef {
688 id: "minimax-coding",
689 name: "MiniMax Coding Plan",
690 env_var: "MINIMAX_CODING_API_KEY",
691 default_base_url: "https://api.minimax.io/anthropic",
692 api_style: "anthropic",
693 category: "other",
694 supports_oauth: false,
695 },
696 ProviderDef {
697 id: "glm",
698 name: "GLM (Z.ai)",
699 env_var: "ZHIPU_API_KEY",
700 default_base_url: "https://api.z.ai/api/paas/v4",
701 api_style: "openai",
702 category: "other",
703 supports_oauth: false,
704 },
705 ProviderDef {
706 id: "glm-coding",
707 name: "GLM Coding Plan",
708 env_var: "ZHIPU_CODING_API_KEY",
709 default_base_url: "https://api.z.ai/api/coding/paas/v4",
710 api_style: "openai",
711 category: "other",
712 supports_oauth: false,
713 },
714];
715
716pub fn find_provider_def(id: &str) -> Option<&'static ProviderDef> {
717 BUILT_IN_PROVIDERS.iter().find(|p| p.id == id)
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize)]
721pub struct ProviderConfig {
722 #[serde(default = "default_provider")]
723 pub default: String,
724 #[serde(default, flatten)]
725 pub providers: HashMap<String, ProviderEntry>,
726}
727
728#[derive(Debug, Clone, Default, Serialize, Deserialize)]
729pub struct ProviderEntry {
730 pub api_key: Option<String>,
731 pub base_url: Option<String>,
732 pub model: Option<String>,
733 pub api_style: Option<String>,
734 pub max_tokens: Option<u32>,
735 pub temperature: Option<f32>,
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize)]
739pub struct ModelsConfig {
740 #[serde(default = "default_max_tokens")]
741 pub max_tokens: u32,
742 #[serde(default)]
743 pub temperature: Option<f32>,
744}
745
746#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
747#[serde(rename_all = "lowercase")]
748pub enum OutputStyle {
749 #[default]
750 Normal,
751 Verbose,
752 Minimal,
753 Structured,
754}
755
756impl std::fmt::Display for OutputStyle {
757 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
758 match self {
759 OutputStyle::Normal => write!(f, "normal"),
760 OutputStyle::Verbose => write!(f, "verbose"),
761 OutputStyle::Minimal => write!(f, "minimal"),
762 OutputStyle::Structured => write!(f, "structured"),
763 }
764 }
765}
766
767#[derive(Debug, Clone, Serialize, Deserialize)]
768pub struct TuiConfig {
769 #[serde(default = "default_true")]
770 pub markdown: bool,
771 #[serde(default = "default_true")]
772 pub streaming: bool,
773 #[serde(default = "default_theme")]
774 pub theme: String,
775 #[serde(default = "default_accent")]
776 pub accent: String,
777 #[serde(default)]
778 pub colors: ThemeOverrides,
779 #[serde(default)]
780 pub notify: NotifyConfig,
781 #[serde(default)]
782 pub output_style: OutputStyle,
783 #[serde(default = "default_true")]
784 pub show_thinking: bool,
785}
786
787#[derive(Debug, Clone, Serialize, Deserialize)]
788pub struct NotifyConfig {
789 #[serde(default = "default_true")]
790 pub bell: bool,
791 #[serde(default)]
792 pub desktop: bool,
793 #[serde(default = "default_min_duration_ms")]
794 pub min_duration_ms: u64,
795}
796
797fn default_min_duration_ms() -> u64 {
798 5000
799}
800
801impl Default for NotifyConfig {
802 fn default() -> Self {
803 Self {
804 bell: true,
805 desktop: false,
806 min_duration_ms: default_min_duration_ms(),
807 }
808 }
809}
810
811#[derive(Debug, Clone, Default, Serialize, Deserialize)]
812pub struct ThemeOverrides {
813 pub bg_page: Option<String>,
814 pub bg_surface: Option<String>,
815 pub bg_elevated: Option<String>,
816 pub bg_sunken: Option<String>,
817 pub text_primary: Option<String>,
818 pub text_secondary: Option<String>,
819 pub text_tertiary: Option<String>,
820 pub text_disabled: Option<String>,
821 pub border_default: Option<String>,
822 pub border_strong: Option<String>,
823 pub accent: Option<String>,
824 pub accent_muted: Option<String>,
825 pub success: Option<String>,
826 pub danger: Option<String>,
827 pub warning: Option<String>,
828 pub info: Option<String>,
829}
830
831fn default_provider() -> String {
832 "openai".to_string()
833}
834
835fn default_max_tokens() -> u32 {
836 4096
837}
838
839fn default_true() -> bool {
840 true
841}
842
843fn default_theme() -> String {
844 "dark".to_string()
845}
846
847fn default_accent() -> String {
848 "quavil-orange".to_string()
849}
850
851impl ProviderConfig {
852 pub fn entry(&self, name: &str) -> Option<&ProviderEntry> {
853 self.providers.get(name)
854 }
855}
856
857impl Default for ProviderConfig {
858 fn default() -> Self {
859 Self {
860 default: default_provider(),
861 providers: HashMap::new(),
862 }
863 }
864}
865
866impl Default for ModelsConfig {
867 fn default() -> Self {
868 Self {
869 max_tokens: default_max_tokens(),
870 temperature: None,
871 }
872 }
873}
874
875impl Default for TuiConfig {
876 fn default() -> Self {
877 Self {
878 markdown: true,
879 streaming: true,
880 theme: default_theme(),
881 accent: default_accent(),
882 colors: ThemeOverrides::default(),
883 notify: NotifyConfig::default(),
884 output_style: OutputStyle::Normal,
885 show_thinking: true,
886 }
887 }
888}
889
890impl Config {
891 pub fn user_root_dir() -> PathBuf {
892 std::env::var_os("QUAVIL_HOME")
893 .map(PathBuf::from)
894 .unwrap_or_else(|| {
895 dirs::home_dir()
896 .unwrap_or_else(|| PathBuf::from("."))
897 .join(".quavil")
898 })
899 }
900
901 pub fn load() -> Result<Self> {
902 let path = Self::config_path();
903 if path.exists() {
904 let content = std::fs::read_to_string(&path).context("Failed to read config file")?;
905 toml::from_str(&content).context("Failed to parse config file")
906 } else {
907 Ok(Self::default())
908 }
909 }
910
911 pub fn config_dir() -> PathBuf {
912 Self::user_root_dir()
913 }
914
915 pub fn config_path() -> PathBuf {
916 Self::config_dir().join("config.toml")
917 }
918
919 pub fn data_dir() -> PathBuf {
920 Self::user_root_dir()
921 }
922
923 pub fn ensure_dirs() -> Result<()> {
924 std::fs::create_dir_all(Self::config_dir())?;
925 std::fs::create_dir_all(Self::data_dir())?;
926 Ok(())
927 }
928
929 pub fn save(&self) -> Result<()> {
930 let path = Self::config_path();
931 Self::ensure_dirs()?;
932 let content = toml::to_string_pretty(self).context("Failed to serialize config")?;
933 std::fs::write(&path, content).context("Failed to write config file")?;
934 Ok(())
935 }
936
937 pub fn save_tui_preferences(theme: &str, accent: &str) -> Result<()> {
938 let mut config = Self::load()?;
939 config.tui.theme = theme.to_string();
940 config.tui.accent = accent.to_string();
941 config.save()
942 }
943
944 pub fn save_trust_mode(mode: &TrustMode) -> Result<()> {
945 let mut config = Self::load()?;
946 config.agent.trust.mode = mode.clone();
947 config.save()
948 }
949
950 pub fn save_provider_selection(provider_id: &str, model_id: Option<&str>) -> Result<()> {
951 let mut config = Self::load()?;
952 config.provider.default = provider_id.to_string();
953
954 if let Some(model_id) = model_id.map(str::trim).filter(|value| !value.is_empty()) {
955 config
956 .provider
957 .providers
958 .entry(provider_id.to_string())
959 .or_default()
960 .model = Some(model_id.to_string());
961 }
962
963 config.save()
964 }
965
966 pub fn preferred_model_for_provider(&self, provider_id: &str) -> Option<String> {
967 self.provider
968 .entry(provider_id)
969 .and_then(|entry| entry.model.clone())
970 .map(|value| value.trim().to_string())
971 .filter(|value| !value.is_empty())
972 }
973
974 pub fn load_project(project_root: &std::path::Path) -> Result<Option<Self>> {
975 let path = project_root.join(".quavil").join("config.toml");
976 if path.exists() {
977 let content =
978 std::fs::read_to_string(&path).context("Failed to read project config")?;
979 let config: Config =
980 toml::from_str(&content).context("Failed to parse project config")?;
981 Ok(Some(config))
982 } else {
983 Ok(None)
984 }
985 }
986
987 pub fn load_local(project_root: &std::path::Path) -> Result<Option<Self>> {
988 let path = project_root.join(".quavil").join("config.local.toml");
989 if path.exists() {
990 let content = std::fs::read_to_string(&path).context("Failed to read local config")?;
991 let config: Config =
992 toml::from_str(&content).context("Failed to parse local config")?;
993 Ok(Some(config))
994 } else {
995 Ok(None)
996 }
997 }
998
999 pub fn merge(global: &Config, project: &Config) -> Config {
1000 let provider = {
1001 let mut merged = global.provider.providers.clone();
1002 for (k, proj_entry) in &project.provider.providers {
1003 let base = merged.remove(k).unwrap_or_default();
1004 merged.insert(k.clone(), merge_provider_entry(&base, proj_entry));
1005 }
1006 ProviderConfig {
1007 default: if project.provider.default != default_provider() {
1008 project.provider.default.clone()
1009 } else {
1010 global.provider.default.clone()
1011 },
1012 providers: merged,
1013 }
1014 };
1015
1016 let mut mcp_servers = global.mcp.servers.clone();
1017 mcp_servers.extend(project.mcp.servers.clone());
1018
1019 Config {
1020 provider,
1021 models: ModelsConfig {
1022 max_tokens: if project.models.max_tokens != default_max_tokens() {
1023 project.models.max_tokens
1024 } else {
1025 global.models.max_tokens
1026 },
1027 temperature: project.models.temperature.or(global.models.temperature),
1028 },
1029 tui: global.tui.clone(),
1030 agent: AgentSettings {
1031 max_steps: project.agent.max_steps.or(global.agent.max_steps),
1032 max_tokens: project.agent.max_tokens.or(global.agent.max_tokens),
1033 custom_instructions: project
1034 .agent
1035 .custom_instructions
1036 .clone()
1037 .or_else(|| global.agent.custom_instructions.clone()),
1038 trust: {
1039 let base = if project.agent.trust.mode != TrustMode::Off {
1040 project.agent.trust.clone()
1041 } else {
1042 global.agent.trust.clone()
1043 };
1044 let mut deny_tools = global.agent.trust.deny_tools.clone();
1045 deny_tools.extend(project.agent.trust.deny_tools.clone());
1046 deny_tools.sort();
1047 deny_tools.dedup();
1048 let mut deny_paths = global.agent.trust.deny_paths.clone();
1049 deny_paths.extend(project.agent.trust.deny_paths.clone());
1050 deny_paths.sort();
1051 deny_paths.dedup();
1052 TrustConfig {
1053 deny_tools,
1054 deny_paths,
1055 ..base
1056 }
1057 },
1058 retry: RetrySettings {
1059 max_retries: if project.agent.retry.max_retries != default_max_retries() {
1060 project.agent.retry.max_retries
1061 } else {
1062 global.agent.retry.max_retries
1063 },
1064 initial_backoff_ms: if project.agent.retry.initial_backoff_ms
1065 != default_initial_backoff_ms()
1066 {
1067 project.agent.retry.initial_backoff_ms
1068 } else {
1069 global.agent.retry.initial_backoff_ms
1070 },
1071 max_backoff_ms: if project.agent.retry.max_backoff_ms
1072 != default_max_backoff_ms()
1073 {
1074 project.agent.retry.max_backoff_ms
1075 } else {
1076 global.agent.retry.max_backoff_ms
1077 },
1078 },
1079 hooks: {
1080 let mut hooks = global.agent.hooks.clone();
1081 hooks.extend(project.agent.hooks.clone());
1082 hooks
1083 },
1084 commands: {
1085 let mut cmds = global.agent.commands.clone();
1086 cmds.extend(project.agent.commands.clone());
1087 cmds
1088 },
1089 routing: if project.agent.routing.enabled {
1090 project.agent.routing.clone()
1091 } else {
1092 global.agent.routing.clone()
1093 },
1094 auto_compact_threshold: project
1095 .agent
1096 .auto_compact_threshold
1097 .or(global.agent.auto_compact_threshold),
1098 compact_instructions: project
1099 .agent
1100 .compact_instructions
1101 .clone()
1102 .or(global.agent.compact_instructions.clone()),
1103 enforce_todos: project.agent.enforce_todos || global.agent.enforce_todos,
1104 auto_simplify: project.agent.auto_simplify || global.agent.auto_simplify,
1105 verify: if !project.agent.verify.checks.is_empty() {
1106 project.agent.verify.clone()
1107 } else {
1108 global.agent.verify.clone()
1109 },
1110 agents: AgentManagerConfig {
1111 max_threads: if project.agent.agents.max_threads != default_max_agents() {
1112 project.agent.agents.max_threads
1113 } else {
1114 global.agent.agents.max_threads
1115 },
1116 max_depth: if project.agent.agents.max_depth != default_max_agent_depth() {
1117 project.agent.agents.max_depth
1118 } else {
1119 global.agent.agents.max_depth
1120 },
1121 roles: {
1122 let mut roles = global.agent.agents.roles.clone();
1123 roles.extend(project.agent.agents.roles.clone());
1124 roles
1125 },
1126 },
1127 auto_commit: project.agent.auto_commit || global.agent.auto_commit,
1128 model_profile: project
1129 .agent
1130 .model_profile
1131 .clone()
1132 .or(global.agent.model_profile.clone()),
1133 subagent_model: project
1134 .agent
1135 .subagent_model
1136 .clone()
1137 .or(global.agent.subagent_model.clone()),
1138 sharing: if project.agent.sharing.enabled {
1139 project.agent.sharing.clone()
1140 } else {
1141 global.agent.sharing.clone()
1142 },
1143 voice: if project.agent.voice.enabled {
1144 project.agent.voice.clone()
1145 } else {
1146 global.agent.voice.clone()
1147 },
1148 },
1149 mcp: McpConfig {
1150 servers: mcp_servers,
1151 },
1152 external_notify: ExternalNotifyConfig {
1153 webhook_url: project
1154 .external_notify
1155 .webhook_url
1156 .clone()
1157 .or_else(|| global.external_notify.webhook_url.clone()),
1158 telegram_bot_token: project
1159 .external_notify
1160 .telegram_bot_token
1161 .clone()
1162 .or_else(|| global.external_notify.telegram_bot_token.clone()),
1163 telegram_chat_id: project
1164 .external_notify
1165 .telegram_chat_id
1166 .clone()
1167 .or_else(|| global.external_notify.telegram_chat_id.clone()),
1168 discord_webhook_url: project
1169 .external_notify
1170 .discord_webhook_url
1171 .clone()
1172 .or_else(|| global.external_notify.discord_webhook_url.clone()),
1173 slack_webhook_url: project
1174 .external_notify
1175 .slack_webhook_url
1176 .clone()
1177 .or_else(|| global.external_notify.slack_webhook_url.clone()),
1178 },
1179 shell: ShellConfig {
1180 path: project
1181 .shell
1182 .path
1183 .clone()
1184 .or_else(|| global.shell.path.clone()),
1185 env: {
1186 let mut env = global.shell.env.clone();
1187 env.extend(project.shell.env.clone());
1188 env
1189 },
1190 startup_commands: if !project.shell.startup_commands.is_empty() {
1191 project.shell.startup_commands.clone()
1192 } else {
1193 global.shell.startup_commands.clone()
1194 },
1195 sandbox: if project.shell.sandbox.enabled {
1196 project.shell.sandbox.clone()
1197 } else {
1198 global.shell.sandbox.clone()
1199 },
1200 },
1201 browser: BrowserConfig {
1202 enabled: project.browser.enabled || global.browser.enabled,
1203 executable_path: project
1204 .browser
1205 .executable_path
1206 .clone()
1207 .or_else(|| global.browser.executable_path.clone()),
1208 headless: project.browser.headless && global.browser.headless,
1209 },
1210 memory: MemoryConfig {
1211 auto_memory: project.memory.auto_memory || global.memory.auto_memory,
1212 },
1213 update: UpdateConfig {
1214 enabled: global.update.enabled && project.update.enabled,
1215 check_interval_hours: if project.update.check_interval_hours
1216 != default_check_interval_hours()
1217 {
1218 project.update.check_interval_hours
1219 } else {
1220 global.update.check_interval_hours
1221 },
1222 release_url: global.update.release_url.clone(),
1225 },
1226 index: IndexConfig {
1227 enabled: global.index.enabled && project.index.enabled,
1228 embedding: if project.index.embedding != default_embedding_mode() {
1229 project.index.embedding.clone()
1230 } else {
1231 global.index.embedding.clone()
1232 },
1233 embedding_model: if project.index.embedding_model != default_embedding_model() {
1234 project.index.embedding_model.clone()
1235 } else {
1236 global.index.embedding_model.clone()
1237 },
1238 auto_context: global.index.auto_context && project.index.auto_context,
1239 auto_context_chunks: if project.index.auto_context_chunks
1240 != default_auto_context_chunks()
1241 {
1242 project.index.auto_context_chunks
1243 } else {
1244 global.index.auto_context_chunks
1245 },
1246 exclude: {
1247 let mut exc = global.index.exclude.clone();
1248 exc.extend(project.index.exclude.clone());
1249 exc.sort();
1250 exc.dedup();
1251 exc
1252 },
1253 },
1254 }
1255 }
1256}
1257
1258fn merge_provider_entry(global: &ProviderEntry, project: &ProviderEntry) -> ProviderEntry {
1259 ProviderEntry {
1260 api_key: project.api_key.clone().or_else(|| global.api_key.clone()),
1261 base_url: project.base_url.clone().or_else(|| global.base_url.clone()),
1262 model: project.model.clone().or_else(|| global.model.clone()),
1263 api_style: project
1264 .api_style
1265 .clone()
1266 .or_else(|| global.api_style.clone()),
1267 max_tokens: project.max_tokens.or(global.max_tokens),
1268 temperature: project.temperature.or(global.temperature),
1269 }
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274 use super::*;
1275 use std::path::PathBuf;
1276 use std::sync::{Mutex, OnceLock};
1277
1278 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1279 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1280 LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
1281 }
1282
1283 fn unique_temp_dir(label: &str) -> PathBuf {
1284 let nanos = std::time::SystemTime::now()
1285 .duration_since(std::time::UNIX_EPOCH)
1286 .unwrap_or_default()
1287 .as_nanos();
1288 let dir = std::env::temp_dir().join(format!(
1289 "quavil-config-{label}-{}-{nanos}",
1290 std::process::id()
1291 ));
1292 std::fs::create_dir_all(&dir).unwrap();
1293 dir
1294 }
1295
1296 struct TestEnv {
1297 root: PathBuf,
1298 old_home: Option<std::ffi::OsString>,
1299 old_xdg_config_home: Option<std::ffi::OsString>,
1300 old_xdg_data_home: Option<std::ffi::OsString>,
1301 }
1302
1303 impl TestEnv {
1304 fn new(label: &str) -> Self {
1305 let root = unique_temp_dir(label);
1306 let home = root.join("home");
1307 let xdg_config = root.join("xdg-config");
1308 let xdg_data = root.join("xdg-data");
1309 std::fs::create_dir_all(&home).unwrap();
1310 std::fs::create_dir_all(&xdg_config).unwrap();
1311 std::fs::create_dir_all(&xdg_data).unwrap();
1312
1313 let old_home = std::env::var_os("HOME");
1314 let old_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
1315 let old_xdg_data_home = std::env::var_os("XDG_DATA_HOME");
1316 std::env::set_var("HOME", &home);
1317 std::env::set_var("XDG_CONFIG_HOME", &xdg_config);
1318 std::env::set_var("XDG_DATA_HOME", &xdg_data);
1319
1320 Self {
1321 root,
1322 old_home,
1323 old_xdg_config_home,
1324 old_xdg_data_home,
1325 }
1326 }
1327 }
1328
1329 impl Drop for TestEnv {
1330 fn drop(&mut self) {
1331 match &self.old_home {
1332 Some(value) => std::env::set_var("HOME", value),
1333 None => std::env::remove_var("HOME"),
1334 }
1335 match &self.old_xdg_config_home {
1336 Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
1337 None => std::env::remove_var("XDG_CONFIG_HOME"),
1338 }
1339 match &self.old_xdg_data_home {
1340 Some(value) => std::env::set_var("XDG_DATA_HOME", value),
1341 None => std::env::remove_var("XDG_DATA_HOME"),
1342 }
1343 let _ = std::fs::remove_dir_all(&self.root);
1344 }
1345 }
1346
1347 #[test]
1348 fn save_provider_selection_updates_default_and_model_without_clobbering_other_settings() {
1349 let _guard = env_lock();
1350 let _env = TestEnv::new("provider-selection");
1351
1352 let mut config = Config::default();
1353 config.tui.theme = "amber".to_string();
1354 config
1355 .provider
1356 .providers
1357 .entry("anthropic".to_string())
1358 .or_default()
1359 .base_url = Some("https://api.anthropic.example".to_string());
1360 config.save().unwrap();
1361
1362 Config::save_provider_selection("anthropic", Some("claude-sonnet-4")).unwrap();
1363
1364 let saved = Config::load().unwrap();
1365 assert_eq!(saved.provider.default, "anthropic");
1366 assert_eq!(
1367 saved.preferred_model_for_provider("anthropic").as_deref(),
1368 Some("claude-sonnet-4")
1369 );
1370 assert_eq!(saved.tui.theme, "amber");
1371 assert_eq!(
1372 saved
1373 .provider
1374 .entry("anthropic")
1375 .and_then(|entry| entry.base_url.as_deref()),
1376 Some("https://api.anthropic.example")
1377 );
1378 }
1379
1380 #[test]
1381 fn config_defaults_to_quavil_orange_accent() {
1382 let config = Config::default();
1383 assert_eq!(config.tui.accent, "quavil-orange");
1384 }
1385}