1use std::fs;
2use std::path::PathBuf;
3
4use crate::brain::agents::AgentConfig;
5use crate::models::{ModelOverride, ModelProfile};
6use crate::rules::{AutoRule, RuleAction};
7
8#[derive(Debug, Clone)]
11pub struct Config {
12 pub interval: u64,
13 pub notify: bool,
14 pub debug: bool,
15 pub grouped: bool,
16 pub sort: Option<String>,
17 pub budget: Option<f64>,
18 pub kill_on_budget: bool,
19 pub webhook: Option<String>,
20 pub webhook_on: Option<Vec<String>>,
21 pub daily_limit: Option<f64>,
22 pub weekly_limit: Option<f64>,
23 pub context_warn_threshold: u8, pub model_overrides: Vec<ModelOverride>,
25 pub rules: Vec<AutoRule>,
26 pub health: HealthThresholds,
27 pub file_conflicts: bool, pub auto_deny_file_conflicts: bool, pub brain: Option<BrainConfig>,
30 pub relay: Option<RelayConfig>,
31 pub hive: Option<HiveConfig>,
32 pub lifecycle: LifecycleConfig,
33 pub idle: IdleConfig,
34 pub agents: Vec<AgentConfig>,
35}
36
37#[derive(Debug, Clone)]
40pub struct HealthThresholds {
41 pub cache_critical_pct: f64, pub cache_warning_pct: f64, pub cache_min_tokens: u64, pub cost_spike_critical: f64, pub cost_spike_warning: f64, pub loop_max_calls: u32, pub stall_min_cost: f64, pub stall_min_minutes: u64, pub context_critical_pct: f64, pub context_warning_pct: f64, pub decay_compaction_pct: f64, pub efficiency_critical_factor: f64, pub error_accel_factor: f64, pub repetition_threshold: u32, }
56
57impl Default for HealthThresholds {
58 fn default() -> Self {
59 Self {
60 cache_critical_pct: 10.0,
61 cache_warning_pct: 30.0,
62 cache_min_tokens: 10_000,
63 cost_spike_critical: 5.0,
64 cost_spike_warning: 2.5,
65 loop_max_calls: 10,
66 stall_min_cost: 5.0,
67 stall_min_minutes: 10,
68 context_critical_pct: 90.0,
69 context_warning_pct: 80.0,
70 decay_compaction_pct: 50.0,
71 efficiency_critical_factor: 2.0,
72 error_accel_factor: 2.0,
73 repetition_threshold: 3,
74 }
75 }
76}
77
78#[derive(Debug, Default)]
80struct RawHealthThresholds {
81 cache_critical_pct: Option<f64>,
82 cache_warning_pct: Option<f64>,
83 cache_min_tokens: Option<u64>,
84 cost_spike_critical: Option<f64>,
85 cost_spike_warning: Option<f64>,
86 loop_max_calls: Option<u32>,
87 stall_min_cost: Option<f64>,
88 stall_min_minutes: Option<u64>,
89 context_critical_pct: Option<f64>,
90 context_warning_pct: Option<f64>,
91 decay_compaction_pct: Option<f64>,
92 efficiency_critical_factor: Option<f64>,
93 error_accel_factor: Option<f64>,
94 repetition_threshold: Option<u32>,
95}
96
97#[derive(Debug, Clone)]
100pub struct BrainConfig {
101 pub enabled: bool,
102 pub endpoint: String,
103 pub model: String,
104 pub auto_mode: bool,
105 pub timeout_ms: u64,
106 pub max_context_tokens: u32,
107 pub few_shot_count: usize,
108 pub max_sessions: usize,
109 pub orchestrate: bool,
110 pub orchestrate_interval_secs: u64,
111}
112
113impl Default for BrainConfig {
114 fn default() -> Self {
115 Self {
116 enabled: true,
117 endpoint: "http://localhost:11434/api/generate".into(),
118 model: "gemma4:e4b".into(),
119 auto_mode: false,
120 timeout_ms: 5000,
121 max_context_tokens: 4000,
122 few_shot_count: 5,
123 max_sessions: 10,
124 orchestrate: false,
125 orchestrate_interval_secs: 30,
126 }
127 }
128}
129
130#[derive(Debug, Clone)]
132pub struct LifecycleConfig {
133 pub auto_restart: bool,
134 pub restart_threshold_pct: f64,
135 pub restart_only_when_idle: bool,
136}
137
138impl Default for LifecycleConfig {
139 fn default() -> Self {
140 Self {
141 auto_restart: false,
142 restart_threshold_pct: 90.0,
143 restart_only_when_idle: true,
144 }
145 }
146}
147
148#[allow(dead_code)]
150#[derive(Debug, Clone)]
151pub struct IdleConfig {
152 pub enabled: bool,
153 pub after_idle_mins: u64,
154 pub max_concurrent: usize,
155 pub max_cost_usd: f64,
156 pub tasks: Vec<IdleTask>,
157}
158
159#[allow(dead_code)]
160#[derive(Debug, Clone)]
161pub struct IdleTask {
162 pub prompt: String,
163 pub cwd: Option<String>,
164 pub priority: u32,
165}
166
167impl Default for IdleConfig {
168 fn default() -> Self {
169 Self {
170 enabled: false,
171 after_idle_mins: 15,
172 max_concurrent: 2,
173 max_cost_usd: 5.0,
174 tasks: Vec::new(),
175 }
176 }
177}
178
179#[derive(Debug, Clone)]
181pub struct RelayConfig {
182 pub enabled: bool,
183 pub listen_port: u16,
184 pub listen_addr: String,
185 pub max_peers: u8,
186 pub heartbeat_interval_secs: u64,
187 pub reconnect_max_secs: u64,
188 pub auto_connect: Vec<String>,
189}
190
191impl Default for RelayConfig {
192 fn default() -> Self {
193 Self {
194 enabled: false,
195 listen_port: 9847,
196 listen_addr: "0.0.0.0".into(),
197 max_peers: 8,
198 heartbeat_interval_secs: 30,
199 reconnect_max_secs: 60,
200 auto_connect: Vec::new(),
201 }
202 }
203}
204
205#[derive(Debug, Clone)]
207pub struct HiveConfig {
208 pub enabled: bool,
209 pub default_trust: f64,
210 pub auto_trust_drift: bool,
211 pub max_propagation: u32,
212 pub export_min_evidence: u32,
213 pub export_min_tool_decisions: u32,
214 pub knowledge_ttl_days: u32,
215 pub inject_unverified: bool,
216 pub share_categories: Vec<String>,
219 pub exclude_tools: Vec<String>,
221 pub exclude_commands: Vec<String>,
223 pub max_units: usize,
225 pub max_prompt_units: usize,
227 pub stale_peer_days: u32,
229}
230
231impl Default for HiveConfig {
232 fn default() -> Self {
233 Self {
234 enabled: false,
235 default_trust: 0.5,
236 auto_trust_drift: true,
237 max_propagation: 5,
238 export_min_evidence: 5,
239 export_min_tool_decisions: 10,
240 knowledge_ttl_days: 30,
241 inject_unverified: true,
242 share_categories: Vec::new(),
243 exclude_tools: Vec::new(),
244 exclude_commands: Vec::new(),
245 max_units: 500,
246 max_prompt_units: 20,
247 stale_peer_days: 90,
248 }
249 }
250}
251
252impl Default for Config {
253 fn default() -> Self {
254 Self {
255 interval: 2000,
256 notify: false,
257 debug: false,
258 grouped: false,
259 sort: None,
260 budget: None,
261 kill_on_budget: false,
262 webhook: None,
263 webhook_on: None,
264 daily_limit: None,
265 weekly_limit: None,
266 context_warn_threshold: 75,
267 model_overrides: Vec::new(),
268 rules: Vec::new(),
269 health: HealthThresholds::default(),
270 file_conflicts: true,
271 auto_deny_file_conflicts: false,
272 brain: None,
273 relay: None,
274 hive: None,
275 lifecycle: LifecycleConfig::default(),
276 idle: IdleConfig::default(),
277 agents: Vec::new(),
278 }
279 }
280}
281
282#[derive(Debug, Default)]
284struct RawConfig {
285 interval: Option<u64>,
286 notify: Option<bool>,
287 debug: Option<bool>,
288 grouped: Option<bool>,
289 sort: Option<String>,
290 budget: Option<f64>,
291 kill_on_budget: Option<bool>,
292 webhook_url: Option<String>,
293 webhook_events: Option<Vec<String>>,
294 daily_limit: Option<f64>,
295 weekly_limit: Option<f64>,
296 context_warn_threshold: Option<u8>,
297 model_overrides: Vec<ModelOverride>,
298 rules: Vec<AutoRule>,
299 health: Option<RawHealthThresholds>,
300 file_conflicts: Option<bool>,
301 auto_deny_file_conflicts: Option<bool>,
302 brain: Option<BrainConfig>,
303 relay: Option<RawRelayConfig>,
304 hive: Option<RawHiveConfig>,
305 lifecycle: Option<RawLifecycleConfig>,
306 idle: Option<RawIdleConfig>,
307 agents: Vec<AgentConfig>,
308}
309
310#[derive(Debug, Default)]
311struct RawRelayConfig {
312 enabled: Option<bool>,
313 listen_port: Option<u16>,
314 listen_addr: Option<String>,
315 max_peers: Option<u8>,
316 heartbeat_interval_secs: Option<u64>,
317 reconnect_max_secs: Option<u64>,
318 auto_connect: Option<Vec<String>>,
319}
320
321#[derive(Debug, Default)]
322struct RawHiveConfig {
323 enabled: Option<bool>,
324 default_trust: Option<f64>,
325 auto_trust_drift: Option<bool>,
326 max_propagation: Option<u32>,
327 export_min_evidence: Option<u32>,
328 export_min_tool_decisions: Option<u32>,
329 knowledge_ttl_days: Option<u32>,
330 inject_unverified: Option<bool>,
331 share_categories: Vec<String>,
332 exclude_tools: Vec<String>,
333 exclude_commands: Vec<String>,
334 max_units: Option<usize>,
335 max_prompt_units: Option<usize>,
336 stale_peer_days: Option<u32>,
337}
338
339#[derive(Debug, Default)]
340struct RawLifecycleConfig {
341 auto_restart: Option<bool>,
342 restart_threshold_pct: Option<f64>,
343 restart_only_when_idle: Option<bool>,
344}
345
346#[derive(Debug, Default)]
347struct RawIdleConfig {
348 enabled: Option<bool>,
349 after_idle_mins: Option<u64>,
350 max_concurrent: Option<usize>,
351 max_cost_usd: Option<f64>,
352}
353
354impl Config {
355 pub fn load() -> Self {
357 let mut config = Config::default();
358
359 if let Some(global) = global_config_path() {
361 if let Some(raw) = parse_config_file(&global) {
362 config.apply(raw);
363 }
364 }
365
366 if let Some(raw) = parse_config_file(&PathBuf::from(".claudectl.toml")) {
368 config.apply(raw);
369 }
370
371 config
372 }
373
374 fn apply(&mut self, raw: RawConfig) {
376 if let Some(v) = raw.interval {
377 self.interval = v;
378 }
379 if let Some(v) = raw.notify {
380 self.notify = v;
381 }
382 if let Some(v) = raw.debug {
383 self.debug = v;
384 }
385 if let Some(v) = raw.grouped {
386 self.grouped = v;
387 }
388 if let Some(v) = raw.sort {
389 self.sort = Some(v);
390 }
391 if let Some(v) = raw.budget {
392 self.budget = Some(v);
393 }
394 if let Some(v) = raw.kill_on_budget {
395 self.kill_on_budget = v;
396 }
397 if let Some(v) = raw.webhook_url {
398 self.webhook = Some(v);
399 }
400 if let Some(v) = raw.webhook_events {
401 self.webhook_on = Some(v);
402 }
403 if let Some(v) = raw.daily_limit {
404 self.daily_limit = Some(v);
405 }
406 if let Some(v) = raw.weekly_limit {
407 self.weekly_limit = Some(v);
408 }
409 if let Some(v) = raw.context_warn_threshold {
410 self.context_warn_threshold = v.min(100);
411 }
412 if let Some(h) = raw.health {
413 if let Some(v) = h.cache_critical_pct {
414 self.health.cache_critical_pct = v;
415 }
416 if let Some(v) = h.cache_warning_pct {
417 self.health.cache_warning_pct = v;
418 }
419 if let Some(v) = h.cache_min_tokens {
420 self.health.cache_min_tokens = v;
421 }
422 if let Some(v) = h.cost_spike_critical {
423 self.health.cost_spike_critical = v;
424 }
425 if let Some(v) = h.cost_spike_warning {
426 self.health.cost_spike_warning = v;
427 }
428 if let Some(v) = h.loop_max_calls {
429 self.health.loop_max_calls = v;
430 }
431 if let Some(v) = h.stall_min_cost {
432 self.health.stall_min_cost = v;
433 }
434 if let Some(v) = h.stall_min_minutes {
435 self.health.stall_min_minutes = v;
436 }
437 if let Some(v) = h.context_critical_pct {
438 self.health.context_critical_pct = v;
439 }
440 if let Some(v) = h.context_warning_pct {
441 self.health.context_warning_pct = v;
442 }
443 if let Some(v) = h.decay_compaction_pct {
444 self.health.decay_compaction_pct = v;
445 }
446 if let Some(v) = h.efficiency_critical_factor {
447 self.health.efficiency_critical_factor = v;
448 }
449 if let Some(v) = h.error_accel_factor {
450 self.health.error_accel_factor = v;
451 }
452 if let Some(v) = h.repetition_threshold {
453 self.health.repetition_threshold = v;
454 }
455 }
456 if let Some(v) = raw.file_conflicts {
457 self.file_conflicts = v;
458 }
459 if let Some(v) = raw.auto_deny_file_conflicts {
460 self.auto_deny_file_conflicts = v;
461 }
462 for override_ in raw.model_overrides {
463 upsert_model_override(&mut self.model_overrides, override_);
464 }
465 for rule in raw.rules {
466 if let Some(pos) = self.rules.iter().position(|r| r.name == rule.name) {
468 self.rules[pos] = rule;
469 } else {
470 self.rules.push(rule);
471 }
472 }
473 if let Some(brain) = raw.brain {
474 self.brain = Some(brain);
475 }
476 if let Some(raw_relay) = raw.relay {
477 let relay = self.relay.get_or_insert_with(RelayConfig::default);
478 if let Some(v) = raw_relay.enabled {
479 relay.enabled = v;
480 }
481 if let Some(v) = raw_relay.listen_port {
482 relay.listen_port = v;
483 }
484 if let Some(v) = raw_relay.listen_addr {
485 relay.listen_addr = v;
486 }
487 if let Some(v) = raw_relay.max_peers {
488 relay.max_peers = v;
489 }
490 if let Some(v) = raw_relay.heartbeat_interval_secs {
491 relay.heartbeat_interval_secs = v;
492 }
493 if let Some(v) = raw_relay.reconnect_max_secs {
494 relay.reconnect_max_secs = v;
495 }
496 if let Some(v) = raw_relay.auto_connect {
497 relay.auto_connect = v;
498 }
499 }
500 if let Some(raw_hive) = raw.hive {
501 let hive = self.hive.get_or_insert_with(HiveConfig::default);
502 if let Some(v) = raw_hive.enabled {
503 hive.enabled = v;
504 }
505 if let Some(v) = raw_hive.default_trust {
506 hive.default_trust = v.clamp(0.0, 1.0);
507 }
508 if let Some(v) = raw_hive.auto_trust_drift {
509 hive.auto_trust_drift = v;
510 }
511 if let Some(v) = raw_hive.max_propagation {
512 hive.max_propagation = v;
513 }
514 if let Some(v) = raw_hive.export_min_evidence {
515 hive.export_min_evidence = v;
516 }
517 if let Some(v) = raw_hive.export_min_tool_decisions {
518 hive.export_min_tool_decisions = v;
519 }
520 if let Some(v) = raw_hive.knowledge_ttl_days {
521 hive.knowledge_ttl_days = v;
522 }
523 if let Some(v) = raw_hive.inject_unverified {
524 hive.inject_unverified = v;
525 }
526 if !raw_hive.share_categories.is_empty() {
527 hive.share_categories = raw_hive.share_categories;
528 }
529 if !raw_hive.exclude_tools.is_empty() {
530 hive.exclude_tools = raw_hive.exclude_tools;
531 }
532 if !raw_hive.exclude_commands.is_empty() {
533 hive.exclude_commands = raw_hive.exclude_commands;
534 }
535 if let Some(v) = raw_hive.max_units {
536 hive.max_units = v;
537 }
538 if let Some(v) = raw_hive.max_prompt_units {
539 hive.max_prompt_units = v;
540 }
541 if let Some(v) = raw_hive.stale_peer_days {
542 hive.stale_peer_days = v;
543 }
544 }
545 if let Some(lc) = raw.lifecycle {
546 if let Some(v) = lc.auto_restart {
547 self.lifecycle.auto_restart = v;
548 }
549 if let Some(v) = lc.restart_threshold_pct {
550 self.lifecycle.restart_threshold_pct = v;
551 }
552 if let Some(v) = lc.restart_only_when_idle {
553 self.lifecycle.restart_only_when_idle = v;
554 }
555 }
556 if let Some(idle) = raw.idle {
557 if let Some(v) = idle.enabled {
558 self.idle.enabled = v;
559 }
560 if let Some(v) = idle.after_idle_mins {
561 self.idle.after_idle_mins = v;
562 }
563 if let Some(v) = idle.max_concurrent {
564 self.idle.max_concurrent = v;
565 }
566 if let Some(v) = idle.max_cost_usd {
567 self.idle.max_cost_usd = v;
568 }
569 }
570 for agent in raw.agents {
571 if let Some(pos) = self.agents.iter().position(|a| a.name == agent.name) {
572 self.agents[pos] = agent;
573 } else {
574 self.agents.push(agent);
575 }
576 }
577 }
578
579 pub fn print_resolved(&self) {
581 println!("Resolved configuration:");
582 println!();
583
584 if let Some(p) = global_config_path() {
585 if p.exists() {
586 println!(" Global config: {}", p.display());
587 } else {
588 println!(" Global config: {} (not found)", p.display());
589 }
590 }
591
592 let project_path = PathBuf::from(".claudectl.toml");
593 if project_path.exists() {
594 println!(" Project config: {}", project_path.display());
595 } else {
596 println!(" Project config: .claudectl.toml (not found)");
597 }
598
599 println!();
600 println!(" interval: {}ms", self.interval);
601 println!(" notify: {}", self.notify);
602 println!(" debug: {}", self.debug);
603 println!(" grouped: {}", self.grouped);
604 println!(
605 " sort: {}",
606 self.sort.as_deref().unwrap_or("default")
607 );
608 println!(
609 " budget: {}",
610 self.budget
611 .map(|b| format!("${b:.2}"))
612 .unwrap_or_else(|| "none".into())
613 );
614 println!(" kill_on_budget: {}", self.kill_on_budget);
615 println!(
616 " webhook: {}",
617 self.webhook.as_deref().unwrap_or("none")
618 );
619 println!(
620 " webhook_on: {}",
621 self.webhook_on
622 .as_ref()
623 .map(|v| v.join(", "))
624 .unwrap_or_else(|| "all".into())
625 );
626 println!(
627 " daily_limit: {}",
628 self.daily_limit
629 .map(|b| format!("${b:.2}"))
630 .unwrap_or_else(|| "none".into())
631 );
632 println!(
633 " weekly_limit: {}",
634 self.weekly_limit
635 .map(|b| format!("${b:.2}"))
636 .unwrap_or_else(|| "none".into())
637 );
638 println!(" context_warn: {}%", self.context_warn_threshold);
639 println!();
640 println!(" [orchestrate]");
641 println!(" file_conflicts: {}", self.file_conflicts);
642 println!(
643 " auto_deny_file_conflicts: {}",
644 self.auto_deny_file_conflicts
645 );
646 println!();
647 println!(" [health]");
648 println!(
649 " cache: critical <{:.0}%, warning <{:.0}%, min {}",
650 self.health.cache_critical_pct,
651 self.health.cache_warning_pct,
652 self.health.cache_min_tokens,
653 );
654 println!(
655 " cost: critical >{:.1}x, warning >{:.1}x",
656 self.health.cost_spike_critical, self.health.cost_spike_warning,
657 );
658 println!(" loop: {} calls", self.health.loop_max_calls);
659 println!(
660 " stall: >${:.0} and >{}min",
661 self.health.stall_min_cost, self.health.stall_min_minutes,
662 );
663 println!(
664 " context: critical >{:.0}%, warning >{:.0}%",
665 self.health.context_critical_pct, self.health.context_warning_pct,
666 );
667 println!(
668 " decay: compact >{:.0}%, efficiency >{:.1}x, errors >{:.1}x, repeats >{}",
669 self.health.decay_compaction_pct,
670 self.health.efficiency_critical_factor,
671 self.health.error_accel_factor,
672 self.health.repetition_threshold,
673 );
674 if self.model_overrides.is_empty() {
675 println!(" model_overrides: none");
676 } else {
677 println!(" model_overrides:");
678 for override_ in &self.model_overrides {
679 println!(
680 " {} => in ${:.2}/M, out ${:.2}/M, ctx {}",
681 override_.name,
682 override_.profile.input_per_m,
683 override_.profile.output_per_m,
684 override_.profile.context_max
685 );
686 }
687 }
688 }
689
690 pub fn print_template() {
692 print!(
693 r#"# claudectl configuration
694# Place this file at:
695# Project: .claudectl.toml (in your project root)
696# Global: ~/.config/claudectl/config.toml
697#
698# Priority: CLI flags > project config > global config > defaults
699# Only set values you want to override — unset keys use defaults.
700
701# ── General ─────────────────────────────────────────────────────────
702
703[defaults]
704# Refresh interval in milliseconds
705# interval = 2000
706
707# Enable desktop notifications on NeedsInput transitions
708# notify = false
709
710# Show debug timing metrics in the footer
711# debug = false
712
713# Group sessions by project in the table view
714# grouped = false
715
716# Default sort column: "Status", "Context", "Cost", "$/hr", "Elapsed"
717# sort = "Status"
718
719# Per-session budget in USD (alert at 80%, optionally kill at 100%)
720# budget = 10.00
721
722# Auto-kill sessions that exceed the budget (requires budget)
723# kill_on_budget = false
724
725# ── Webhook ─────────────────────────────────────────────────────────
726
727[webhook]
728# POST JSON on status changes
729# url = "https://hooks.slack.com/services/..."
730
731# Only fire on these status transitions (omit for all)
732# events = ["NeedsInput", "Finished"]
733
734# ── Budget Limits ───────────────────────────────────────────────────
735
736[budget]
737# Daily spending limit in USD
738# daily_limit = 50.00
739
740# Weekly spending limit in USD
741# weekly_limit = 200.00
742
743# ── Context ─────────────────────────────────────────────────────────
744
745[context]
746# Fire on_context_high hook when context usage crosses this percentage
747# warn_threshold = 75
748
749# ── Orchestration ───────────────────────────────────────────────────
750
751[orchestrate]
752# Detect file-level conflicts when multiple sessions edit the same file
753# file_conflicts = true
754
755# Auto-deny writes to files being edited by another session
756# auto_deny_file_conflicts = false
757
758# ── Health Check Thresholds ─────────────────────────────────────────
759
760[health]
761# Cache hit ratio thresholds (percentage, 0-100)
762# cache_critical_pct = 10.0
763# cache_warning_pct = 30.0
764# cache_min_tokens = 10000
765
766# Cost spike detection (multiplier of session average burn rate)
767# cost_spike_critical = 5.0
768# cost_spike_warning = 2.5
769
770# Loop detection (tool call count threshold when errors are present)
771# loop_max_calls = 10
772
773# Stall detection (minimum cost in USD and minutes with no file edits)
774# stall_min_cost = 5.0
775# stall_min_minutes = 10
776
777# Context saturation thresholds (percentage, 0-100)
778# context_critical_pct = 90.0
779# context_warning_pct = 80.0
780
781# Cognitive decay detection
782# decay_compaction_pct = 50.0 # Context % to suggest proactive /compact
783# efficiency_critical_factor = 2.0 # Tokens-per-edit ratio vs baseline to trigger
784# error_accel_factor = 2.0 # Error rate ratio vs baseline to trigger
785# repetition_threshold = 3 # File re-reads without edit to trigger
786
787# ── Model Pricing Overrides ─────────────────────────────────────────
788# Override built-in pricing for specific models.
789#
790# [models."my-custom-model"]
791# input_per_m = 3.00
792# output_per_m = 15.00
793# cache_read_per_m = 0.30
794# cache_write_per_m = 3.75
795# context_max = 200000
796
797# ── Auto-Rules ──────────────────────────────────────────────────────
798# Match sessions by status/tool/command/project/cost, then take action.
799# Deny rules always take precedence regardless of order.
800#
801# [rules.approve_reads]
802# match_status = ["Needs Input"]
803# match_tool = ["Read", "Glob", "Grep"]
804# action = "approve"
805#
806# [rules.deny_destructive]
807# match_tool = ["Bash"]
808# match_command = ["rm -rf", "git push --force"]
809# action = "deny"
810#
811# [rules.kill_runaway]
812# match_cost_above = 20.0
813# action = "terminate"
814#
815# [rules.auto_continue]
816# match_status = ["Waiting"]
817# action = "send"
818# message = "continue"
819
820# ── Event Hooks ─────────────────────────────────────────────────────
821# Run shell commands on session events.
822#
823# [hooks.on_needs_input]
824# run = "notify-send 'Session needs input'"
825#
826# [hooks.on_finished]
827# run = "say 'Session finished'"
828#
829# Available events: on_needs_input, on_finished, on_budget_80,
830# on_budget_exceeded, on_context_high, on_status_change
831
832# ── Brain (Local LLM) ──────────────────────────────────────────────
833
834# [brain]
835# enabled = true
836# endpoint = "http://localhost:11434/api/generate"
837# model = "gemma4:e4b"
838# auto = false
839# timeout_ms = 5000
840# max_context_tokens = 4000
841# few_shot_count = 5
842# max_sessions = 10
843# orchestrate = false
844# orchestrate_interval = 30
845
846# ── Relay / Hive (feature: relay) ─────────────────────────────────────
847#
848# [relay]
849# enabled = false
850# listen_addr = "0.0.0.0"
851# listen_port = 9847
852# max_peers = 8
853# heartbeat_interval_secs = 30
854# reconnect_max_secs = 60
855# auto_connect = []
856#
857# [hive]
858# enabled = false
859# default_trust = 0.5
860# auto_trust_drift = true
861# max_propagation = 5
862# export_min_evidence = 5
863# export_min_tool_decisions = 10
864# knowledge_ttl_days = 30
865# inject_unverified = true
866
867# ── External Agents ─────────────────────────────────────────────────
868#
869# [agents.codex]
870# type = "codex"
871# command = "codex --quiet"
872# capabilities = ["code-review", "refactoring"]
873# cwd = "/path/to/project"
874"#
875 );
876 }
877}
878
879fn global_config_path() -> Option<PathBuf> {
880 std::env::var_os("HOME").map(|home| {
881 PathBuf::from(home)
882 .join(".config")
883 .join("claudectl")
884 .join("config.toml")
885 })
886}
887
888fn parse_config_file(path: &PathBuf) -> Option<RawConfig> {
891 let content = fs::read_to_string(path).ok()?;
892 let mut raw = RawConfig::default();
893 let mut section = String::new();
894
895 for line in content.lines() {
896 let line = line.trim();
897
898 if line.is_empty() || line.starts_with('#') {
900 continue;
901 }
902
903 if line.starts_with('[') && line.ends_with(']') {
905 section = line[1..line.len() - 1].trim().to_string();
906 continue;
907 }
908
909 let Some((key, value)) = line.split_once('=') else {
911 continue;
912 };
913 let key = key.trim();
914 let value = value.trim();
915
916 let value = value.split('#').next().unwrap_or(value).trim();
918
919 match (section.as_str(), key) {
920 ("" | "defaults", "interval") => {
921 raw.interval = value.parse().ok();
922 }
923 ("" | "defaults", "notify") => {
924 raw.notify = parse_bool(value);
925 }
926 ("" | "defaults", "debug") => {
927 raw.debug = parse_bool(value);
928 }
929 ("" | "defaults", "grouped") => {
930 raw.grouped = parse_bool(value);
931 }
932 ("" | "defaults", "sort") => {
933 raw.sort = Some(unquote(value));
934 }
935 ("" | "defaults", "budget") => {
936 raw.budget = value.parse().ok();
937 }
938 ("" | "defaults", "kill_on_budget") => {
939 raw.kill_on_budget = parse_bool(value);
940 }
941 ("webhook", "url") => {
942 raw.webhook_url = Some(unquote(value));
943 }
944 ("webhook", "events") => {
945 raw.webhook_events = Some(parse_string_array(value));
946 }
947 ("budget", "daily_limit") => {
948 raw.daily_limit = value.parse().ok();
949 }
950 ("budget", "weekly_limit") => {
951 raw.weekly_limit = value.parse().ok();
952 }
953 ("context", "warn_threshold") => {
954 raw.context_warn_threshold = value.parse().ok();
955 }
956 ("orchestrate", "file_conflicts") => {
957 raw.file_conflicts = parse_bool(value);
958 }
959 ("orchestrate", "auto_deny_file_conflicts") => {
960 raw.auto_deny_file_conflicts = parse_bool(value);
961 }
962 ("health", key) => {
963 let h = raw.health.get_or_insert_with(RawHealthThresholds::default);
964 match key {
965 "cache_critical_pct" => h.cache_critical_pct = value.parse().ok(),
966 "cache_warning_pct" => h.cache_warning_pct = value.parse().ok(),
967 "cache_min_tokens" => h.cache_min_tokens = value.parse().ok(),
968 "cost_spike_critical" => h.cost_spike_critical = value.parse().ok(),
969 "cost_spike_warning" => h.cost_spike_warning = value.parse().ok(),
970 "loop_max_calls" => h.loop_max_calls = value.parse().ok(),
971 "stall_min_cost" => h.stall_min_cost = value.parse().ok(),
972 "stall_min_minutes" => h.stall_min_minutes = value.parse().ok(),
973 "context_critical_pct" => h.context_critical_pct = value.parse().ok(),
974 "context_warning_pct" => h.context_warning_pct = value.parse().ok(),
975 "decay_compaction_pct" => h.decay_compaction_pct = value.parse().ok(),
976 "efficiency_critical_factor" => {
977 h.efficiency_critical_factor = value.parse().ok()
978 }
979 "error_accel_factor" => h.error_accel_factor = value.parse().ok(),
980 "repetition_threshold" => h.repetition_threshold = value.parse().ok(),
981 _ => {}
982 }
983 }
984 _ if parse_model_section(§ion).is_some() => {
985 let Some(model_name) = parse_model_section(§ion) else {
986 continue;
987 };
988 let profile = ensure_model_override(&mut raw.model_overrides, &model_name);
989 match key {
990 "input_per_m" => {
991 profile.input_per_m = value.parse().unwrap_or(profile.input_per_m);
992 }
993 "output_per_m" => {
994 profile.output_per_m = value.parse().unwrap_or(profile.output_per_m);
995 }
996 "cache_read_per_m" => {
997 profile.cache_read_per_m =
998 value.parse().unwrap_or(profile.cache_read_per_m);
999 }
1000 "cache_write_per_m" => {
1001 profile.cache_write_per_m =
1002 value.parse().unwrap_or(profile.cache_write_per_m);
1003 }
1004 "context_max" => {
1005 profile.context_max = value.parse().unwrap_or(profile.context_max);
1006 }
1007 _ => {}
1008 }
1009 }
1010 _ if parse_rule_section(§ion).is_some() => {
1011 let Some(rule_name) = parse_rule_section(§ion) else {
1012 continue;
1013 };
1014 let rule = ensure_rule(&mut raw.rules, &rule_name);
1015 match key {
1016 "match_status" => rule.match_status = parse_string_array(value),
1017 "match_tool" => rule.match_tool = parse_string_array(value),
1018 "match_command" => rule.match_command = parse_string_array(value),
1019 "match_project" => rule.match_project = parse_string_array(value),
1020 "match_cost_above" => rule.match_cost_above = value.parse().ok(),
1021 "match_last_error" => rule.match_last_error = parse_bool(value),
1022 "match_file_conflict" => rule.match_file_conflict = parse_bool(value),
1023 "action" => {
1024 if let Some(a) = RuleAction::parse(&unquote(value)) {
1025 rule.action = a;
1026 }
1027 }
1028 "message" => rule.message = Some(unquote(value)),
1029 _ => {}
1030 }
1031 }
1032 ("lifecycle", key) => {
1033 let lc = raw
1034 .lifecycle
1035 .get_or_insert_with(RawLifecycleConfig::default);
1036 match key {
1037 "auto_restart" => lc.auto_restart = parse_bool(value),
1038 "restart_threshold_pct" => lc.restart_threshold_pct = value.parse().ok(),
1039 "restart_only_when_idle" => lc.restart_only_when_idle = parse_bool(value),
1040 _ => {}
1041 }
1042 }
1043 ("idle", key) => {
1044 let idle = raw.idle.get_or_insert_with(RawIdleConfig::default);
1045 match key {
1046 "enabled" => idle.enabled = parse_bool(value),
1047 "after_idle_mins" => idle.after_idle_mins = value.parse().ok(),
1048 "max_concurrent" => idle.max_concurrent = value.parse().ok(),
1049 "max_cost_usd" => idle.max_cost_usd = value.parse().ok(),
1050 _ => {}
1051 }
1052 }
1053 ("brain", _) => {
1054 let brain = raw.brain.get_or_insert_with(BrainConfig::default);
1055 match key {
1056 "enabled" => {
1057 if let Some(v) = parse_bool(value) {
1058 brain.enabled = v;
1059 }
1060 }
1061 "endpoint" => brain.endpoint = unquote(value),
1062 "model" => brain.model = unquote(value),
1063 "auto" => {
1064 if let Some(v) = parse_bool(value) {
1065 brain.auto_mode = v;
1066 }
1067 }
1068 "timeout_ms" => {
1069 if let Ok(v) = value.parse() {
1070 brain.timeout_ms = v;
1071 }
1072 }
1073 "max_context_tokens" => {
1074 if let Ok(v) = value.parse() {
1075 brain.max_context_tokens = v;
1076 }
1077 }
1078 "few_shot_count" => {
1079 if let Ok(v) = value.parse() {
1080 brain.few_shot_count = v;
1081 }
1082 }
1083 "max_sessions" => {
1084 if let Ok(v) = value.parse() {
1085 brain.max_sessions = v;
1086 }
1087 }
1088 "orchestrate" => {
1089 if let Some(v) = parse_bool(value) {
1090 brain.orchestrate = v;
1091 }
1092 }
1093 "orchestrate_interval" => {
1094 if let Ok(v) = value.parse() {
1095 brain.orchestrate_interval_secs = v;
1096 }
1097 }
1098 _ => {}
1099 }
1100 }
1101 ("relay", _) => {
1102 let relay = raw.relay.get_or_insert_with(RawRelayConfig::default);
1103 match key {
1104 "enabled" => {
1105 relay.enabled = parse_bool(value);
1106 }
1107 "listen_port" | "port" => {
1108 relay.listen_port = value.parse().ok();
1109 }
1110 "listen_addr" | "addr" => relay.listen_addr = Some(unquote(value)),
1111 "max_peers" => {
1112 relay.max_peers = value.parse().ok();
1113 }
1114 "heartbeat_interval" | "heartbeat_interval_secs" => {
1115 relay.heartbeat_interval_secs = value.parse().ok();
1116 }
1117 "reconnect_max" | "reconnect_max_secs" => {
1118 relay.reconnect_max_secs = value.parse().ok();
1119 }
1120 "auto_connect" => {
1121 relay.auto_connect = Some(parse_string_array(value));
1122 }
1123 _ => {}
1124 }
1125 }
1126 ("hive", _) => {
1127 let hive = raw.hive.get_or_insert_with(RawHiveConfig::default);
1128 match key {
1129 "enabled" => {
1130 hive.enabled = parse_bool(value);
1131 }
1132 "default_trust" => {
1133 hive.default_trust = value.parse().ok();
1134 }
1135 "auto_trust_drift" => {
1136 hive.auto_trust_drift = parse_bool(value);
1137 }
1138 "max_propagation" => {
1139 hive.max_propagation = value.parse().ok();
1140 }
1141 "export_min_evidence" => {
1142 hive.export_min_evidence = value.parse().ok();
1143 }
1144 "export_min_tool_decisions" => {
1145 hive.export_min_tool_decisions = value.parse().ok();
1146 }
1147 "knowledge_ttl_days" => {
1148 hive.knowledge_ttl_days = value.parse().ok();
1149 }
1150 "inject_unverified" => {
1151 hive.inject_unverified = parse_bool(value);
1152 }
1153 "share_categories" => {
1154 hive.share_categories = parse_string_array(value);
1155 }
1156 "exclude_tools" => {
1157 hive.exclude_tools = parse_string_array(value);
1158 }
1159 "exclude_commands" => {
1160 hive.exclude_commands = parse_string_array(value);
1161 }
1162 "max_units" => {
1163 hive.max_units = value.parse().ok();
1164 }
1165 "max_prompt_units" => {
1166 hive.max_prompt_units = value.parse().ok();
1167 }
1168 "stale_peer_days" => {
1169 hive.stale_peer_days = value.parse().ok();
1170 }
1171 _ => {}
1172 }
1173 }
1174 _ if parse_agent_section(§ion).is_some() => {
1175 let Some(agent_name) = parse_agent_section(§ion) else {
1176 continue;
1177 };
1178 let agent = ensure_agent(&mut raw.agents, &agent_name);
1179 match key {
1180 "type" => agent.agent_type = unquote(value),
1181 "command" => agent.command = unquote(value),
1182 "capabilities" => agent.capabilities = parse_string_array(value),
1183 "cwd" => agent.cwd = unquote(value),
1184 _ => {}
1185 }
1186 }
1187 _ => {} }
1189 }
1190
1191 Some(raw)
1192}
1193
1194pub fn load_hooks() -> crate::hooks::HookRegistry {
1196 let mut registry = crate::hooks::HookRegistry::new();
1197
1198 if let Some(global) = global_config_path() {
1199 parse_hooks_from_file(&global, &mut registry);
1200 }
1201 parse_hooks_from_file(&PathBuf::from(".claudectl.toml"), &mut registry);
1202
1203 registry
1204}
1205
1206fn parse_hooks_from_file(path: &PathBuf, registry: &mut crate::hooks::HookRegistry) {
1207 let content = match fs::read_to_string(path) {
1208 Ok(c) => c,
1209 Err(_) => return,
1210 };
1211
1212 let mut section = String::new();
1213
1214 for line in content.lines() {
1215 let line = line.trim();
1216 if line.is_empty() || line.starts_with('#') {
1217 continue;
1218 }
1219
1220 if line.starts_with('[') && line.ends_with(']') {
1221 section = line[1..line.len() - 1].trim().to_string();
1222 continue;
1223 }
1224
1225 if !section.starts_with("hooks.") {
1227 continue;
1228 }
1229
1230 let Some((key, value)) = line.split_once('=') else {
1231 continue;
1232 };
1233 let key = key.trim();
1234 let value = value.trim();
1235 let value = value.split('#').next().unwrap_or(value).trim();
1236
1237 if key == "run" {
1238 if let Some(event) = crate::hooks::HookEvent::from_section(§ion) {
1239 registry.add(event, unquote(value));
1240 }
1241 }
1242 }
1243}
1244
1245fn parse_bool(s: &str) -> Option<bool> {
1246 match s {
1247 "true" => Some(true),
1248 "false" => Some(false),
1249 _ => None,
1250 }
1251}
1252
1253fn unquote(s: &str) -> String {
1254 s.trim_matches('"').trim_matches('\'').to_string()
1255}
1256
1257fn parse_string_array(s: &str) -> Vec<String> {
1258 let s = s.trim_start_matches('[').trim_end_matches(']');
1259 s.split(',')
1260 .map(|item| unquote(item.trim()))
1261 .filter(|item| !item.is_empty())
1262 .collect()
1263}
1264
1265fn parse_model_section(section: &str) -> Option<String> {
1266 section.strip_prefix("models.").map(unquote)
1267}
1268
1269fn ensure_model_override<'a>(
1270 overrides: &'a mut Vec<ModelOverride>,
1271 model_name: &str,
1272) -> &'a mut ModelProfile {
1273 if let Some(index) = overrides.iter().position(|item| item.name == model_name) {
1274 return &mut overrides[index].profile;
1275 }
1276
1277 overrides.push(ModelOverride {
1278 name: model_name.to_string(),
1279 profile: ModelProfile {
1280 input_per_m: 0.0,
1281 output_per_m: 0.0,
1282 cache_read_per_m: 0.0,
1283 cache_write_per_m: 0.0,
1284 context_max: 0,
1285 },
1286 });
1287
1288 &mut overrides
1289 .last_mut()
1290 .expect("override was just pushed")
1291 .profile
1292}
1293
1294fn upsert_model_override(overrides: &mut Vec<ModelOverride>, incoming: ModelOverride) {
1295 if let Some(existing) = overrides.iter_mut().find(|item| item.name == incoming.name) {
1296 *existing = incoming;
1297 } else {
1298 overrides.push(incoming);
1299 }
1300}
1301
1302fn parse_rule_section(section: &str) -> Option<String> {
1303 section.strip_prefix("rules.").map(unquote)
1304}
1305
1306fn ensure_rule<'a>(rules: &'a mut Vec<AutoRule>, name: &str) -> &'a mut AutoRule {
1307 if let Some(index) = rules.iter().position(|r| r.name == name) {
1308 return &mut rules[index];
1309 }
1310 rules.push(AutoRule::new(name.to_string(), RuleAction::Approve));
1311 rules.last_mut().expect("rule was just pushed")
1312}
1313
1314fn parse_agent_section(section: &str) -> Option<String> {
1315 section.strip_prefix("agents.").map(unquote)
1316}
1317
1318fn ensure_agent<'a>(agents: &'a mut Vec<AgentConfig>, name: &str) -> &'a mut AgentConfig {
1319 if let Some(index) = agents.iter().position(|a| a.name == name) {
1320 return &mut agents[index];
1321 }
1322 agents.push(AgentConfig::new(name.to_string()));
1323 agents.last_mut().expect("agent was just pushed")
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328 use super::*;
1329
1330 #[test]
1331 fn test_parse_bool() {
1332 assert_eq!(parse_bool("true"), Some(true));
1333 assert_eq!(parse_bool("false"), Some(false));
1334 assert_eq!(parse_bool("yes"), None);
1335 }
1336
1337 #[test]
1338 fn test_unquote() {
1339 assert_eq!(unquote("\"hello\""), "hello");
1340 assert_eq!(unquote("'hello'"), "hello");
1341 assert_eq!(unquote("hello"), "hello");
1342 }
1343
1344 #[test]
1345 fn test_parse_string_array() {
1346 let result = parse_string_array("[\"NeedsInput\", \"Finished\"]");
1347 assert_eq!(result, vec!["NeedsInput", "Finished"]);
1348 }
1349
1350 #[test]
1351 fn test_parse_config_file() {
1352 use std::io::Write;
1353 let mut file = tempfile::NamedTempFile::new().unwrap();
1354 writeln!(
1355 file,
1356 r#"
1357# Global claudectl config
1358[defaults]
1359interval = 1000
1360notify = true
1361grouped = true
1362sort = "cost"
1363budget = 5.00
1364kill_on_budget = false
1365
1366[webhook]
1367url = "https://hooks.slack.com/test"
1368events = ["NeedsInput", "Finished"]
1369
1370[models."gpt-4o"]
1371input_per_m = 1.25
1372output_per_m = 5.0
1373cache_read_per_m = 0.15
1374cache_write_per_m = 0.9
1375context_max = 128000
1376"#
1377 )
1378 .unwrap();
1379 file.flush().unwrap();
1380
1381 let raw = parse_config_file(&file.path().to_path_buf()).unwrap();
1382 assert_eq!(raw.interval, Some(1000));
1383 assert_eq!(raw.notify, Some(true));
1384 assert_eq!(raw.grouped, Some(true));
1385 assert_eq!(raw.sort, Some("cost".into()));
1386 assert_eq!(raw.budget, Some(5.0));
1387 assert_eq!(raw.kill_on_budget, Some(false));
1388 assert_eq!(raw.webhook_url, Some("https://hooks.slack.com/test".into()));
1389 assert_eq!(
1390 raw.webhook_events,
1391 Some(vec!["NeedsInput".into(), "Finished".into()])
1392 );
1393 assert_eq!(raw.model_overrides.len(), 1);
1394 assert_eq!(raw.model_overrides[0].name, "gpt-4o");
1395 assert_eq!(raw.model_overrides[0].profile.context_max, 128_000);
1396 }
1397
1398 #[test]
1399 fn test_config_layering() {
1400 let mut config = Config::default();
1401 assert_eq!(config.interval, 2000);
1402 assert!(!config.notify);
1403
1404 config.apply(RawConfig {
1406 interval: Some(1000),
1407 notify: Some(true),
1408 budget: Some(5.0),
1409 ..RawConfig::default()
1410 });
1411 assert_eq!(config.interval, 1000);
1412 assert!(config.notify);
1413 assert_eq!(config.budget, Some(5.0));
1414
1415 config.apply(RawConfig {
1417 budget: Some(10.0),
1418 grouped: Some(true),
1419 ..RawConfig::default()
1420 });
1421 assert_eq!(config.interval, 1000); assert!(config.notify); assert_eq!(config.budget, Some(10.0)); assert!(config.grouped); config.apply(RawConfig {
1428 relay: Some(RawRelayConfig {
1429 enabled: Some(true),
1430 listen_port: Some(9000),
1431 ..RawRelayConfig::default()
1432 }),
1433 hive: Some(RawHiveConfig {
1434 enabled: Some(true),
1435 default_trust: Some(0.7),
1436 ..RawHiveConfig::default()
1437 }),
1438 ..RawConfig::default()
1439 });
1440 config.apply(RawConfig {
1441 relay: Some(RawRelayConfig {
1442 max_peers: Some(4),
1443 ..RawRelayConfig::default()
1444 }),
1445 hive: Some(RawHiveConfig {
1446 knowledge_ttl_days: Some(14),
1447 ..RawHiveConfig::default()
1448 }),
1449 ..RawConfig::default()
1450 });
1451 let relay = config.relay.as_ref().unwrap();
1452 assert!(relay.enabled);
1453 assert_eq!(relay.listen_port, 9000);
1454 assert_eq!(relay.max_peers, 4);
1455 let hive = config.hive.as_ref().unwrap();
1456 assert!(hive.enabled);
1457 assert_eq!(hive.default_trust, 0.7);
1458 assert_eq!(hive.knowledge_ttl_days, 14);
1459 }
1460
1461 #[test]
1462 fn test_parse_rules_from_config() {
1463 use std::io::Write;
1464 let mut file = tempfile::NamedTempFile::new().unwrap();
1465 writeln!(
1466 file,
1467 r#"
1468[rules.approve_reads]
1469match_status = ["Needs Input"]
1470match_tool = ["Read", "Glob", "Grep"]
1471action = "approve"
1472
1473[rules.deny_destructive]
1474match_status = ["Needs Input"]
1475match_tool = ["Bash"]
1476match_command = ["rm -rf", "git push --force"]
1477action = "deny"
1478
1479[rules.auto_continue]
1480match_status = ["Waiting"]
1481action = "send"
1482message = "continue"
1483
1484[rules.kill_expensive]
1485match_cost_above = 10.0
1486action = "terminate"
1487"#
1488 )
1489 .unwrap();
1490 file.flush().unwrap();
1491
1492 let raw = parse_config_file(&file.path().to_path_buf()).unwrap();
1493 assert_eq!(raw.rules.len(), 4);
1494
1495 let r0 = &raw.rules[0];
1496 assert_eq!(r0.name, "approve_reads");
1497 assert_eq!(r0.match_tool, vec!["Read", "Glob", "Grep"]);
1498 assert_eq!(r0.action, RuleAction::Approve);
1499
1500 let r1 = &raw.rules[1];
1501 assert_eq!(r1.name, "deny_destructive");
1502 assert_eq!(r1.match_command, vec!["rm -rf", "git push --force"]);
1503 assert_eq!(r1.action, RuleAction::Deny);
1504
1505 let r2 = &raw.rules[2];
1506 assert_eq!(r2.name, "auto_continue");
1507 assert_eq!(r2.action, RuleAction::Send);
1508 assert_eq!(r2.message, Some("continue".into()));
1509
1510 let r3 = &raw.rules[3];
1511 assert_eq!(r3.name, "kill_expensive");
1512 assert_eq!(r3.match_cost_above, Some(10.0));
1513 assert_eq!(r3.action, RuleAction::Terminate);
1514 }
1515
1516 #[test]
1517 fn test_parse_brain_config() {
1518 use std::io::Write;
1519 let mut file = tempfile::NamedTempFile::new().unwrap();
1520 writeln!(
1521 file,
1522 r#"
1523[brain]
1524enabled = true
1525endpoint = "http://localhost:8080/v1/chat"
1526model = "llama3:8b"
1527auto = true
1528timeout_ms = 3000
1529max_context_tokens = 8000
1530"#
1531 )
1532 .unwrap();
1533 file.flush().unwrap();
1534
1535 let raw = parse_config_file(&file.path().to_path_buf()).unwrap();
1536 let brain = raw.brain.expect("brain config should be parsed");
1537 assert!(brain.enabled);
1538 assert_eq!(brain.endpoint, "http://localhost:8080/v1/chat");
1539 assert_eq!(brain.model, "llama3:8b");
1540 assert!(brain.auto_mode);
1541 assert_eq!(brain.timeout_ms, 3000);
1542 assert_eq!(brain.max_context_tokens, 8000);
1543 }
1544
1545 #[test]
1546 fn test_no_brain_config_returns_none() {
1547 use std::io::Write;
1548 let mut file = tempfile::NamedTempFile::new().unwrap();
1549 writeln!(file, "[defaults]\ninterval = 1000").unwrap();
1550 file.flush().unwrap();
1551
1552 let raw = parse_config_file(&file.path().to_path_buf()).unwrap();
1553 assert!(raw.brain.is_none());
1554 }
1555
1556 #[test]
1557 fn test_parse_relay_hive_config() {
1558 use std::io::Write;
1559 let mut file = tempfile::NamedTempFile::new().unwrap();
1560 writeln!(
1561 file,
1562 r#"
1563[relay]
1564enabled = true
1565listen_port = 9999
1566listen_addr = "127.0.0.1"
1567max_peers = 3
1568auto_connect = ["peer-a:9847"]
1569
1570[hive]
1571enabled = true
1572default_trust = 0.65
1573max_propagation = 2
1574knowledge_ttl_days = 7
1575inject_unverified = false
1576"#
1577 )
1578 .unwrap();
1579 file.flush().unwrap();
1580
1581 let raw = parse_config_file(&file.path().to_path_buf()).unwrap();
1582 let relay = raw.relay.expect("relay config should be parsed");
1583 assert_eq!(relay.enabled, Some(true));
1584 assert_eq!(relay.listen_port, Some(9999));
1585 assert_eq!(relay.listen_addr.as_deref(), Some("127.0.0.1"));
1586 assert_eq!(relay.max_peers, Some(3));
1587 assert_eq!(relay.auto_connect, Some(vec!["peer-a:9847".into()]));
1588
1589 let hive = raw.hive.expect("hive config should be parsed");
1590 assert_eq!(hive.enabled, Some(true));
1591 assert_eq!(hive.default_trust, Some(0.65));
1592 assert_eq!(hive.max_propagation, Some(2));
1593 assert_eq!(hive.knowledge_ttl_days, Some(7));
1594 assert_eq!(hive.inject_unverified, Some(false));
1595 }
1596
1597 #[test]
1598 fn test_parse_agents_from_config() {
1599 use std::io::Write;
1600 let mut file = tempfile::NamedTempFile::new().unwrap();
1601 writeln!(
1602 file,
1603 r#"
1604[agents.codex]
1605type = "codex"
1606command = "codex --quiet"
1607capabilities = ["code-review", "refactoring"]
1608cwd = "/tmp/project"
1609
1610[agents.aider]
1611type = "aider"
1612command = "aider --yes"
1613capabilities = ["implementation", "debugging"]
1614"#
1615 )
1616 .unwrap();
1617 file.flush().unwrap();
1618
1619 let raw = parse_config_file(&file.path().to_path_buf()).unwrap();
1620 assert_eq!(raw.agents.len(), 2);
1621
1622 let codex = &raw.agents[0];
1623 assert_eq!(codex.name, "codex");
1624 assert_eq!(codex.agent_type, "codex");
1625 assert_eq!(codex.command, "codex --quiet");
1626 assert_eq!(codex.capabilities, vec!["code-review", "refactoring"]);
1627 assert_eq!(codex.cwd, "/tmp/project");
1628
1629 let aider = &raw.agents[1];
1630 assert_eq!(aider.name, "aider");
1631 assert_eq!(aider.command, "aider --yes");
1632 }
1633
1634 #[test]
1635 fn test_parse_health_thresholds() {
1636 use std::io::Write;
1637 let mut file = tempfile::NamedTempFile::new().unwrap();
1638 writeln!(
1639 file,
1640 r#"
1641[health]
1642cache_critical_pct = 5.0
1643cache_warning_pct = 20.0
1644cache_min_tokens = 50000
1645cost_spike_critical = 8.0
1646cost_spike_warning = 3.0
1647loop_max_calls = 15
1648stall_min_cost = 10.0
1649stall_min_minutes = 20
1650context_critical_pct = 95.0
1651context_warning_pct = 85.0
1652"#
1653 )
1654 .unwrap();
1655 file.flush().unwrap();
1656
1657 let raw = parse_config_file(&file.path().to_path_buf()).unwrap();
1658 let h = raw.health.expect("health config should be parsed");
1659 assert_eq!(h.cache_critical_pct, Some(5.0));
1660 assert_eq!(h.cache_warning_pct, Some(20.0));
1661 assert_eq!(h.cache_min_tokens, Some(50000));
1662 assert_eq!(h.cost_spike_critical, Some(8.0));
1663 assert_eq!(h.cost_spike_warning, Some(3.0));
1664 assert_eq!(h.loop_max_calls, Some(15));
1665 assert_eq!(h.stall_min_cost, Some(10.0));
1666 assert_eq!(h.stall_min_minutes, Some(20));
1667 assert_eq!(h.context_critical_pct, Some(95.0));
1668 assert_eq!(h.context_warning_pct, Some(85.0));
1669 }
1670
1671 #[test]
1672 fn test_health_thresholds_layering() {
1673 let mut config = Config::default();
1674 assert_eq!(config.health.cache_critical_pct, 10.0); config.apply(RawConfig {
1677 health: Some(RawHealthThresholds {
1678 cache_critical_pct: Some(5.0),
1679 ..RawHealthThresholds::default()
1680 }),
1681 ..RawConfig::default()
1682 });
1683 assert_eq!(config.health.cache_critical_pct, 5.0); assert_eq!(config.health.cache_warning_pct, 30.0); }
1686
1687 #[test]
1688 fn test_parse_orchestrate_config() {
1689 use std::io::Write;
1690 let mut file = tempfile::NamedTempFile::new().unwrap();
1691 writeln!(
1692 file,
1693 r#"
1694[orchestrate]
1695file_conflicts = true
1696auto_deny_file_conflicts = true
1697"#
1698 )
1699 .unwrap();
1700 file.flush().unwrap();
1701
1702 let raw = parse_config_file(&file.path().to_path_buf()).unwrap();
1703 assert_eq!(raw.file_conflicts, Some(true));
1704 assert_eq!(raw.auto_deny_file_conflicts, Some(true));
1705 }
1706
1707 #[test]
1708 fn test_orchestrate_defaults() {
1709 let config = Config::default();
1710 assert!(config.file_conflicts); assert!(!config.auto_deny_file_conflicts); }
1713}