Skip to main content

rpi_cli/
settings.rs

1//! `~/.rpi/agent/settings.json` — saved user defaults. Mirrors the slice of
2//! pi's `Settings` interface (`packages/coding-agent/src/core/settings-manager.ts`)
3//! that rpi honors: `defaultProvider` / `defaultModel` / `defaultThinkingLevel`
4//! (consumed by `provider::resolve` as pi's `findInitialModel` step 3 — the
5//! saved default, when authed, wins over the built-in fallback), `theme`,
6//! packages, and configurable resource directories.
7//!
8//! pi's `Settings` carries ~40 fields; rpi reads the fields it uses and drops the
9//! rest (serde `default` ignores unknown fields), so a copied pi `settings.json`
10//! parses clean.
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15use crate::config::{self, strip_line_comments, ConfigError};
16
17/// The honored subset of pi's `Settings`. Unknown fields are ignored.
18#[derive(serde::Deserialize, Default, Clone, Debug)]
19#[serde(rename_all = "camelCase")]
20pub struct Settings {
21    /// Saved default provider id (v1 honors only `"anthropic"`; an absent or
22    /// anthropic value allows the saved default-model lookup).
23    #[serde(default)]
24    pub default_provider: Option<String>,
25    /// Saved default model id. When present and the model is authed,
26    /// `provider::resolve` selects it (pi `findInitialModel` step 3).
27    #[serde(default)]
28    pub default_model: Option<String>,
29    /// Saved default thinking level (a level-name string: off/minimal/low/medium/
30    /// high/xhigh/max). Parsed by the caller via `args::parse_thinking_level`.
31    #[serde(default)]
32    pub default_thinking_level: Option<String>,
33    /// Saved theme name. Surfaced for best-effort TUI theme application.
34    #[serde(default)]
35    pub theme: Option<String>,
36    /// `/scoped-models`: the model ids allowed in the Ctrl+M cycle. Absent /
37    /// empty ⇒ every catalog model cycles (the default).
38    #[serde(default)]
39    pub scoped_models: Option<Vec<String>>,
40    /// Pi-compatible static package specs. Entries may be local package
41    /// directories, `package.json` files, or installed package names.
42    #[serde(default)]
43    pub packages: Option<Vec<String>>,
44    /// Additional skill directories. Relative paths are resolved against the
45    /// settings file's owner (project root for project settings, agent dir for
46    /// global settings). `skills` is accepted as a compatibility shorthand.
47    #[serde(default, alias = "skills")]
48    pub skill_dirs: Option<Vec<String>>,
49    /// Additional prompt-template directories/files. `prompts` is accepted as
50    /// a compatibility shorthand.
51    #[serde(default, alias = "prompts")]
52    pub prompt_dirs: Option<Vec<String>>,
53    /// Additional Rust cdylib extension directories. `extensions` is accepted
54    /// as a compatibility shorthand.
55    #[serde(default, alias = "extensions")]
56    pub extension_dirs: Option<Vec<String>>,
57    /// Native Pi keybinding overrides. Values may be a key string, an array
58    /// of key strings, or an empty array to unbind an action.
59    #[serde(default)]
60    pub keybindings: Option<HashMap<String, serde_json::Value>>,
61    /// Action performed by two quick Escape presses while the editor is empty.
62    /// Native Pi defaults this to `tree`; `none` disables the gesture.
63    #[serde(default)]
64    pub double_escape_action: Option<String>,
65    /// Hide the body of thinking blocks while retaining a compact label.
66    #[serde(default)]
67    pub hide_thinking_block: Option<bool>,
68    /// Suppress interactive startup notices and update checks.
69    #[serde(default)]
70    pub quiet_startup: Option<bool>,
71    /// Show the global terminal progress indicator while a run is active.
72    #[serde(default)]
73    pub show_terminal_progress: Option<bool>,
74    /// Horizontal editor padding in terminal columns.
75    #[serde(default)]
76    pub editor_padding_x: Option<usize>,
77    /// Maximum number of autocomplete rows shown above the editor.
78    #[serde(default)]
79    pub autocomplete_max_visible: Option<usize>,
80}
81
82/// Load `~/.rpi/agent/settings.json`. Missing file ⇒ `Settings::default()`
83/// (not an error). Malformed JSON ⇒ `ConfigError::Json`. Tolerates `//` line
84/// comments (a copied pi settings.json may contain them).
85pub fn load_settings() -> Result<Settings, ConfigError> {
86    let path = config::settings_path()?;
87    match std::fs::read_to_string(&path) {
88        Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
89            path: path.clone(),
90            source: e,
91        }),
92        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
93            // A native Pi installation keeps its settings under ~/.pi/agent.
94            // Read that file only as a fallback; all writes still target the
95            // rpi-owned ~/.rpi/agent/settings.json path.
96            let legacy = if std::env::var_os(config::CONFIG_DIR_ENV).is_some() {
97                None
98            } else {
99                dirs::home_dir().map(|home| home.join(".pi/agent/settings.json"))
100            };
101            match legacy.filter(|candidate| candidate != &path) {
102                Some(legacy_path) => match std::fs::read_to_string(&legacy_path) {
103                    Ok(text) => parse_settings(&text).map_err(|e| ConfigError::Json {
104                        path: legacy_path,
105                        source: e,
106                    }),
107                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
108                        Ok(Settings::default())
109                    }
110                    Err(error) => Err(ConfigError::Read {
111                        path: legacy_path,
112                        source: error,
113                    }),
114                },
115                None => Ok(Settings::default()),
116            }
117        }
118        Err(e) => Err(ConfigError::Read { path, source: e }),
119    }
120}
121
122/// Load project-local settings in precedence order: `.rpi/settings.json`,
123/// then legacy `.pi/settings.json`. Both files may contribute additional
124/// resource paths; the preferred `.rpi` file is returned first so its paths
125/// register before legacy Pi paths. Malformed or unreadable optional project
126/// settings are ignored, matching the best-effort behavior of resource-dir
127/// discovery; global settings remain available through [`load_settings`].
128pub fn load_project_settings(cwd: &Path) -> Vec<Settings> {
129    load_project_settings_with_paths(cwd)
130        .into_iter()
131        .map(|(_, settings)| settings)
132        .collect()
133}
134
135/// Load project settings together with their source paths. The path is kept
136/// so callers can preserve `.rpi` before `.pi` ordering even when only one of
137/// the two files exists.
138pub fn load_project_settings_with_paths(cwd: &Path) -> Vec<(PathBuf, Settings)> {
139    [
140        cwd.join(".rpi").join("settings.json"),
141        cwd.join(".pi").join("settings.json"),
142    ]
143    .into_iter()
144    .filter_map(|path| {
145        load_settings_file(&path)
146            .ok()
147            .map(|settings| (path, settings))
148    })
149    .collect()
150}
151
152fn load_settings_file(path: &Path) -> Result<Settings, ConfigError> {
153    let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
154        path: path.to_path_buf(),
155        source,
156    })?;
157    parse_settings(&text).map_err(|source| ConfigError::Json {
158        path: path.to_path_buf(),
159        source,
160    })
161}
162
163/// Resolve a configured path relative to its settings owner. Absolute paths
164/// are preserved; empty entries are ignored.
165pub fn resolve_configured_paths(base: &Path, values: &[String]) -> Vec<PathBuf> {
166    values
167        .iter()
168        .map(|value| value.trim())
169        .filter(|value| !value.is_empty())
170        .map(|value| {
171            let path = PathBuf::from(value);
172            if path.is_absolute() {
173                path
174            } else {
175                base.join(path)
176            }
177        })
178        .collect()
179}
180
181fn parse_settings(text: &str) -> Result<Settings, serde_json::Error> {
182    match serde_json::from_str(text) {
183        Ok(s) => Ok(s),
184        Err(first) => {
185            let stripped = strip_line_comments(text);
186            serde_json::from_str(&stripped).map_err(|_| first)
187        }
188    }
189}
190
191/// Persist the honored settings back to `~/.rpi/agent/settings.json`.
192/// Unknown pi fields (which `Settings` doesn't model) are **preserved**: the
193/// current file is read as raw JSON, the known fields are overlaid, and the
194/// merged object is written — so a copied pi `settings.json` survives edits
195/// without losing pi-only keys. Missing file ⇒ a fresh object. Best-effort
196/// errors are returned as strings for the caller to surface.
197pub fn save_settings(settings: &Settings) -> Result<(), String> {
198    let path = config::settings_path().map_err(|e| e.to_string())?;
199    // Read the existing file as a raw object to preserve unknown fields.
200    let mut merged = match std::fs::read_to_string(&path) {
201        Ok(text) => serde_json::from_str::<serde_json::Value>(&text)
202            .unwrap_or(serde_json::Value::Object(Default::default())),
203        Err(_) => serde_json::Value::Object(Default::default()),
204    };
205    let obj = merged
206        .as_object_mut()
207        .ok_or("settings file is not an object")?;
208    for (key, val) in [
209        ("defaultProvider", settings.default_provider.as_ref()),
210        ("defaultModel", settings.default_model.as_ref()),
211        (
212            "defaultThinkingLevel",
213            settings.default_thinking_level.as_ref(),
214        ),
215        ("theme", settings.theme.as_ref()),
216    ] {
217        match val {
218            Some(v) => {
219                obj.insert(key.to_string(), serde_json::Value::String(v.clone()));
220            }
221            None => {
222                obj.remove(key);
223            }
224        }
225    }
226    match &settings.scoped_models {
227        Some(list) if !list.is_empty() => {
228            obj.insert(
229                "scopedModels".to_string(),
230                serde_json::Value::Array(
231                    list.iter()
232                        .map(|m| serde_json::Value::String(m.clone()))
233                        .collect(),
234                ),
235            );
236        }
237        _ => {
238            obj.remove("scopedModels");
239        }
240    }
241    match &settings.packages {
242        Some(list) if !list.is_empty() => {
243            obj.insert(
244                "packages".to_string(),
245                serde_json::Value::Array(
246                    list.iter()
247                        .map(|p| serde_json::Value::String(p.clone()))
248                        .collect(),
249                ),
250            );
251        }
252        _ => {
253            obj.remove("packages");
254        }
255    }
256    for (key, values) in [
257        ("skillDirs", settings.skill_dirs.as_ref()),
258        ("promptDirs", settings.prompt_dirs.as_ref()),
259        ("extensionDirs", settings.extension_dirs.as_ref()),
260    ] {
261        match values {
262            Some(list) if !list.is_empty() => {
263                obj.insert(
264                    key.to_string(),
265                    serde_json::Value::Array(
266                        list.iter()
267                            .map(|path| serde_json::Value::String(path.clone()))
268                            .collect(),
269                    ),
270                );
271            }
272            _ => {
273                obj.remove(key);
274            }
275        }
276    }
277    match &settings.keybindings {
278        Some(bindings) => {
279            obj.insert(
280                "keybindings".to_string(),
281                serde_json::to_value(bindings).map_err(|e| e.to_string())?,
282            );
283        }
284        None => {
285            obj.remove("keybindings");
286        }
287    }
288    match settings.double_escape_action.as_deref() {
289        Some(action) if !action.trim().is_empty() => {
290            obj.insert(
291                "doubleEscapeAction".to_string(),
292                serde_json::Value::String(action.to_string()),
293            );
294        }
295        _ => {
296            obj.remove("doubleEscapeAction");
297        }
298    }
299    for (key, value) in [
300        (
301            "hideThinkingBlock",
302            settings.hide_thinking_block.map(serde_json::Value::Bool),
303        ),
304        (
305            "quietStartup",
306            settings.quiet_startup.map(serde_json::Value::Bool),
307        ),
308        (
309            "showTerminalProgress",
310            settings.show_terminal_progress.map(serde_json::Value::Bool),
311        ),
312        (
313            "editorPaddingX",
314            settings
315                .editor_padding_x
316                .map(|v| serde_json::Value::Number(v.into())),
317        ),
318        (
319            "autocompleteMaxVisible",
320            settings
321                .autocomplete_max_visible
322                .map(|v| serde_json::Value::Number(v.into())),
323        ),
324    ] {
325        match value {
326            Some(value) => {
327                obj.insert(key.to_string(), value);
328            }
329            None => {
330                obj.remove(key);
331            }
332        }
333    }
334    if let Some(parent) = path.parent() {
335        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
336    }
337    let text = serde_json::to_string_pretty(&merged).map_err(|e| e.to_string())?;
338    std::fs::write(&path, text).map_err(|e| e.to_string())
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::config::test_support::env_lock;
345
346    /// Point `RPI_CODING_AGENT_DIR` at a fresh temp dir for this test.
347    struct TempConfig {
348        _guard: std::sync::MutexGuard<'static, ()>,
349        _tmp: tempfile::TempDir,
350        prev: Option<std::ffi::OsString>,
351    }
352    impl TempConfig {
353        fn new() -> Self {
354            let guard = env_lock().lock().unwrap();
355            let prev = std::env::var_os(config::CONFIG_DIR_ENV);
356            let tmp = tempfile::TempDir::new().unwrap();
357            std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
358            Self {
359                _guard: guard,
360                _tmp: tmp,
361                prev,
362            }
363        }
364    }
365    impl Drop for TempConfig {
366        fn drop(&mut self) {
367            match self.prev.take() {
368                Some(v) => std::env::set_var(config::CONFIG_DIR_ENV, v),
369                None => std::env::remove_var(config::CONFIG_DIR_ENV),
370            }
371        }
372    }
373
374    #[test]
375    fn missing_settings_is_default() {
376        let _cfg = TempConfig::new();
377        let s = load_settings().unwrap();
378        assert!(s.default_provider.is_none());
379        assert!(s.default_model.is_none());
380        assert!(s.default_thinking_level.is_none());
381        assert!(s.theme.is_none());
382        assert!(s.skill_dirs.is_none());
383        assert!(s.prompt_dirs.is_none());
384        assert!(s.extension_dirs.is_none());
385    }
386
387    #[test]
388    fn reads_honored_fields_and_ignores_unknown() {
389        let _cfg = TempConfig::new();
390        let path = config::settings_path().unwrap();
391        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
392        // A pi-style settings.json with many unknown fields + the 4 we honor.
393        std::fs::write(
394            &path,
395            r#"{
396                "lastChangelogVersion": "1.0.0",
397                "defaultProvider": "anthropic",
398                "defaultModel": "claude-sonnet-5",
399                "defaultThinkingLevel": "high",
400                "theme": "dark",
401                "hideThinkingBlock": true,
402                "quietStartup": true,
403                "showTerminalProgress": false,
404                "editorPaddingX": 3,
405                "autocompleteMaxVisible": 7,
406                "compaction": { "threshold": 100 },
407                "packages": ["some-pkg"]
408            }"#,
409        )
410        .unwrap();
411        let s = load_settings().unwrap();
412        assert_eq!(s.default_provider.as_deref(), Some("anthropic"));
413        assert_eq!(s.default_model.as_deref(), Some("claude-sonnet-5"));
414        assert_eq!(s.default_thinking_level.as_deref(), Some("high"));
415        assert_eq!(s.theme.as_deref(), Some("dark"));
416        assert_eq!(s.packages, Some(vec!["some-pkg".to_string()]));
417        assert_eq!(s.hide_thinking_block, Some(true));
418        assert_eq!(s.quiet_startup, Some(true));
419        assert_eq!(s.show_terminal_progress, Some(false));
420        assert_eq!(s.editor_padding_x, Some(3));
421        assert_eq!(s.autocomplete_max_visible, Some(7));
422    }
423
424    #[test]
425    fn loads_project_settings_in_rpi_then_pi_order() {
426        let tmp = tempfile::tempdir().unwrap();
427        std::fs::create_dir_all(tmp.path().join(".rpi")).unwrap();
428        std::fs::create_dir_all(tmp.path().join(".pi")).unwrap();
429        std::fs::write(
430            tmp.path().join(".rpi/settings.json"),
431            r#"{"skillDirs":["rpi-skills"],"extensions":["rpi-ext"]}"#,
432        )
433        .unwrap();
434        std::fs::write(
435            tmp.path().join(".pi/settings.json"),
436            r#"{"skills":["pi-skills"],"extensionDirs":["pi-ext"]}"#,
437        )
438        .unwrap();
439
440        let settings = load_project_settings(tmp.path());
441        assert_eq!(settings.len(), 2);
442        assert_eq!(
443            settings[0].skill_dirs.as_deref(),
444            Some(["rpi-skills".to_string()].as_slice())
445        );
446        assert_eq!(
447            settings[0].extension_dirs.as_deref(),
448            Some(["rpi-ext".to_string()].as_slice())
449        );
450        assert_eq!(
451            settings[1].skill_dirs.as_deref(),
452            Some(["pi-skills".to_string()].as_slice())
453        );
454        assert_eq!(
455            settings[1].extension_dirs.as_deref(),
456            Some(["pi-ext".to_string()].as_slice())
457        );
458    }
459
460    #[test]
461    fn tolerates_line_comments() {
462        let _cfg = TempConfig::new();
463        let path = config::settings_path().unwrap();
464        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
465        std::fs::write(
466            &path,
467            "{\n  // my default\n  \"defaultModel\": \"glm-5\",\n  \"theme\": \"light\"\n}\n",
468        )
469        .unwrap();
470        let s = load_settings().unwrap();
471        assert_eq!(s.default_model.as_deref(), Some("glm-5"));
472        assert_eq!(s.theme.as_deref(), Some("light"));
473    }
474
475    #[test]
476    fn malformed_is_error() {
477        let _cfg = TempConfig::new();
478        let path = config::settings_path().unwrap();
479        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
480        std::fs::write(&path, "{ not json").unwrap();
481        assert!(matches!(load_settings(), Err(ConfigError::Json { .. })));
482    }
483}
484
485#[cfg(test)]
486mod scoped_tests {
487    use super::*;
488    use crate::config::test_support::env_lock;
489
490    fn with_temp_env() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
491        let guard = env_lock().lock().unwrap();
492        let tmp = tempfile::TempDir::new().unwrap();
493        std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
494        (tmp, guard)
495    }
496
497    #[test]
498    fn save_load_scoped_models_roundtrip() {
499        let (_tmp, _guard) = with_temp_env();
500        let mut s = Settings::default();
501        s.scoped_models = Some(vec!["a".into(), "b".into()]);
502        save_settings(&s).unwrap();
503        let loaded = load_settings().unwrap();
504        assert_eq!(
505            loaded.scoped_models,
506            Some(vec!["a".to_string(), "b".to_string()])
507        );
508        // Clearing removes the key.
509        let mut s2 = load_settings().unwrap();
510        s2.scoped_models = None;
511        save_settings(&s2).unwrap();
512        assert_eq!(load_settings().unwrap().scoped_models, None);
513    }
514
515    #[test]
516    fn save_preserves_unknown_fields() {
517        let (_tmp, _guard) = with_temp_env();
518        let path = config::settings_path().unwrap();
519        std::fs::write(&path, r#"{"piOnlyField":"keep-me","theme":"dark"}"#).unwrap();
520        let mut s = load_settings().unwrap();
521        s.scoped_models = Some(vec!["m1".into()]);
522        save_settings(&s).unwrap();
523        let raw: serde_json::Value =
524            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
525        assert_eq!(raw["piOnlyField"], "keep-me");
526        assert_eq!(raw["scopedModels"][0], "m1");
527        assert_eq!(raw["theme"], "dark");
528    }
529}