1use anyhow::{Context, Result};
2use clap::{Parser, Subcommand};
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6#[derive(Parser, Debug)]
8#[command(author, version, about = "Tmux Multi Agent Interface")]
9pub struct Config {
10 #[arg(short, long, global = true)]
12 pub debug: bool,
13
14 #[arg(short, long, global = true)]
16 pub config: Option<PathBuf>,
17
18 #[arg(short = 'i', long)]
20 pub poll_interval: Option<u64>,
21
22 #[arg(short = 'l', long)]
24 pub capture_lines: Option<u32>,
25
26 #[arg(long, action = clap::ArgAction::Set)]
28 pub attached_only: Option<bool>,
29
30 #[arg(long)]
32 pub audit: bool,
33
34 #[command(subcommand)]
36 pub command: Option<Command>,
37}
38
39#[derive(Subcommand, Debug, Clone)]
41pub enum Command {
42 Wrap {
44 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
46 args: Vec<String>,
47 },
48 Demo,
50 Audit {
52 #[command(subcommand)]
53 subcommand: AuditCommand,
54 },
55}
56
57#[derive(Subcommand, Debug, Clone)]
59pub enum AuditCommand {
60 Stats {
62 #[arg(long, default_value = "20")]
64 top: usize,
65 },
66 Misdetections {
68 #[arg(long, short = 'n', default_value = "50")]
70 limit: usize,
71 },
72 Disagreements {
74 #[arg(long, short = 'n', default_value = "50")]
76 limit: usize,
77 },
78}
79
80impl Config {
81 pub fn parse_args() -> Self {
83 Self::parse()
84 }
85
86 pub fn is_wrap_mode(&self) -> bool {
88 matches!(self.command, Some(Command::Wrap { .. }))
89 }
90
91 pub fn is_demo_mode(&self) -> bool {
93 matches!(self.command, Some(Command::Demo))
94 }
95
96 pub fn is_audit_mode(&self) -> bool {
98 matches!(self.command, Some(Command::Audit { .. }))
99 }
100
101 pub fn get_audit_command(&self) -> Option<&AuditCommand> {
103 match &self.command {
104 Some(Command::Audit { subcommand }) => Some(subcommand),
105 _ => None,
106 }
107 }
108
109 pub fn get_wrap_args(&self) -> Option<(String, Vec<String>)> {
111 match &self.command {
112 Some(Command::Wrap { args }) if !args.is_empty() => {
113 let command = args[0].clone();
114 let cmd_args = args[1..].to_vec();
115 Some((command, cmd_args))
116 }
117 _ => None,
118 }
119 }
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct Settings {
125 #[serde(default = "default_poll_interval")]
127 pub poll_interval_ms: u64,
128
129 #[serde(default = "default_passthrough_poll_interval")]
131 pub passthrough_poll_interval_ms: u64,
132
133 #[serde(default = "default_capture_lines")]
135 pub capture_lines: u32,
136
137 #[serde(default = "default_attached_only")]
139 pub attached_only: bool,
140
141 #[serde(default)]
143 pub agent_patterns: Vec<AgentPattern>,
144
145 #[serde(default)]
147 pub ui: UiSettings,
148
149 #[serde(default)]
151 pub web: WebSettings,
152
153 #[serde(default)]
155 pub exfil_detection: ExfilDetectionSettings,
156
157 #[serde(default)]
159 pub teams: TeamSettings,
160
161 #[serde(default)]
163 pub audit: AuditSettings,
164
165 #[serde(default)]
167 pub auto_approve: AutoApproveSettings,
168
169 #[serde(default)]
171 pub create_process: CreateProcessSettings,
172}
173
174fn default_poll_interval() -> u64 {
175 500
176}
177
178fn default_passthrough_poll_interval() -> u64 {
179 10
180}
181
182fn default_capture_lines() -> u32 {
183 100
184}
185
186fn default_attached_only() -> bool {
187 true
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct AgentPattern {
193 pub pattern: String,
195 pub agent_type: String,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct UiSettings {
202 #[serde(default = "default_show_preview")]
204 pub show_preview: bool,
205
206 #[serde(default = "default_preview_height")]
208 pub preview_height: u16,
209
210 #[serde(default = "default_color")]
212 pub color: bool,
213
214 #[serde(default = "default_show_activity_name")]
218 pub show_activity_name: bool,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct WebSettings {
224 #[serde(default = "default_web_enabled")]
226 pub enabled: bool,
227
228 #[serde(default = "default_web_port")]
230 pub port: u16,
231}
232
233fn default_web_enabled() -> bool {
234 true
235}
236
237fn default_web_port() -> u16 {
238 9876
239}
240
241impl Default for WebSettings {
242 fn default() -> Self {
243 Self {
244 enabled: default_web_enabled(),
245 port: default_web_port(),
246 }
247 }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct ExfilDetectionSettings {
253 #[serde(default = "default_exfil_enabled")]
255 pub enabled: bool,
256
257 #[serde(default)]
259 pub additional_commands: Vec<String>,
260}
261
262fn default_exfil_enabled() -> bool {
263 true
264}
265
266impl Default for ExfilDetectionSettings {
267 fn default() -> Self {
268 Self {
269 enabled: default_exfil_enabled(),
270 additional_commands: Vec::new(),
271 }
272 }
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct TeamSettings {
278 #[serde(default = "default_team_enabled")]
280 pub enabled: bool,
281
282 #[serde(default = "default_scan_interval")]
284 pub scan_interval: u32,
285}
286
287fn default_team_enabled() -> bool {
289 true
290}
291
292fn default_scan_interval() -> u32 {
294 5
295}
296
297impl Default for TeamSettings {
298 fn default() -> Self {
299 Self {
300 enabled: default_team_enabled(),
301 scan_interval: default_scan_interval(),
302 }
303 }
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct AuditSettings {
309 #[serde(default = "default_audit_enabled")]
311 pub enabled: bool,
312
313 #[serde(default = "default_audit_max_size")]
315 pub max_size_bytes: u64,
316
317 #[serde(default)]
319 pub log_source_disagreement: bool,
320}
321
322fn default_audit_enabled() -> bool {
324 false
325}
326
327fn default_audit_max_size() -> u64 {
329 10_485_760
330}
331
332impl Default for AuditSettings {
333 fn default() -> Self {
334 Self {
335 enabled: default_audit_enabled(),
336 max_size_bytes: default_audit_max_size(),
337 log_source_disagreement: false,
338 }
339 }
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct AutoApproveSettings {
345 #[serde(default)]
347 pub enabled: bool,
348
349 #[serde(default)]
352 pub mode: Option<crate::auto_approve::types::AutoApproveMode>,
353
354 #[serde(default)]
356 pub rules: RuleSettings,
357
358 #[serde(default = "default_aa_provider")]
360 pub provider: String,
361
362 #[serde(default = "default_aa_model")]
364 pub model: String,
365
366 #[serde(default = "default_aa_timeout")]
368 pub timeout_secs: u64,
369
370 #[serde(default = "default_aa_cooldown")]
372 pub cooldown_secs: u64,
373
374 #[serde(default = "default_aa_interval")]
376 pub check_interval_ms: u64,
377
378 #[serde(default)]
380 pub allowed_types: Vec<String>,
381
382 #[serde(default = "default_aa_max_concurrent")]
384 pub max_concurrent: usize,
385
386 #[serde(default)]
388 pub custom_command: Option<String>,
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct RuleSettings {
394 #[serde(default = "default_true")]
396 pub allow_read: bool,
397
398 #[serde(default = "default_true")]
400 pub allow_tests: bool,
401
402 #[serde(default = "default_true")]
404 pub allow_fetch: bool,
405
406 #[serde(default = "default_true")]
408 pub allow_git_readonly: bool,
409
410 #[serde(default = "default_true")]
412 pub allow_format_lint: bool,
413
414 #[serde(default)]
416 pub allow_patterns: Vec<String>,
417}
418
419fn default_true() -> bool {
421 true
422}
423
424impl Default for RuleSettings {
425 fn default() -> Self {
426 Self {
427 allow_read: true,
428 allow_tests: true,
429 allow_fetch: true,
430 allow_git_readonly: true,
431 allow_format_lint: true,
432 allow_patterns: Vec::new(),
433 }
434 }
435}
436
437fn default_aa_provider() -> String {
438 "claude_haiku".to_string()
439}
440
441fn default_aa_model() -> String {
442 "haiku".to_string()
443}
444
445fn default_aa_timeout() -> u64 {
446 30
447}
448
449fn default_aa_cooldown() -> u64 {
450 10
451}
452
453fn default_aa_interval() -> u64 {
454 1000
455}
456
457fn default_aa_max_concurrent() -> usize {
458 3
459}
460
461impl AutoApproveSettings {
462 pub fn effective_mode(&self) -> crate::auto_approve::types::AutoApproveMode {
468 use crate::auto_approve::types::AutoApproveMode;
469 match self.mode {
470 Some(m) => m,
471 None => {
472 if self.enabled {
473 AutoApproveMode::Ai
474 } else {
475 AutoApproveMode::Off
476 }
477 }
478 }
479 }
480}
481
482#[derive(Debug, Clone, Default, Serialize, Deserialize)]
484pub struct CreateProcessSettings {
485 #[serde(default)]
487 pub base_directories: Vec<String>,
488
489 #[serde(default)]
491 pub pinned: Vec<String>,
492}
493
494impl Default for AutoApproveSettings {
495 fn default() -> Self {
496 Self {
497 enabled: false,
498 mode: None,
499 rules: RuleSettings::default(),
500 provider: default_aa_provider(),
501 model: default_aa_model(),
502 timeout_secs: default_aa_timeout(),
503 cooldown_secs: default_aa_cooldown(),
504 check_interval_ms: default_aa_interval(),
505 allowed_types: Vec::new(),
506 max_concurrent: default_aa_max_concurrent(),
507 custom_command: None,
508 }
509 }
510}
511
512fn default_show_preview() -> bool {
513 true
514}
515
516fn default_preview_height() -> u16 {
517 40
518}
519
520fn default_color() -> bool {
521 true
522}
523
524fn default_show_activity_name() -> bool {
525 true
526}
527
528impl Default for UiSettings {
529 fn default() -> Self {
530 Self {
531 show_preview: default_show_preview(),
532 preview_height: default_preview_height(),
533 color: default_color(),
534 show_activity_name: default_show_activity_name(),
535 }
536 }
537}
538
539impl Default for Settings {
540 fn default() -> Self {
541 Self {
542 poll_interval_ms: default_poll_interval(),
543 passthrough_poll_interval_ms: default_passthrough_poll_interval(),
544 capture_lines: default_capture_lines(),
545 attached_only: default_attached_only(),
546 agent_patterns: Vec::new(),
547 ui: UiSettings::default(),
548 web: WebSettings::default(),
549 exfil_detection: ExfilDetectionSettings::default(),
550 teams: TeamSettings::default(),
551 audit: AuditSettings::default(),
552 auto_approve: AutoApproveSettings::default(),
553 create_process: CreateProcessSettings::default(),
554 }
555 }
556}
557
558impl Settings {
559 pub fn load(path: Option<&PathBuf>) -> Result<Self> {
561 if let Some(p) = path {
563 if p.exists() {
564 let content = std::fs::read_to_string(p)
565 .with_context(|| format!("Failed to read config file: {:?}", p))?;
566 return toml::from_str(&content)
567 .with_context(|| format!("Failed to parse config file: {:?}", p));
568 }
569 }
570
571 let default_paths = [
573 dirs::config_dir().map(|p| p.join("tmai/config.toml")),
574 dirs::home_dir().map(|p| p.join(".config/tmai/config.toml")),
575 dirs::home_dir().map(|p| p.join(".tmai.toml")),
576 ];
577
578 for path in default_paths.iter().flatten() {
579 if path.exists() {
580 let content = std::fs::read_to_string(path)
581 .with_context(|| format!("Failed to read config file: {:?}", path))?;
582 return toml::from_str(&content)
583 .with_context(|| format!("Failed to parse config file: {:?}", path));
584 }
585 }
586
587 Ok(Self::default())
589 }
590
591 pub fn merge_cli(&mut self, cli: &Config) {
593 if let Some(poll_interval) = cli.poll_interval {
594 self.poll_interval_ms = poll_interval;
595 }
596 if let Some(capture_lines) = cli.capture_lines {
597 self.capture_lines = capture_lines;
598 }
599 if let Some(attached_only) = cli.attached_only {
600 self.attached_only = attached_only;
601 }
602 if cli.audit {
603 self.audit.enabled = true;
604 }
605 }
606
607 pub fn validate(&mut self) {
611 const MIN_POLL_INTERVAL: u64 = 1;
612
613 if self.poll_interval_ms < MIN_POLL_INTERVAL {
614 self.poll_interval_ms = MIN_POLL_INTERVAL;
615 }
616 if self.passthrough_poll_interval_ms < MIN_POLL_INTERVAL {
617 self.passthrough_poll_interval_ms = MIN_POLL_INTERVAL;
618 }
619
620 if self.auto_approve.check_interval_ms < 100 {
622 self.auto_approve.check_interval_ms = 100;
623 }
624 if self.auto_approve.max_concurrent == 0 {
625 self.auto_approve.max_concurrent = 1;
626 }
627 if self.auto_approve.timeout_secs == 0 {
628 self.auto_approve.timeout_secs = 5;
629 }
630 }
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636
637 #[test]
638 fn test_default_settings() {
639 let settings = Settings::default();
640 assert_eq!(settings.poll_interval_ms, 500);
641 assert_eq!(settings.capture_lines, 100);
642 assert!(settings.attached_only);
643 assert!(settings.ui.show_preview);
644 }
645
646 #[test]
647 fn test_parse_toml() {
648 let toml = r#"
649 poll_interval_ms = 1000
650 capture_lines = 200
651
652 [ui]
653 show_preview = false
654 "#;
655
656 let settings: Settings = toml::from_str(toml).expect("Should parse TOML");
657 assert_eq!(settings.poll_interval_ms, 1000);
658 assert_eq!(settings.capture_lines, 200);
659 assert!(!settings.ui.show_preview);
660 }
661}