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    // Layer 2: global file (or legacy fallback if explicit path absent).
282    let global_value = if global_path.exists() {
283        Some(read_json_value(global_path)?)
284    } else if global_path == get_config_path().as_path() {
285        // Only fall through to legacy when the caller passed the *default* path —
286        // tests that pass an explicit synthetic path should not pick up the user's
287        // real legacy file.
288        let legacy = get_legacy_config_path();
289        if legacy.exists() {
290            Some(read_json_value(&legacy)?)
291        } else {
292            None
293        }
294    } else {
295        None
296    };
297    if let Some(v) = global_value {
298        merged = deep_merge(merged, v);
299    }
300
301    // Layer 3: repo-local `.cwconfig.json` walked up from cwd.
302    if let Some(repo_value) = crate::repo_config::load_repo_config(cwd)? {
303        merged = deep_merge(merged, repo_value);
304    }
305
306    serde_json::from_value(merged)
307        .map_err(|e| CwError::Config(format!("invalid effective config: {}", e)))
308}
309
310fn read_json_value(path: &Path) -> Result<serde_json::Value> {
311    let content = std::fs::read_to_string(path).map_err(|e| {
312        CwError::Config(format!(
313            "failed to load config from {}: {}",
314            path.display(),
315            e
316        ))
317    })?;
318    serde_json::from_str(&content).map_err(|e| {
319        CwError::Config(format!(
320            "failed to parse config from {}: {}",
321            path.display(),
322            e
323        ))
324    })
325}
326
327/// Save configuration to file.
328pub fn save_config(config: &Config) -> Result<()> {
329    let config_path = get_config_path();
330    if let Some(parent) = config_path.parent() {
331        std::fs::create_dir_all(parent)?;
332    }
333
334    let content = serde_json::to_string_pretty(config)?;
335    std::fs::write(&config_path, content).map_err(|e| {
336        CwError::Config(format!(
337            "Failed to save config to {}: {}",
338            config_path.display(),
339            e
340        ))
341    })
342}
343
344/// Get the AI tool command to execute.
345///
346/// Priority: CW_AI_TOOL env > config file > default ("claude").
347pub fn get_ai_tool_command() -> Result<Vec<String>> {
348    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
349    get_ai_tool_command_for_cwd(&cwd)
350}
351
352/// Like [`get_ai_tool_command`] but resolves config from `cwd` so a repo-local `.cwconfig.json` can override the global `ai_tool` block.
353pub fn get_ai_tool_command_for_cwd(cwd: &Path) -> Result<Vec<String>> {
354    // Check environment variable first
355    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
356        if env_tool.trim().is_empty() {
357            return Ok(Vec::new());
358        }
359        return Ok(env_tool.split_whitespace().map(String::from).collect());
360    }
361
362    let config = load_effective_config(cwd)?;
363    let command = &config.ai_tool.command;
364    let args = &config.ai_tool.args;
365
366    let presets = ai_tool_presets();
367    if let Some(base_cmd) = presets.get(command.as_str()) {
368        let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
369        cmd.extend(args.iter().cloned());
370        return Ok(cmd);
371    }
372
373    if command.trim().is_empty() {
374        return Ok(Vec::new());
375    }
376
377    let mut cmd = vec![command.clone()];
378    cmd.extend(args.iter().cloned());
379    Ok(cmd)
380}
381
382/// Get the AI tool resume command.
383pub fn get_ai_tool_resume_command() -> Result<Vec<String>> {
384    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
385    get_ai_tool_resume_command_for_cwd(&cwd)
386}
387
388/// Like [`get_ai_tool_resume_command`] but resolves config from `cwd` so a repo-local `.cwconfig.json` can override the global `ai_tool` block.
389pub fn get_ai_tool_resume_command_for_cwd(cwd: &Path) -> Result<Vec<String>> {
390    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
391        if env_tool.trim().is_empty() {
392            return Ok(Vec::new());
393        }
394        let mut parts: Vec<String> = env_tool.split_whitespace().map(String::from).collect();
395        parts.push("--resume".to_string());
396        return Ok(parts);
397    }
398
399    let config = load_effective_config(cwd)?;
400    let command = &config.ai_tool.command;
401    let args = &config.ai_tool.args;
402
403    if command.trim().is_empty() {
404        return Ok(Vec::new());
405    }
406
407    let resume_presets = ai_tool_resume_presets();
408    if let Some(resume_cmd) = resume_presets.get(command.as_str()) {
409        let mut cmd: Vec<String> = resume_cmd.iter().map(|s| s.to_string()).collect();
410        cmd.extend(args.iter().cloned());
411        return Ok(cmd);
412    }
413
414    let presets = ai_tool_presets();
415    if let Some(base_cmd) = presets.get(command.as_str()) {
416        if base_cmd.is_empty() {
417            return Ok(Vec::new());
418        }
419        let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
420        cmd.extend(args.iter().cloned());
421        cmd.push("--resume".to_string());
422        return Ok(cmd);
423    }
424
425    let mut cmd = vec![command.clone()];
426    cmd.extend(args.iter().cloned());
427    cmd.push("--resume".to_string());
428    Ok(cmd)
429}
430
431/// Check if the currently configured AI tool is Claude-based.
432pub fn is_claude_tool() -> Result<bool> {
433    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
434    is_claude_tool_for_cwd(&cwd)
435}
436
437/// Like [`is_claude_tool`] but resolves config from `cwd` so a repo-local `.cwconfig.json` can override the global `ai_tool` block.
438pub fn is_claude_tool_for_cwd(cwd: &Path) -> Result<bool> {
439    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
440        let first_word = env_tool.split_whitespace().next().unwrap_or("");
441        return Ok(first_word == "claude");
442    }
443    let config = load_effective_config(cwd)?;
444    Ok(claude_preset_names().contains(&config.ai_tool.command.as_str()))
445}
446
447/// Resolve a launch method value (possibly an alias) to its display name.
448pub fn resolve_launch_display_name(method: &str) -> String {
449    let aliases = launch_method_aliases();
450    let canonical = aliases.get(method).copied().unwrap_or(method);
451    LaunchMethod::from_str_opt(canonical)
452        .map(|m| format!("{} ({})", m.display_name(), method))
453        .unwrap_or_else(|| method.to_string())
454}
455
456// ---------------------------------------------------------------------------
457// Shell completion prompt
458// ---------------------------------------------------------------------------
459
460/// Check if shell integration (gw-cd) is already installed in the user's profile.
461fn is_shell_integration_installed() -> bool {
462    let home = home_dir_or_fallback();
463    let shell_env = std::env::var("SHELL").unwrap_or_default();
464
465    let profile_path = if shell_env.contains("zsh") {
466        home.join(".zshrc")
467    } else if shell_env.contains("bash") {
468        home.join(".bashrc")
469    } else if shell_env.contains("fish") {
470        home.join(".config").join("fish").join("config.fish")
471    } else {
472        return false;
473    };
474
475    if let Ok(content) = std::fs::read_to_string(&profile_path) {
476        content.contains("gw _shell-function") || content.contains("gw-cd")
477    } else {
478        false
479    }
480}
481
482/// Prompt user to set up shell integration on first run.
483///
484/// Shows a one-time hint if:
485/// - Shell integration is not already installed
486/// - User has not been prompted before
487///
488/// Updates `shell_completion.prompted` in config after showing.
489pub fn prompt_shell_completion_setup() {
490    let config = match load_config() {
491        Ok(c) => c,
492        Err(_) => return,
493    };
494
495    if config.shell_completion.prompted || config.shell_completion.installed {
496        return;
497    }
498
499    if is_shell_integration_installed() {
500        // Already installed — mark both flags and skip
501        let mut config = config;
502        config.shell_completion.prompted = true;
503        config.shell_completion.installed = true;
504        let _ = save_config(&config);
505        return;
506    }
507
508    // Show one-time hint
509    eprintln!(
510        "\n{} Shell integration (gw-cd + tab completion) is not set up.",
511        console::style("Tip:").cyan().bold()
512    );
513    eprintln!(
514        "     Run {} to enable directory navigation and completions.\n",
515        console::style("gw shell-setup").cyan()
516    );
517
518    // Mark as prompted
519    let mut config = config;
520    config.shell_completion.prompted = true;
521    let _ = save_config(&config);
522}
523
524// ---------------------------------------------------------------------------
525// Launch method configuration
526// ---------------------------------------------------------------------------
527
528/// Resolve launch method alias to full name.
529pub fn resolve_launch_alias(value: &str) -> String {
530    let deprecated: HashMap<&str, &str> =
531        HashMap::from([("bg", "detach"), ("background", "detach")]);
532    let aliases = launch_method_aliases();
533
534    // Handle session name suffix (e.g., "t:mysession")
535    if let Some((prefix, suffix)) = value.split_once(':') {
536        let resolved_prefix = if let Some(&new) = deprecated.get(prefix) {
537            eprintln!(
538                "Warning: '{}' is deprecated. Use '{}' instead.",
539                prefix, new
540            );
541            new.to_string()
542        } else {
543            aliases
544                .get(prefix)
545                .map(|s| s.to_string())
546                .unwrap_or_else(|| prefix.to_string())
547        };
548        return format!("{}:{}", resolved_prefix, suffix);
549    }
550
551    if let Some(&new) = deprecated.get(value) {
552        eprintln!("Warning: '{}' is deprecated. Use '{}' instead.", value, new);
553        return new.to_string();
554    }
555
556    aliases
557        .get(value)
558        .map(|s| s.to_string())
559        .unwrap_or_else(|| value.to_string())
560}
561
562/// Parse --term option value.
563///
564/// Returns (LaunchMethod, optional_session_name).
565pub fn parse_term_option(term_value: Option<&str>) -> Result<(LaunchMethod, Option<String>)> {
566    let term_value = match term_value {
567        Some(v) => v,
568        None => return Ok((get_default_launch_method()?, None)),
569    };
570
571    let resolved = resolve_launch_alias(term_value);
572
573    if let Some((method_str, session_name)) = resolved.split_once(':') {
574        let method = LaunchMethod::from_str_opt(method_str)
575            .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", method_str)))?;
576
577        if matches!(method, LaunchMethod::Tmux | LaunchMethod::Zellij) {
578            if session_name.len() > MAX_SESSION_NAME_LENGTH {
579                return Err(CwError::Config(format!(
580                    "Session name too long (max {} chars): {}",
581                    MAX_SESSION_NAME_LENGTH, session_name
582                )));
583            }
584            return Ok((method, Some(session_name.to_string())));
585        } else {
586            return Err(CwError::Config(format!(
587                "Session name not supported for {}",
588                method_str
589            )));
590        }
591    }
592
593    let method = LaunchMethod::from_str_opt(&resolved)
594        .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", term_value)))?;
595    Ok((method, None))
596}
597
598/// Get default launch method from config or environment.
599pub fn get_default_launch_method() -> Result<LaunchMethod> {
600    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
601    get_default_launch_method_for_cwd(&cwd)
602}
603
604/// Like [`get_default_launch_method`] but resolves config from `cwd` so a repo-local `.cwconfig.json` can override the global `launch` block.
605pub fn get_default_launch_method_for_cwd(cwd: &Path) -> Result<LaunchMethod> {
606    // 1. Environment variable
607    if let Ok(env_val) = std::env::var("CW_LAUNCH_METHOD") {
608        let resolved = resolve_launch_alias(&env_val);
609        if let Some(method) = LaunchMethod::from_str_opt(&resolved) {
610            return Ok(method);
611        }
612    }
613
614    // 2. Config file
615    let config = load_effective_config(cwd)?;
616    if let Some(ref method) = config.launch.method {
617        let resolved = resolve_launch_alias(method);
618        if let Some(m) = LaunchMethod::from_str_opt(&resolved) {
619            return Ok(m);
620        }
621    }
622
623    Ok(LaunchMethod::Foreground)
624}
625
626/// Resolve a launch method honoring an optional CLI override.
627///
628/// Resolution order:
629///   1. `term_override` (the `-T/--term` CLI flag), if `Some` — parsed via
630///      [`parse_term_option`], so it understands aliases and the
631///      `method:session-name` syntax.
632///   2. `CW_LAUNCH_METHOD` env var.
633///   3. `.cwconfig.json` (repo-local) `launch.method`.
634///   4. global `~/.config/git-worktree-manager/config.json` `launch.method`.
635///   5. [`LaunchMethod::Foreground`] (the default).
636///
637/// Returns `(method, session_name)` where `session_name` is `Some` only
638/// when the override used the `method:session-name` syntax (env / config
639/// paths can't carry a session name today).
640pub fn resolve_term_option(
641    term_override: Option<&str>,
642    cwd: &Path,
643) -> Result<(LaunchMethod, Option<String>)> {
644    if let Some(value) = term_override {
645        // Override path: parse_term_option handles aliases and
646        // method:session syntax. Falls through to the env/config chain
647        // below only when the override is None — that chain needs `cwd`,
648        // which parse_term_option does not take.
649        return parse_term_option(Some(value));
650    }
651    let method = get_default_launch_method_for_cwd(cwd)?;
652    Ok((method, None))
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    #[test]
660    fn test_default_config() {
661        let config = Config::default();
662        assert_eq!(config.ai_tool.command, "claude");
663        assert!(config.ai_tool.args.is_empty());
664        assert!(config.update.auto_check);
665    }
666
667    #[test]
668    fn test_resolve_launch_alias() {
669        assert_eq!(resolve_launch_alias("fg"), "foreground");
670        assert_eq!(resolve_launch_alias("t"), "tmux");
671        assert_eq!(resolve_launch_alias("z-t"), "zellij-tab");
672        assert_eq!(resolve_launch_alias("t:mywork"), "tmux:mywork");
673        assert_eq!(resolve_launch_alias("foreground"), "foreground");
674    }
675
676    #[test]
677    fn test_parse_term_option() {
678        let (method, session) = parse_term_option(Some("t")).unwrap();
679        assert_eq!(method, LaunchMethod::Tmux);
680        assert!(session.is_none());
681
682        let (method, session) = parse_term_option(Some("t:mywork")).unwrap();
683        assert_eq!(method, LaunchMethod::Tmux);
684        assert_eq!(session.unwrap(), "mywork");
685
686        let (method, session) = parse_term_option(Some("i-t")).unwrap();
687        assert_eq!(method, LaunchMethod::ItermTab);
688        assert!(session.is_none());
689    }
690
691    #[test]
692    fn test_preset_names() {
693        let presets = ai_tool_presets();
694        assert!(presets.contains_key("claude"));
695        assert!(presets.contains_key("no-op"));
696        assert!(presets.contains_key("codex"));
697        assert_eq!(presets["no-op"].len(), 0);
698        assert_eq!(presets["claude"], vec!["claude"]);
699    }
700
701    /// Regression guard for the bug where `spawn_in_worktree(prompt=Some)`
702    /// mistakenly used the merge preset (which injects `--print
703    /// --tools=default`) and left users staring at a blank pane while claude
704    /// ran headlessly. The interactive presets must never carry `--print` or
705    /// any other non-interactive automation flag.
706    #[test]
707    fn interactive_presets_have_no_print_flag() {
708        let presets = ai_tool_presets();
709        for (name, args) in &presets {
710            for forbidden in ["--print", "--tools=default", "--non-interactive"] {
711                assert!(
712                    !args.contains(&forbidden),
713                    "interactive preset {:?} must not carry {} (would force \
714                     headless mode for `gw new --prompt`); got {:?}",
715                    name,
716                    forbidden,
717                    args
718                );
719            }
720        }
721    }
722}