Skip to main content

forge_workspace/
launch_settings.rs

1//! The settings a session is spawned with.
2//!
3//! Read from the three Claude Code documents rather than kept as state, so a
4//! session started from a client spawns with what the CLI itself would have
5//! launched. A spawn that skipped this carries no language and no model, and
6//! the CLI's own defaults for permissions, effort and output style.
7
8use forge_agent::client::SessionLaunchSettings;
9use forge_primitives::runtime::EffortLevel;
10use serde_json::{Map, Value, json};
11
12/// The three documents a launch reads, each as the CLI sees it.
13pub struct LaunchSettingsDocuments<'a> {
14    /// `<config_dir>/settings.json`.
15    pub user: &'a Value,
16    /// `<cwd>/.claude/settings.local.json`.
17    pub local: &'a Value,
18    /// `$HOME/.claude.json`.
19    pub preferences: &'a Value,
20}
21
22/// The mode stored at `settings.permissions.defaultMode`.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum DefaultPermissionMode {
25    #[default]
26    Default,
27    Auto,
28    AcceptEdits,
29    Plan,
30    DontAsk,
31    BypassPermissions,
32}
33
34impl DefaultPermissionMode {
35    pub const fn as_stored(self) -> &'static str {
36        match self {
37            Self::Default => "default",
38            Self::Auto => "auto",
39            Self::AcceptEdits => "acceptEdits",
40            Self::Plan => "plan",
41            Self::DontAsk => "dontAsk",
42            Self::BypassPermissions => "bypassPermissions",
43        }
44    }
45
46    pub fn from_stored(value: &str) -> Option<Self> {
47        match value {
48            "default" => Some(Self::Default),
49            "auto" => Some(Self::Auto),
50            "acceptEdits" => Some(Self::AcceptEdits),
51            "plan" => Some(Self::Plan),
52            "dontAsk" => Some(Self::DontAsk),
53            "bypassPermissions" => Some(Self::BypassPermissions),
54            _ => None,
55        }
56    }
57}
58
59/// The style stored at `settings.outputStyle`.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum OutputStyle {
62    #[default]
63    Default,
64    Explanatory,
65    Learning,
66}
67
68impl OutputStyle {
69    pub const fn as_stored(self) -> &'static str {
70        match self {
71            Self::Default => "Default",
72            Self::Explanatory => "Explanatory",
73            Self::Learning => "Learning",
74        }
75    }
76
77    pub fn from_stored(value: &str) -> Option<Self> {
78        match value {
79            "Default" => Some(Self::Default),
80            "Explanatory" => Some(Self::Explanatory),
81            "Learning" => Some(Self::Learning),
82            _ => None,
83        }
84    }
85}
86
87const LANGUAGE_MIN_CHARS: usize = 2;
88const LANGUAGE_MAX_CHARS: usize = 30;
89
90/// Validate a free-text language string before it reaches the launch
91/// settings payload. Returns a message when the value is out of range,
92/// otherwise `None` for "looks fine".
93pub fn language_input_validation_message(value: &str) -> Option<&'static str> {
94    let trimmed = value.trim();
95    if trimmed.is_empty() {
96        return None;
97    }
98    let length = trimmed.chars().count();
99    if length < LANGUAGE_MIN_CHARS {
100        Some("Language must be at least 2 characters.")
101    } else if length > LANGUAGE_MAX_CHARS {
102        Some("Language must be at most 30 characters.")
103    } else {
104        None
105    }
106}
107
108/// The language a session is launched in, when the stored value is usable.
109pub fn language(document: &Value) -> Option<String> {
110    read_string(document, &["language"])
111        .ok()
112        .flatten()
113        .map(|value| value.trim().to_owned())
114        .filter(|value| !value.is_empty())
115        .filter(|value| language_input_validation_message(value).is_none())
116}
117
118pub fn always_thinking_enabled(document: &Value) -> bool {
119    read_bool(document, &["alwaysThinkingEnabled"]).ok().flatten().unwrap_or(false)
120}
121
122/// The model a session launches on. Forge defaults to `opus` when none is
123/// stored; the CLI's own default is `sonnet`, so without this every fresh
124/// forge session would launch on sonnet.
125pub fn model(document: &Value) -> Option<String> {
126    read_string(document, &["model"]).ok().flatten().or_else(|| Some("opus".to_owned()))
127}
128
129/// Forge defaults to `max` effort when none is stored.
130pub fn thinking_effort(document: &Value) -> EffortLevel {
131    read_string(document, &["effortLevel"])
132        .ok()
133        .flatten()
134        .and_then(|value| EffortLevel::from_stored(&value))
135        .unwrap_or(EffortLevel::Max)
136}
137
138/// Forge defaults to `Auto`, which the CLI itself calls `default`.
139pub fn default_permission_mode(document: &Value) -> DefaultPermissionMode {
140    read_string(document, &["permissions", "defaultMode"])
141        .ok()
142        .flatten()
143        .and_then(|value| DefaultPermissionMode::from_stored(&value))
144        .unwrap_or(DefaultPermissionMode::Auto)
145}
146
147pub fn output_style(document: &Value) -> OutputStyle {
148    read_string(document, &["outputStyle"])
149        .ok()
150        .flatten()
151        .and_then(|value| OutputStyle::from_stored(&value))
152        .unwrap_or_default()
153}
154
155pub fn spinner_tips_enabled(document: &Value) -> bool {
156    read_bool(document, &["spinnerTipsEnabled"]).ok().flatten().unwrap_or(true)
157}
158
159pub fn terminal_progress_bar_enabled(document: &Value) -> bool {
160    read_bool(document, &["terminalProgressBarEnabled"]).ok().flatten().unwrap_or(true)
161}
162
163/// The settings a launch carries: the language, the settings object the CLI
164/// merges at boot, and agent progress summaries.
165pub fn session_launch_settings(documents: &LaunchSettingsDocuments<'_>) -> SessionLaunchSettings {
166    SessionLaunchSettings {
167        language: language(documents.user),
168        settings: Some(build_session_settings_object(documents)),
169        agent_progress_summaries: Some(true),
170        charter: None,
171        ..SessionLaunchSettings::default()
172    }
173}
174
175fn build_session_settings_object(documents: &LaunchSettingsDocuments<'_>) -> Value {
176    let mut settings = Map::new();
177
178    settings.insert(
179        "alwaysThinkingEnabled".to_owned(),
180        Value::Bool(always_thinking_enabled(documents.user)),
181    );
182
183    if let Some(model) = model(documents.user) {
184        settings.insert("model".to_owned(), Value::String(model));
185    }
186
187    settings.insert(
188        "permissions".to_owned(),
189        json!({ "defaultMode": default_permission_mode(documents.user).as_stored() }),
190    );
191    settings.insert(
192        "effortLevel".to_owned(),
193        Value::String(thinking_effort(documents.user).as_stored().to_owned()),
194    );
195    settings.insert(
196        "outputStyle".to_owned(),
197        Value::String(output_style(documents.local).as_stored().to_owned()),
198    );
199    settings.insert(
200        "spinnerTipsEnabled".to_owned(),
201        Value::Bool(spinner_tips_enabled(documents.local)),
202    );
203    settings.insert(
204        "terminalProgressBarEnabled".to_owned(),
205        Value::Bool(terminal_progress_bar_enabled(documents.preferences)),
206    );
207    if let Some(mut sandbox) = documents.user.get("sandbox").and_then(Value::as_object).cloned() {
208        if sandbox.get("enabled").and_then(Value::as_bool) == Some(true)
209            && !sandbox.contains_key("failIfUnavailable")
210        {
211            sandbox.insert("failIfUnavailable".to_owned(), Value::Bool(false));
212        }
213        settings.insert("sandbox".to_owned(), Value::Object(sandbox));
214    }
215
216    Value::Object(settings)
217}
218
219fn read_json_path<'a>(document: &'a Value, path: &[&str]) -> Option<&'a Value> {
220    let mut current = document;
221    for key in path {
222        current = current.get(key)?;
223    }
224    Some(current)
225}
226
227fn read_bool(document: &Value, path: &[&str]) -> Result<Option<bool>, ()> {
228    match read_json_path(document, path) {
229        None => Ok(None),
230        Some(Value::Bool(value)) => Ok(Some(*value)),
231        Some(_) => Err(()),
232    }
233}
234
235fn read_string(document: &Value, path: &[&str]) -> Result<Option<String>, ()> {
236    match read_json_path(document, path) {
237        None => Ok(None),
238        Some(Value::String(value)) => Ok(Some(value.clone())),
239        Some(_) => Err(()),
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    fn documents<'a>(
248        user: &'a Value,
249        local: &'a Value,
250        preferences: &'a Value,
251    ) -> LaunchSettingsDocuments<'a> {
252        LaunchSettingsDocuments { user, local, preferences }
253    }
254
255    fn empty() -> Value {
256        Value::Object(Map::new())
257    }
258
259    fn settings_of(launch: &SessionLaunchSettings) -> &Value {
260        launch.settings.as_ref().expect("a launch always carries the settings object")
261    }
262
263    #[test]
264    fn the_settings_carry_the_stored_model_permissions_and_effort() {
265        let user = json!({
266            "alwaysThinkingEnabled": true,
267            "model": "haiku",
268            "permissions": { "defaultMode": "plan" },
269            "effortLevel": "high",
270            "language": "German",
271        });
272        // Each key is pinned in the document it is read from, so a reader
273        // that reaches for the wrong one answers differently rather than
274        // matching by luck.
275        let local = json!({ "outputStyle": "Learning", "spinnerTipsEnabled": false });
276        // False, which is not the default: a reader that reached for the
277        // wrong document would otherwise answer `true` and match by luck.
278        let preferences = json!({ "terminalProgressBarEnabled": false });
279
280        let launch = session_launch_settings(&documents(&user, &local, &preferences));
281
282        assert_eq!(launch.language.as_deref(), Some("German"));
283        assert_eq!(launch.agent_progress_summaries, Some(true));
284        let settings = settings_of(&launch);
285        assert_eq!(settings["alwaysThinkingEnabled"], json!(true));
286        assert_eq!(settings["model"], json!("haiku"));
287        assert_eq!(settings["permissions"]["defaultMode"], json!("plan"));
288        assert_eq!(settings["effortLevel"], json!("high"));
289        assert_eq!(settings["outputStyle"], json!("Learning"));
290        assert_eq!(settings["spinnerTipsEnabled"], json!(false));
291        assert_eq!(settings["terminalProgressBarEnabled"], json!(false));
292    }
293
294    /// Forge's own defaults, which are not the CLI's: a document that says
295    /// nothing still launches on opus, `auto` and max effort.
296    #[test]
297    fn nothing_stored_still_carries_forges_defaults() {
298        let (user, local, preferences) = (empty(), empty(), empty());
299
300        let launch = session_launch_settings(&documents(&user, &local, &preferences));
301
302        assert_eq!(launch.language, None);
303        let settings = settings_of(&launch);
304        assert_eq!(settings["model"], json!("opus"));
305        assert_eq!(settings["permissions"]["defaultMode"], json!("auto"));
306        assert_eq!(settings["effortLevel"], json!("max"));
307    }
308
309    /// A stored value the launch cannot use reads as the default rather
310    /// than travelling to the CLI: a launch never fails on a bad document.
311    #[test]
312    fn an_unreadable_value_reads_as_the_default() {
313        let user = json!({
314            "model": true,
315            "effortLevel": "brisk",
316            "permissions": { "defaultMode": "not-a-mode" },
317        });
318        let local = json!({ "outputStyle": "Verbose" });
319
320        let launch = session_launch_settings(&documents(&user, &local, &empty()));
321
322        let settings = settings_of(&launch);
323        assert_eq!(settings["model"], json!("opus"));
324        assert_eq!(settings["effortLevel"], json!("max"));
325        assert_eq!(settings["permissions"]["defaultMode"], json!("auto"));
326        assert_eq!(settings["outputStyle"], json!("Default"));
327    }
328
329    /// The CLI's own sandbox fallback is opt-out: a stored sandbox that is
330    /// enabled but says nothing about availability is made explicit, and one
331    /// that already answers the question is left alone.
332    #[test]
333    fn a_stored_sandbox_keeps_its_own_fallback_answer() {
334        let user = json!({ "sandbox": { "enabled": true, "allowUnsandboxedCommands": false } });
335        let launch = session_launch_settings(&documents(&user, &empty(), &empty()));
336        assert_eq!(
337            settings_of(&launch)["sandbox"],
338            json!({ "enabled": true, "allowUnsandboxedCommands": false, "failIfUnavailable": false }),
339            "an enabled sandbox with no answer gets the explicit opt-out, and its other keys ride along",
340        );
341
342        let user = json!({ "sandbox": { "enabled": true, "failIfUnavailable": true } });
343        let launch = session_launch_settings(&documents(&user, &empty(), &empty()));
344        assert_eq!(
345            settings_of(&launch)["sandbox"]["failIfUnavailable"],
346            json!(true),
347            "an explicit answer is never overwritten",
348        );
349    }
350
351    /// A language the settings document carries is trimmed before it reaches
352    /// the payload, and one too short or too long is dropped rather than
353    /// launched with.
354    #[test]
355    fn an_unusable_language_never_reaches_the_payload() {
356        let user = json!({ "language": "  German  " });
357        let launch = session_launch_settings(&documents(&user, &empty(), &empty()));
358        assert_eq!(launch.language.as_deref(), Some("German"), "trimmed");
359
360        let user = json!({ "language": "E" });
361        let launch = session_launch_settings(&documents(&user, &empty(), &empty()));
362        assert_eq!(launch.language, None, "too short");
363
364        let user = json!({ "language": "   " });
365        let launch = session_launch_settings(&documents(&user, &empty(), &empty()));
366        assert_eq!(launch.language, None, "blank");
367
368        // One character longer than the ceiling, so the bound is the one
369        // being read rather than a shorter string failing some other way.
370        let too_long = "L".repeat(31);
371        let user = json!({ "language": too_long });
372        let launch = session_launch_settings(&documents(&user, &empty(), &empty()));
373        assert_eq!(launch.language, None, "longer than the ceiling");
374
375        let at_the_ceiling = "L".repeat(30);
376        let user = json!({ "language": at_the_ceiling });
377        let launch = session_launch_settings(&documents(&user, &empty(), &empty()));
378        assert_eq!(launch.language.as_deref(), Some(&*at_the_ceiling), "the ceiling itself passes");
379    }
380}