Skip to main content

ninox_core/
config.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::{fs, path::PathBuf};
4
5// ---------------------------------------------------------------------------
6// Theme
7// ---------------------------------------------------------------------------
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum ThemeVariant {
12    Light,
13    #[default]
14    Dark,
15    Ninox,
16}
17
18// ---------------------------------------------------------------------------
19// Agent configuration
20// ---------------------------------------------------------------------------
21
22/// Which agent harness and model to use for a session type.
23///
24/// Example `~/.config/ninox/config.toml`:
25/// ```toml
26/// [orchestrator]
27/// harness = "claude-code"
28/// model = "claude-opus-4-5"
29///
30/// [worker]
31/// harness = "codex"
32/// model = "gpt-4o"
33/// ```
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct AgentConfig {
36    /// Agent harness: `"claude-code"`, `"codex"`, `"aider"`, or `"opencode"`.
37    #[serde(default = "default_harness")]
38    pub harness: String,
39    /// Model identifier passed to the harness CLI.
40    /// Omit to use the harness default.
41    pub model: Option<String>,
42}
43
44fn default_harness() -> String {
45    "claude-code".to_string()
46}
47
48impl Default for AgentConfig {
49    fn default() -> Self {
50        Self { harness: default_harness(), model: None }
51    }
52}
53
54impl AgentConfig {
55    /// Interactive launch command for an orchestrator session.
56    pub fn interactive_cmd(&self) -> String {
57        let binary = harness_binary(&self.harness);
58        match &self.model {
59            Some(m) => format!("{binary} --model {m}"),
60            None    => binary.to_string(),
61        }
62    }
63
64    /// Launch command for a worker session.
65    pub fn worker_cmd(&self, prompt: &str) -> String {
66        let binary = harness_binary(&self.harness);
67        let quoted = shell_quote(prompt);
68        match self.harness.as_str() {
69            "claude-code" => {
70                // Interactive mode with positional prompt: the full agent TUI is
71                // visible in the terminal and the agent runs autonomously.
72                // --dangerously-skip-permissions allows tool calls without approval.
73                let model_part = self.model.as_deref()
74                    .map(|m| format!(" --model {}", shell_quote(m)))
75                    .unwrap_or_default();
76                format!("{binary} --dangerously-skip-permissions{model_part} -- {quoted}")
77            }
78            "aider" => {
79                let model_part = self.model.as_deref()
80                    .map(|m| format!(" --model {}", shell_quote(m)))
81                    .unwrap_or_default();
82                format!("{binary}{model_part} --message {quoted}")
83            }
84            _ => {
85                let model_part = self.model.as_deref()
86                    .map(|m| format!(" --model {}", shell_quote(m)))
87                    .unwrap_or_default();
88                format!("{binary}{model_part} -p {quoted}")
89            }
90        }
91    }
92}
93
94fn harness_binary(harness: &str) -> &str {
95    match harness {
96        "claude-code" => "claude",
97        "codex"       => "codex",
98        "aider"       => "aider",
99        "opencode"    => "opencode",
100        other         => other,
101    }
102}
103
104
105fn shell_quote(s: &str) -> String {
106    format!("'{}'", s.replace('\'', "'\\''"))
107}
108
109// ---------------------------------------------------------------------------
110// Brain configuration
111// ---------------------------------------------------------------------------
112
113#[derive(Debug, Clone, Default, Serialize, Deserialize)]
114pub struct BrainConfig {
115    pub path: Option<PathBuf>,
116}
117
118// ---------------------------------------------------------------------------
119// App configuration
120// ---------------------------------------------------------------------------
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct AppConfig {
124    pub port:      u16,
125    pub font_size: f32,
126    #[serde(default)]
127    pub theme:     ThemeVariant,
128    /// Override for the orchestrator root directory.
129    /// Defaults to `~/.config/ninox/orchestrator`.
130    #[serde(default)]
131    pub orchestrator_root: Option<PathBuf>,
132    /// Agent harness and model for orchestrator sessions.
133    #[serde(default)]
134    pub orchestrator: AgentConfig,
135    /// Agent harness and model for worker sessions spawned by `ninox spawn`.
136    #[serde(default)]
137    pub worker: AgentConfig,
138    /// GitHub personal access token. If absent, falls back to GITHUB_TOKEN env var.
139    /// Requires `repo` scope for private repos, `public_repo` for public.
140    #[serde(default)]
141    pub github_token: Option<String>,
142    /// Knowledge base (brain) configuration.
143    #[serde(default)]
144    pub brain: BrainConfig,
145}
146
147impl Default for AppConfig {
148    fn default() -> Self {
149        Self {
150            port:             8080,
151            font_size:        13.0,
152            theme:            ThemeVariant::Dark,
153            orchestrator_root: None,
154            orchestrator:     AgentConfig::default(),
155            worker:           AgentConfig::default(),
156            github_token:     None,
157            brain:            BrainConfig::default(),
158        }
159    }
160}
161
162impl AppConfig {
163    pub fn resolved_brain_path(&self) -> PathBuf {
164        if let Some(ref p) = self.brain.path {
165            return p.clone();
166        }
167        dirs::config_dir()
168            .unwrap_or_else(|| PathBuf::from("."))
169            .join("ninox")
170            .join("brain")
171    }
172
173    pub fn resolved_orchestrator_root(&self) -> PathBuf {
174        self.orchestrator_root.clone().unwrap_or_else(|| {
175            dirs::config_dir()
176                .unwrap_or_else(|| PathBuf::from("."))
177                .join("ninox")
178                .join("orchestrator")
179        })
180    }
181
182    pub fn config_path() -> PathBuf {
183        dirs::config_dir()
184            .unwrap_or_else(|| PathBuf::from("."))
185            .join("ninox")
186            .join("config.toml")
187    }
188
189    /// Directory for Ninox-managed shell wrappers prepended to agent PATH.
190    /// Default: `~/.config/ninox/bin/`
191    pub fn ninox_bin_dir() -> PathBuf {
192        dirs::config_dir()
193            .unwrap_or_else(|| PathBuf::from("."))
194            .join("ninox")
195            .join("bin")
196    }
197
198    /// Directory where per-session metadata JSON files are written by wrapper hooks.
199    /// Default: `~/.config/ninox/sessions/`
200    pub fn sessions_dir() -> PathBuf {
201        dirs::config_dir()
202            .unwrap_or_else(|| PathBuf::from("."))
203            .join("ninox")
204            .join("sessions")
205    }
206
207    fn path() -> PathBuf { Self::config_path() }
208
209    pub fn load() -> Result<Self> {
210        let p = Self::path();
211        if !p.exists() { return Ok(Self::default()); }
212        Ok(toml::from_str(&fs::read_to_string(p)?)?)
213    }
214
215    pub fn save(&self) -> Result<()> {
216        let p = Self::path();
217        fs::create_dir_all(p.parent().unwrap())?;
218        fs::write(p, toml::to_string(self)?)?;
219        Ok(())
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use tempfile::tempdir;
227
228    #[test]
229    fn round_trip() {
230        let dir = tempdir().unwrap();
231        let path = dir.path().join("config.toml");
232        let cfg = AppConfig { port: 9090, font_size: 14.0, theme: ThemeVariant::Light, ..AppConfig::default() };
233        fs::write(&path, toml::to_string(&cfg).unwrap()).unwrap();
234        let loaded: AppConfig = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
235        assert_eq!(loaded.port, 9090);
236        assert_eq!(loaded.theme, ThemeVariant::Light);
237        assert!(loaded.orchestrator_root.is_none());
238    }
239
240    #[test]
241    fn default_theme_is_dark() {
242        assert_eq!(AppConfig::default().theme, ThemeVariant::Dark);
243    }
244
245    #[test]
246    fn missing_theme_field_defaults_to_dark() {
247        let cfg: AppConfig = toml::from_str("port = 8080\nfont_size = 13.0\n").unwrap();
248        assert_eq!(cfg.theme, ThemeVariant::Dark);
249    }
250
251    #[test]
252    fn agent_config_round_trip() {
253        let toml = "port = 8080\nfont_size = 13.0\n\n[orchestrator]\nharness = \"claude-code\"\nmodel = \"claude-opus-4-5\"\n\n[worker]\nharness = \"codex\"\n";
254        let cfg: AppConfig = toml::from_str(toml).unwrap();
255        assert_eq!(cfg.orchestrator.harness, "claude-code");
256        assert_eq!(cfg.orchestrator.model.as_deref(), Some("claude-opus-4-5"));
257        assert_eq!(cfg.worker.harness, "codex");
258        assert!(cfg.worker.model.is_none());
259    }
260
261    #[test]
262    fn interactive_cmd_with_model() {
263        let cfg = AgentConfig { harness: "claude-code".into(), model: Some("claude-opus-4-5".into()) };
264        assert_eq!(cfg.interactive_cmd(), "claude --model claude-opus-4-5");
265    }
266
267    #[test]
268    fn worker_cmd_codex() {
269        let cfg = AgentConfig { harness: "codex".into(), model: Some("gpt-4o".into()) };
270        assert_eq!(cfg.worker_cmd("do the thing"), "codex --model 'gpt-4o' -p 'do the thing'");
271    }
272
273    #[test]
274    fn worker_cmd_claude_code() {
275        let cfg = AgentConfig { harness: "claude-code".into(), model: None };
276        assert_eq!(cfg.worker_cmd("Fix the bug"), "claude --dangerously-skip-permissions -- 'Fix the bug'");
277    }
278
279    #[test]
280    fn worker_cmd_claude_code_with_model() {
281        let cfg = AgentConfig { harness: "claude-code".into(), model: Some("claude-opus-4-5".into()) };
282        assert_eq!(cfg.worker_cmd("do task"), "claude --dangerously-skip-permissions --model 'claude-opus-4-5' -- 'do task'");
283    }
284
285    #[test]
286    fn resolved_orchestrator_root_default() {
287        let cfg = AppConfig::default();
288        assert!(cfg.resolved_orchestrator_root().ends_with("ninox/orchestrator"));
289    }
290}