Skip to main content

git_worktree_manager/
config.rs

1/// Configuration management for git-worktree-manager.
2///
3/// Supports multiple AI coding assistants with customizable commands.
4/// Configuration stored in ~/.config/git-worktree-manager/config.json.
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::constants::{
12    home_dir_or_fallback, launch_method_aliases, LaunchMethod, MAX_SESSION_NAME_LENGTH,
13};
14use crate::error::{CwError, Result};
15
16/// Typed configuration structure matching the JSON schema.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct Config {
19    pub ai_tool: AiToolConfig,
20    pub launch: LaunchConfig,
21    pub git: GitConfig,
22    pub update: UpdateConfig,
23    pub shell_completion: ShellCompletionConfig,
24    #[serde(default)]
25    pub hooks: HookConfig,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct AiToolConfig {
30    pub command: String,
31    pub args: Vec<String>,
32    /// Inject the `gw guard` PreToolUse(Bash) hook into Claude sessions
33    /// launched by gw. Default true. Has no effect when the configured
34    /// AI tool isn't Claude.
35    #[serde(default = "default_guard")]
36    pub guard: bool,
37}
38
39fn default_guard() -> bool {
40    true
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct LaunchConfig {
45    pub method: Option<String>,
46    pub tmux_session_prefix: String,
47    pub wezterm_ready_timeout: f64,
48}
49
50#[derive(Debug, Clone, Default, Serialize, Deserialize)]
51pub struct GitConfig {
52    // default_base_branch removed — auto-detected per repo via git::detect_default_branch()
53}
54
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct HookConfig {
57    #[serde(default)]
58    pub post_new: Option<String>,
59    #[serde(default)]
60    pub pre_rm: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct UpdateConfig {
65    pub auto_check: bool,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ShellCompletionConfig {
70    pub prompted: bool,
71    pub installed: bool,
72}
73
74impl Default for Config {
75    fn default() -> Self {
76        Self {
77            ai_tool: AiToolConfig {
78                command: "claude".to_string(),
79                args: Vec::new(),
80                guard: true,
81            },
82            launch: LaunchConfig {
83                method: None,
84                tmux_session_prefix: "gw".to_string(),
85                wezterm_ready_timeout: 5.0,
86            },
87            git: GitConfig {},
88            update: UpdateConfig { auto_check: true },
89            shell_completion: ShellCompletionConfig {
90                prompted: false,
91                installed: false,
92            },
93            hooks: HookConfig::default(),
94        }
95    }
96}
97
98/// AI tool presets: preset name -> command parts.
99pub fn ai_tool_presets() -> HashMap<&'static str, Vec<&'static str>> {
100    HashMap::from([
101        ("no-op", vec![]),
102        ("claude", vec!["claude"]),
103        (
104            "claude-yolo",
105            vec!["claude", "--dangerously-skip-permissions"],
106        ),
107        ("claude-remote", vec!["claude", "/remote-control"]),
108        (
109            "claude-yolo-remote",
110            vec![
111                "claude",
112                "--dangerously-skip-permissions",
113                "/remote-control",
114            ],
115        ),
116        ("codex", vec!["codex"]),
117        (
118            "codex-yolo",
119            vec!["codex", "--dangerously-bypass-approvals-and-sandbox"],
120        ),
121    ])
122}
123
124/// AI tool resume presets.
125pub fn ai_tool_resume_presets() -> HashMap<&'static str, Vec<&'static str>> {
126    HashMap::from([
127        ("claude", vec!["claude", "--continue"]),
128        (
129            "claude-yolo",
130            vec!["claude", "--dangerously-skip-permissions", "--continue"],
131        ),
132        (
133            "claude-remote",
134            vec!["claude", "--continue", "/remote-control"],
135        ),
136        (
137            "claude-yolo-remote",
138            vec![
139                "claude",
140                "--dangerously-skip-permissions",
141                "--continue",
142                "/remote-control",
143            ],
144        ),
145        ("codex", vec!["codex", "resume", "--last"]),
146        (
147            "codex-yolo",
148            vec![
149                "codex",
150                "resume",
151                "--dangerously-bypass-approvals-and-sandbox",
152                "--last",
153            ],
154        ),
155    ])
156}
157
158/// Set of Claude-based preset names.
159pub fn claude_preset_names() -> Vec<&'static str> {
160    ai_tool_presets()
161        .iter()
162        .filter(|(_, v)| v.first().map(|&s| s == "claude").unwrap_or(false))
163        .map(|(&k, _)| k)
164        .collect()
165}
166
167// ---------------------------------------------------------------------------
168// Config file I/O
169// ---------------------------------------------------------------------------
170
171/// Get the path to the configuration file.
172///
173/// Resolution order:
174///   1. `$GW_CONFIG_HOME/config.json` — test / CI override; never set in
175///      production.
176///   2. `$HOME/.config/git-worktree-manager/config.json` — standard path.
177///
178/// The `$GW_CONFIG_HOME` escape hatch exists because `dirs::home_dir()` on
179/// Windows uses a Windows API call that ignores the `HOME` / `USERPROFILE`
180/// env vars, so integration tests cannot redirect config I/O through a
181/// tempdir without an explicit override.
182pub fn get_config_path() -> PathBuf {
183    if let Ok(override_dir) = std::env::var("GW_CONFIG_HOME") {
184        if !override_dir.is_empty() {
185            return PathBuf::from(override_dir).join("config.json");
186        }
187    }
188    let home = home_dir_or_fallback();
189    home.join(".config")
190        .join("git-worktree-manager")
191        .join("config.json")
192}
193
194/// Deep merge: override takes precedence, nested dicts merged recursively.
195fn deep_merge(base: Value, over: Value) -> Value {
196    match (base, over) {
197        (Value::Object(mut base_map), Value::Object(over_map)) => {
198            for (key, over_val) in over_map {
199                let merged = if let Some(base_val) = base_map.remove(&key) {
200                    deep_merge(base_val, over_val)
201                } else {
202                    over_val
203                };
204                base_map.insert(key, merged);
205            }
206            Value::Object(base_map)
207        }
208        (_, over) => over,
209    }
210}
211
212/// Get the path to the legacy Python configuration file.
213fn get_legacy_config_path() -> PathBuf {
214    let home = home_dir_or_fallback();
215    home.join(".config")
216        .join("claude-worktree")
217        .join("config.json")
218}
219
220/// Load configuration from file, deep-merged with defaults.
221/// Falls back to legacy Python config path if the new path doesn't exist.
222pub fn load_config() -> Result<Config> {
223    let config_path = get_config_path();
224
225    let config_path = if config_path.exists() {
226        config_path
227    } else {
228        let legacy = get_legacy_config_path();
229        if legacy.exists() {
230            legacy
231        } else {
232            return Ok(Config::default());
233        }
234    };
235
236    let content = std::fs::read_to_string(&config_path).map_err(|e| {
237        CwError::Config(format!(
238            "Failed to load config from {}: {}",
239            config_path.display(),
240            e
241        ))
242    })?;
243
244    let file_value: Value = serde_json::from_str(&content).map_err(|e| {
245        CwError::Config(format!(
246            "Failed to parse config from {}: {}",
247            config_path.display(),
248            e
249        ))
250    })?;
251
252    let default_value = serde_json::to_value(Config::default())?;
253    let merged = deep_merge(default_value, file_value);
254
255    serde_json::from_value(merged).map_err(|e| {
256        CwError::Config(format!(
257            "Failed to deserialize config from {}: {}",
258            config_path.display(),
259            e
260        ))
261    })
262}
263
264/// Load configuration with the repo-local `.cwconfig.json` (if any) deep-merged
265/// over the global config. Layer order (lowest to highest precedence):
266///
267///   1. `Config::default()`
268///   2. Global `~/.config/git-worktree-manager/config.json` (or legacy path)
269///   3. Repo-local `.cwconfig.json` walked up from `cwd`
270///
271/// Returns `Config::default()` if no config files are found.
272pub fn load_effective_config(cwd: &Path) -> Result<Config> {
273    load_effective_config_with_global(cwd, &get_config_path())
274}
275
276/// Same as [`load_effective_config`] but accepts an explicit global config path.
277/// Carved out so tests can drive the global layer without setting `HOME`.
278pub fn load_effective_config_with_global(cwd: &Path, global_path: &Path) -> Result<Config> {
279    let mut merged = serde_json::to_value(Config::default())?;
280
281    // Cache the default path once to avoid a second `get_config_path()` call
282    // (which re-reads `GW_CONFIG_HOME`) and eliminate the TOCTOU window between
283    // the two reads.
284    let default_path = get_config_path();
285
286    // Layer 2: global file (or legacy fallback if explicit path absent).
287    let global_value = if global_path.exists() {
288        Some(read_json_value(global_path)?)
289    } else if global_path == default_path.as_path() {
290        // Only fall through to legacy when the caller passed the *default* path —
291        // tests that pass an explicit synthetic path should not pick up the user's
292        // real legacy file.
293        let legacy = get_legacy_config_path();
294        if legacy.exists() {
295            Some(read_json_value(&legacy)?)
296        } else {
297            None
298        }
299    } else {
300        None
301    };
302    if let Some(v) = global_value {
303        merged = deep_merge(merged, v);
304    }
305
306    // Layer 3: repo-local `.cwconfig.json` walked up from cwd.
307    if let Some(repo_value) = crate::repo_config::load_repo_config(cwd)? {
308        merged = deep_merge(merged, repo_value);
309    }
310
311    serde_json::from_value(merged)
312        .map_err(|e| CwError::Config(format!("invalid effective config: {}", e)))
313}
314
315fn read_json_value(path: &Path) -> Result<serde_json::Value> {
316    let content = std::fs::read_to_string(path).map_err(|e| {
317        CwError::Config(format!(
318            "failed to load config from {}: {}",
319            path.display(),
320            e
321        ))
322    })?;
323    serde_json::from_str(&content).map_err(|e| {
324        CwError::Config(format!(
325            "failed to parse config from {}: {}",
326            path.display(),
327            e
328        ))
329    })
330}
331
332/// Save configuration to file.
333pub fn save_config(config: &Config) -> Result<()> {
334    let config_path = get_config_path();
335    if let Some(parent) = config_path.parent() {
336        std::fs::create_dir_all(parent)?;
337    }
338
339    let content = serde_json::to_string_pretty(config)?;
340    std::fs::write(&config_path, content).map_err(|e| {
341        CwError::Config(format!(
342            "Failed to save config to {}: {}",
343            config_path.display(),
344            e
345        ))
346    })
347}
348
349/// Get the AI tool command to execute.
350///
351/// Priority: CW_AI_TOOL env > config file > default ("claude").
352pub fn get_ai_tool_command() -> Result<Vec<String>> {
353    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
354    get_ai_tool_command_for_cwd(&cwd)
355}
356
357/// Like [`get_ai_tool_command`] but resolves config from `cwd` so a repo-local `.cwconfig.json` can override the global `ai_tool` block.
358pub fn get_ai_tool_command_for_cwd(cwd: &Path) -> Result<Vec<String>> {
359    // Check environment variable first
360    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
361        if env_tool.trim().is_empty() {
362            return Ok(Vec::new());
363        }
364        return Ok(env_tool.split_whitespace().map(String::from).collect());
365    }
366
367    let config = load_effective_config(cwd)?;
368    let command = &config.ai_tool.command;
369    let args = &config.ai_tool.args;
370
371    let presets = ai_tool_presets();
372    if let Some(base_cmd) = presets.get(command.as_str()) {
373        let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
374        cmd.extend(args.iter().cloned());
375        return Ok(cmd);
376    }
377
378    if command.trim().is_empty() {
379        return Ok(Vec::new());
380    }
381
382    let mut cmd = vec![command.clone()];
383    cmd.extend(args.iter().cloned());
384    Ok(cmd)
385}
386
387/// Get the AI tool resume command.
388pub fn get_ai_tool_resume_command() -> Result<Vec<String>> {
389    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
390    get_ai_tool_resume_command_for_cwd(&cwd)
391}
392
393/// Like [`get_ai_tool_resume_command`] but resolves config from `cwd` so a repo-local `.cwconfig.json` can override the global `ai_tool` block.
394pub fn get_ai_tool_resume_command_for_cwd(cwd: &Path) -> Result<Vec<String>> {
395    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
396        if env_tool.trim().is_empty() {
397            return Ok(Vec::new());
398        }
399        // Look up the first word in the resume preset table so that known
400        // tools (e.g. "claude") get their proper resume flag (--continue)
401        // instead of a blind --resume append.
402        let first_word = env_tool.split_whitespace().next().unwrap_or("");
403        let resume_presets = ai_tool_resume_presets();
404        if let Some(preset_cmd) = resume_presets.get(first_word) {
405            return Ok(preset_cmd.iter().map(|s| s.to_string()).collect());
406        }
407        // Unknown tool: fall back to appending --resume.
408        let mut parts: Vec<String> = env_tool.split_whitespace().map(String::from).collect();
409        parts.push("--resume".to_string());
410        return Ok(parts);
411    }
412
413    let config = load_effective_config(cwd)?;
414    let command = &config.ai_tool.command;
415    let args = &config.ai_tool.args;
416
417    if command.trim().is_empty() {
418        return Ok(Vec::new());
419    }
420
421    let resume_presets = ai_tool_resume_presets();
422    if let Some(resume_cmd) = resume_presets.get(command.as_str()) {
423        let mut cmd: Vec<String> = resume_cmd.iter().map(|s| s.to_string()).collect();
424        cmd.extend(args.iter().cloned());
425        return Ok(cmd);
426    }
427
428    let presets = ai_tool_presets();
429    if let Some(base_cmd) = presets.get(command.as_str()) {
430        if base_cmd.is_empty() {
431            return Ok(Vec::new());
432        }
433        let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
434        cmd.extend(args.iter().cloned());
435        cmd.push("--resume".to_string());
436        return Ok(cmd);
437    }
438
439    let mut cmd = vec![command.clone()];
440    cmd.extend(args.iter().cloned());
441    cmd.push("--resume".to_string());
442    Ok(cmd)
443}
444
445/// Check if the currently configured AI tool is Claude-based.
446pub fn is_claude_tool() -> Result<bool> {
447    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
448    is_claude_tool_for_cwd(&cwd)
449}
450
451/// Like [`is_claude_tool`] but resolves config from `cwd` so a repo-local `.cwconfig.json` can override the global `ai_tool` block.
452pub fn is_claude_tool_for_cwd(cwd: &Path) -> Result<bool> {
453    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
454        let first_word = env_tool.split_whitespace().next().unwrap_or("");
455        return Ok(first_word == "claude");
456    }
457    let config = load_effective_config(cwd)?;
458    Ok(claude_preset_names().contains(&config.ai_tool.command.as_str()))
459}
460
461/// Resolve a launch method value (possibly an alias) to its display name.
462pub fn resolve_launch_display_name(method: &str) -> String {
463    let aliases = launch_method_aliases();
464    let canonical = aliases.get(method).copied().unwrap_or(method);
465    LaunchMethod::from_str_opt(canonical)
466        .map(|m| format!("{} ({})", m.display_name(), method))
467        .unwrap_or_else(|| method.to_string())
468}
469
470// ---------------------------------------------------------------------------
471// Shell completion prompt
472// ---------------------------------------------------------------------------
473
474/// Check if shell integration (gw-cd) is already installed in the user's profile.
475fn is_shell_integration_installed() -> bool {
476    let home = home_dir_or_fallback();
477    let shell_env = std::env::var("SHELL").unwrap_or_default();
478
479    let profile_path = if shell_env.contains("zsh") {
480        home.join(".zshrc")
481    } else if shell_env.contains("bash") {
482        home.join(".bashrc")
483    } else if shell_env.contains("fish") {
484        home.join(".config").join("fish").join("config.fish")
485    } else {
486        return false;
487    };
488
489    if let Ok(content) = std::fs::read_to_string(&profile_path) {
490        content.contains("gw _shell-function") || content.contains("gw-cd")
491    } else {
492        false
493    }
494}
495
496/// Prompt user to set up shell integration on first run.
497///
498/// Shows a one-time hint if:
499/// - Shell integration is not already installed
500/// - User has not been prompted before
501///
502/// Updates `shell_completion.prompted` in config after showing.
503pub fn prompt_shell_completion_setup() {
504    let config = match load_config() {
505        Ok(c) => c,
506        Err(_) => return,
507    };
508
509    if config.shell_completion.prompted || config.shell_completion.installed {
510        return;
511    }
512
513    if is_shell_integration_installed() {
514        // Already installed — mark both flags and skip
515        let mut config = config;
516        config.shell_completion.prompted = true;
517        config.shell_completion.installed = true;
518        let _ = save_config(&config);
519        return;
520    }
521
522    // Show one-time hint
523    eprintln!(
524        "\n{} Shell integration (gw-cd + tab completion) is not set up.",
525        console::style("Tip:").cyan().bold()
526    );
527    eprintln!(
528        "     Run {} to enable directory navigation and completions.\n",
529        console::style("gw shell-setup").cyan()
530    );
531
532    // Mark as prompted
533    let mut config = config;
534    config.shell_completion.prompted = true;
535    let _ = save_config(&config);
536}
537
538// ---------------------------------------------------------------------------
539// Launch method configuration
540// ---------------------------------------------------------------------------
541
542/// Resolve launch method alias to full name.
543pub fn resolve_launch_alias(value: &str) -> String {
544    let deprecated: HashMap<&str, &str> =
545        HashMap::from([("bg", "detach"), ("background", "detach")]);
546    let aliases = launch_method_aliases();
547
548    // Handle session name suffix (e.g., "t:mysession")
549    if let Some((prefix, suffix)) = value.split_once(':') {
550        let resolved_prefix = if let Some(&new) = deprecated.get(prefix) {
551            eprintln!(
552                "Warning: '{}' is deprecated. Use '{}' instead.",
553                prefix, new
554            );
555            new.to_string()
556        } else {
557            aliases
558                .get(prefix)
559                .map(|s| s.to_string())
560                .unwrap_or_else(|| prefix.to_string())
561        };
562        return format!("{}:{}", resolved_prefix, suffix);
563    }
564
565    if let Some(&new) = deprecated.get(value) {
566        eprintln!("Warning: '{}' is deprecated. Use '{}' instead.", value, new);
567        return new.to_string();
568    }
569
570    aliases
571        .get(value)
572        .map(|s| s.to_string())
573        .unwrap_or_else(|| value.to_string())
574}
575
576/// Parse --term option value.
577///
578/// Returns (LaunchMethod, optional_session_name).
579pub fn parse_term_option(term_value: Option<&str>) -> Result<(LaunchMethod, Option<String>)> {
580    let term_value = match term_value {
581        Some(v) => v,
582        None => return Ok((get_default_launch_method()?, None)),
583    };
584
585    let resolved = resolve_launch_alias(term_value);
586
587    if let Some((method_str, session_name)) = resolved.split_once(':') {
588        let method = LaunchMethod::from_str_opt(method_str)
589            .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", method_str)))?;
590
591        if matches!(method, LaunchMethod::Tmux | LaunchMethod::Zellij) {
592            if session_name.len() > MAX_SESSION_NAME_LENGTH {
593                return Err(CwError::Config(format!(
594                    "Session name too long (max {} chars): {}",
595                    MAX_SESSION_NAME_LENGTH, session_name
596                )));
597            }
598            return Ok((method, Some(session_name.to_string())));
599        } else {
600            return Err(CwError::Config(format!(
601                "Session name not supported for {}",
602                method_str
603            )));
604        }
605    }
606
607    let method = LaunchMethod::from_str_opt(&resolved)
608        .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", term_value)))?;
609    Ok((method, None))
610}
611
612/// Get default launch method from config or environment.
613pub fn get_default_launch_method() -> Result<LaunchMethod> {
614    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
615    get_default_launch_method_for_cwd(&cwd)
616}
617
618/// Like [`get_default_launch_method`] but resolves config from `cwd` so a repo-local `.cwconfig.json` can override the global `launch` block.
619pub fn get_default_launch_method_for_cwd(cwd: &Path) -> Result<LaunchMethod> {
620    // 1. Environment variable
621    if let Ok(env_val) = std::env::var("CW_LAUNCH_METHOD") {
622        let resolved = resolve_launch_alias(&env_val);
623        if let Some(method) = LaunchMethod::from_str_opt(&resolved) {
624            return Ok(method);
625        }
626    }
627
628    // 2. Config file
629    let config = load_effective_config(cwd)?;
630    if let Some(ref method) = config.launch.method {
631        let resolved = resolve_launch_alias(method);
632        if let Some(m) = LaunchMethod::from_str_opt(&resolved) {
633            return Ok(m);
634        }
635    }
636
637    Ok(LaunchMethod::Foreground)
638}
639
640/// Resolve a launch method honoring an optional CLI override.
641///
642/// Resolution order:
643///   1. `term_override` (the `-T/--term` CLI flag), if `Some` — parsed via
644///      [`parse_term_option`], so it understands aliases and the
645///      `method:session-name` syntax.
646///   2. `CW_LAUNCH_METHOD` env var.
647///   3. `.cwconfig.json` (repo-local) `launch.method`.
648///   4. global `~/.config/git-worktree-manager/config.json` `launch.method`.
649///   5. [`LaunchMethod::Foreground`] (the default).
650///
651/// Returns `(method, session_name)` where `session_name` is `Some` only
652/// when the override used the `method:session-name` syntax (env / config
653/// paths can't carry a session name today).
654pub fn resolve_term_option(
655    term_override: Option<&str>,
656    cwd: &Path,
657) -> Result<(LaunchMethod, Option<String>)> {
658    if let Some(value) = term_override {
659        // Override path: parse_term_option handles aliases and
660        // method:session syntax. Falls through to the env/config chain
661        // below only when the override is None — that chain needs `cwd`,
662        // which parse_term_option does not take.
663        return parse_term_option(Some(value));
664    }
665    let method = get_default_launch_method_for_cwd(cwd)?;
666    Ok((method, None))
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    #[test]
674    fn test_default_config() {
675        let config = Config::default();
676        assert_eq!(config.ai_tool.command, "claude");
677        assert!(config.ai_tool.args.is_empty());
678        assert!(config.update.auto_check);
679    }
680
681    #[test]
682    fn test_resolve_launch_alias() {
683        assert_eq!(resolve_launch_alias("fg"), "foreground");
684        assert_eq!(resolve_launch_alias("t"), "tmux");
685        assert_eq!(resolve_launch_alias("z-t"), "zellij-tab");
686        assert_eq!(resolve_launch_alias("t:mywork"), "tmux:mywork");
687        assert_eq!(resolve_launch_alias("foreground"), "foreground");
688    }
689
690    #[test]
691    fn test_parse_term_option() {
692        let (method, session) = parse_term_option(Some("t")).unwrap();
693        assert_eq!(method, LaunchMethod::Tmux);
694        assert!(session.is_none());
695
696        let (method, session) = parse_term_option(Some("t:mywork")).unwrap();
697        assert_eq!(method, LaunchMethod::Tmux);
698        assert_eq!(session.unwrap(), "mywork");
699
700        let (method, session) = parse_term_option(Some("i-t")).unwrap();
701        assert_eq!(method, LaunchMethod::ItermTab);
702        assert!(session.is_none());
703    }
704
705    #[test]
706    fn test_preset_names() {
707        let presets = ai_tool_presets();
708        assert!(presets.contains_key("claude"));
709        assert!(presets.contains_key("no-op"));
710        assert!(presets.contains_key("codex"));
711        assert_eq!(presets["no-op"].len(), 0);
712        assert_eq!(presets["claude"], vec!["claude"]);
713    }
714
715    /// Regression guard for the bug where `spawn_in_worktree(prompt=Some)`
716    /// mistakenly used the merge preset (which injects `--print
717    /// --tools=default`) and left users staring at a blank pane while claude
718    /// ran headlessly. The interactive presets must never carry `--print` or
719    /// any other non-interactive automation flag.
720    #[test]
721    fn interactive_presets_have_no_print_flag() {
722        let presets = ai_tool_presets();
723        for (name, args) in &presets {
724            for forbidden in ["--print", "--tools=default", "--non-interactive"] {
725                assert!(
726                    !args.contains(&forbidden),
727                    "interactive preset {:?} must not carry {} (would force \
728                     headless mode for `gw new --prompt`); got {:?}",
729                    name,
730                    forbidden,
731                    args
732                );
733            }
734        }
735    }
736}