Skip to main content

tmai_core/config/
settings.rs

1use anyhow::{Context, Result};
2use clap::{Parser, Subcommand};
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6/// Command line arguments
7#[derive(Parser, Debug)]
8#[command(author, version, about = "Tmux Multi Agent Interface")]
9pub struct Config {
10    /// Enable debug mode
11    #[arg(short, long, global = true)]
12    pub debug: bool,
13
14    /// Path to config file
15    #[arg(short, long, global = true)]
16    pub config: Option<PathBuf>,
17
18    /// Polling interval in milliseconds
19    #[arg(short = 'i', long)]
20    pub poll_interval: Option<u64>,
21
22    /// Number of lines to capture from panes
23    #[arg(short = 'l', long)]
24    pub capture_lines: Option<u32>,
25
26    /// Only show panes from attached sessions
27    #[arg(long, action = clap::ArgAction::Set)]
28    pub attached_only: Option<bool>,
29
30    /// Enable detection audit log (/tmp/tmai/audit/detection.ndjson)
31    #[arg(long)]
32    pub audit: bool,
33
34    /// Subcommand
35    #[command(subcommand)]
36    pub command: Option<Command>,
37}
38
39/// Subcommands
40#[derive(Subcommand, Debug, Clone)]
41pub enum Command {
42    /// Wrap an AI agent command with PTY monitoring
43    Wrap {
44        /// The command to wrap (e.g., "claude", "codex")
45        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
46        args: Vec<String>,
47    },
48    /// Run interactive demo mode (no tmux required)
49    Demo,
50    /// Analyze audit detection logs
51    Audit {
52        #[command(subcommand)]
53        subcommand: AuditCommand,
54    },
55}
56
57/// Audit analysis subcommands
58#[derive(Subcommand, Debug, Clone)]
59pub enum AuditCommand {
60    /// Show aggregate statistics from detection logs
61    Stats {
62        /// Number of top rules to display
63        #[arg(long, default_value = "20")]
64        top: usize,
65    },
66    /// Analyze potential misdetections (UserInputDuringProcessing events)
67    Misdetections {
68        /// Maximum number of individual records to display
69        #[arg(long, short = 'n', default_value = "50")]
70        limit: usize,
71    },
72    /// Analyze IPC/capture-pane disagreements
73    Disagreements {
74        /// Maximum number of individual records to display
75        #[arg(long, short = 'n', default_value = "50")]
76        limit: usize,
77    },
78}
79
80impl Config {
81    /// Parse command line arguments
82    pub fn parse_args() -> Self {
83        Self::parse()
84    }
85
86    /// Check if running in wrap mode
87    pub fn is_wrap_mode(&self) -> bool {
88        matches!(self.command, Some(Command::Wrap { .. }))
89    }
90
91    /// Check if running in demo mode
92    pub fn is_demo_mode(&self) -> bool {
93        matches!(self.command, Some(Command::Demo))
94    }
95
96    /// Check if running in audit mode
97    pub fn is_audit_mode(&self) -> bool {
98        matches!(self.command, Some(Command::Audit { .. }))
99    }
100
101    /// Get audit subcommand
102    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    /// Get wrap command and arguments
110    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/// Application settings (from config file)
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct Settings {
125    /// Polling interval in milliseconds
126    #[serde(default = "default_poll_interval")]
127    pub poll_interval_ms: u64,
128
129    /// Polling interval in passthrough mode (milliseconds)
130    #[serde(default = "default_passthrough_poll_interval")]
131    pub passthrough_poll_interval_ms: u64,
132
133    /// Number of lines to capture from panes
134    #[serde(default = "default_capture_lines")]
135    pub capture_lines: u32,
136
137    /// Only show panes from attached sessions
138    #[serde(default = "default_attached_only")]
139    pub attached_only: bool,
140
141    /// Custom agent patterns
142    #[serde(default)]
143    pub agent_patterns: Vec<AgentPattern>,
144
145    /// UI settings
146    #[serde(default)]
147    pub ui: UiSettings,
148
149    /// Web server settings
150    #[serde(default)]
151    pub web: WebSettings,
152
153    /// External transmission detection settings
154    #[serde(default)]
155    pub exfil_detection: ExfilDetectionSettings,
156
157    /// Team detection settings
158    #[serde(default)]
159    pub teams: TeamSettings,
160
161    /// Audit log settings
162    #[serde(default)]
163    pub audit: AuditSettings,
164
165    /// Auto-approve settings
166    #[serde(default)]
167    pub auto_approve: AutoApproveSettings,
168
169    /// Create process popup settings
170    #[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/// Custom agent detection pattern
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct AgentPattern {
193    /// Pattern to match (regex)
194    pub pattern: String,
195    /// Agent type name
196    pub agent_type: String,
197}
198
199/// UI-related settings
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct UiSettings {
202    /// Show preview panel
203    #[serde(default = "default_show_preview")]
204    pub show_preview: bool,
205
206    /// Preview panel height (percentage)
207    #[serde(default = "default_preview_height")]
208    pub preview_height: u16,
209
210    /// Enable color output
211    #[serde(default = "default_color")]
212    pub color: bool,
213
214    /// Show activity name (tool name) during Processing instead of generic "Processing"
215    /// When true (default): shows "Bash", "Compacting", etc.
216    /// When false: always shows "Processing"
217    #[serde(default = "default_show_activity_name")]
218    pub show_activity_name: bool,
219}
220
221/// Web server settings
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct WebSettings {
224    /// Enable web server
225    #[serde(default = "default_web_enabled")]
226    pub enabled: bool,
227
228    /// Web server port
229    #[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/// External transmission detection settings
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct ExfilDetectionSettings {
253    /// Enable detection
254    #[serde(default = "default_exfil_enabled")]
255    pub enabled: bool,
256
257    /// Additional commands to detect (beyond built-in list)
258    #[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/// Team detection settings
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct TeamSettings {
278    /// Enable team detection
279    #[serde(default = "default_team_enabled")]
280    pub enabled: bool,
281
282    /// Scan interval in poll cycles (default: 5 = ~2.5s at 500ms poll)
283    #[serde(default = "default_scan_interval")]
284    pub scan_interval: u32,
285}
286
287/// Default for team enabled
288fn default_team_enabled() -> bool {
289    true
290}
291
292/// Default scan interval
293fn 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/// Audit log settings
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct AuditSettings {
309    /// Enable audit logging
310    #[serde(default = "default_audit_enabled")]
311    pub enabled: bool,
312
313    /// Maximum log file size in bytes before rotation
314    #[serde(default = "default_audit_max_size")]
315    pub max_size_bytes: u64,
316
317    /// Log source disagreement events
318    #[serde(default)]
319    pub log_source_disagreement: bool,
320}
321
322/// Default for audit enabled
323fn default_audit_enabled() -> bool {
324    false
325}
326
327/// Default audit max size (10MB)
328fn 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/// Auto-approve settings
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct AutoApproveSettings {
345    /// Enable auto-approve feature (legacy; prefer `mode`)
346    #[serde(default)]
347    pub enabled: bool,
348
349    /// Operating mode: "off", "rules", "ai", "hybrid"
350    /// When set, takes precedence over `enabled`.
351    #[serde(default)]
352    pub mode: Option<crate::auto_approve::types::AutoApproveMode>,
353
354    /// Rule-based auto-approve settings
355    #[serde(default)]
356    pub rules: RuleSettings,
357
358    /// Judgment provider (currently only "claude_haiku")
359    #[serde(default = "default_aa_provider")]
360    pub provider: String,
361
362    /// Model to use for judgment
363    #[serde(default = "default_aa_model")]
364    pub model: String,
365
366    /// Timeout for each judgment in seconds
367    #[serde(default = "default_aa_timeout")]
368    pub timeout_secs: u64,
369
370    /// Cooldown after judgment before re-evaluating the same target (seconds)
371    #[serde(default = "default_aa_cooldown")]
372    pub cooldown_secs: u64,
373
374    /// Interval between checking for new approval candidates (milliseconds)
375    #[serde(default = "default_aa_interval")]
376    pub check_interval_ms: u64,
377
378    /// Allowed approval types (empty = all types except UserQuestion)
379    #[serde(default)]
380    pub allowed_types: Vec<String>,
381
382    /// Maximum concurrent judgments
383    #[serde(default = "default_aa_max_concurrent")]
384    pub max_concurrent: usize,
385
386    /// Custom command to use instead of "claude" (for alternative providers)
387    #[serde(default)]
388    pub custom_command: Option<String>,
389}
390
391/// Rule-based auto-approve settings
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct RuleSettings {
394    /// Auto-approve Read operations (file reads, cat, head, ls, find, grep)
395    #[serde(default = "default_true")]
396    pub allow_read: bool,
397
398    /// Auto-approve test execution (cargo test, npm test, pytest, go test)
399    #[serde(default = "default_true")]
400    pub allow_tests: bool,
401
402    /// Auto-approve WebFetch/Search (curl GET without POST/data)
403    #[serde(default = "default_true")]
404    pub allow_fetch: bool,
405
406    /// Auto-approve read-only git commands (status, log, diff, branch, show, blame)
407    #[serde(default = "default_true")]
408    pub allow_git_readonly: bool,
409
410    /// Auto-approve format/lint commands (cargo fmt/clippy, prettier, eslint)
411    #[serde(default = "default_true")]
412    pub allow_format_lint: bool,
413
414    /// Additional allow patterns (regex, matched against screen context)
415    #[serde(default)]
416    pub allow_patterns: Vec<String>,
417}
418
419/// Helper for serde default = true
420fn 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    /// Resolve the effective operating mode.
463    ///
464    /// - If `mode` is explicitly set, use it directly.
465    /// - Otherwise fall back to `enabled` for backward compatibility:
466    ///   `enabled: true` → `Ai`, `enabled: false` → `Off`.
467    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/// Settings for the create process popup
483#[derive(Debug, Clone, Default, Serialize, Deserialize)]
484pub struct CreateProcessSettings {
485    /// Base directories - subdirectories are automatically listed
486    #[serde(default)]
487    pub base_directories: Vec<String>,
488
489    /// Pinned directories - always shown as-is
490    #[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    /// Load settings from config file or use defaults
560    pub fn load(path: Option<&PathBuf>) -> Result<Self> {
561        // Try custom path first
562        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        // Try default config locations
572        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        // Return defaults if no config file found
588        Ok(Self::default())
589    }
590
591    /// Merge CLI config into settings (CLI takes precedence)
592    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    /// Validate and normalize settings values
608    ///
609    /// Ensures poll intervals have a minimum value to prevent CPU exhaustion.
610    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        // Validate auto-approve settings to prevent dangerous edge cases
621        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}