Skip to main content

git_worktree_manager/operations/
ai_tools.rs

1/// AI tool integration operations.
2///
3/// Handles launching AI coding assistants in various terminal environments.
4use std::collections::BTreeMap;
5use std::path::Path;
6
7use console::style;
8
9use crate::config::{
10    self, get_ai_tool_command_for_cwd, get_ai_tool_resume_command_for_cwd, is_claude_tool_for_cwd,
11    load_effective_config,
12};
13use crate::constants::{LaunchMethod, MAX_SESSION_NAME_LENGTH};
14use crate::error::{CwError, Result};
15use crate::git;
16use crate::messages;
17use crate::session;
18
19use super::claude_settings;
20use super::helpers::{resolve_target_strict, resolve_worktree_target};
21use super::launchers;
22use super::spawn_spec::{self, SpawnSpec};
23
24/// Per-invocation knobs that ride alongside `term_override` and `prompt`
25/// into every AI-tool launcher path. Bundled so a future option (extra args,
26/// `--reason`-style metadata) doesn't fan out into every signature.
27#[derive(Debug, Default, Clone)]
28pub struct LaunchOptions<'a> {
29    /// `-T/--term` override.
30    pub term_override: Option<&'a str>,
31    /// Trailing args forwarded verbatim to the AI tool (after the preset's
32    /// own args, before the prompt positional).
33    pub forward_args: &'a [String],
34    /// True when `--no-env-forward` was passed.
35    pub no_env_forward: bool,
36}
37
38impl<'a> LaunchOptions<'a> {
39    /// Convenience constructor for callers that only have `-T`.
40    pub fn from_term(term_override: Option<&'a str>) -> Self {
41        Self {
42            term_override,
43            forward_args: &[],
44            no_env_forward: false,
45        }
46    }
47}
48
49/// Dispatch a pre-materialized command to the configured launcher.
50///
51/// Both `launch_ai_tool` and `spawn_in_worktree` share this block; keeping it
52/// in one place means any launcher added in the future is automatically
53/// available to both callers.
54fn dispatch_launch(
55    path: &Path,
56    method: LaunchMethod,
57    session_name: Option<String>,
58    cmd: &str,
59    ai_tool_name: &str,
60) -> Result<()> {
61    match method {
62        LaunchMethod::Skip => {
63            // Should be intercepted by callers before dispatch (so they can
64            // skip ai-tool resolution and the executable check entirely).
65            // Treat as a defensive no-op if reached.
66        }
67        LaunchMethod::Foreground => {
68            println!(
69                "{}\n",
70                style(messages::starting_ai_tool_foreground(ai_tool_name)).cyan()
71            );
72            // `_session_lock` binding is intentional: RAII guard lives for
73            // the foreground AI process lifetime; dropped on return.
74            let _session_lock = match crate::operations::lockfile::acquire(path, ai_tool_name) {
75                Ok(lock) => Some(lock),
76                Err(err @ crate::operations::lockfile::AcquireError::ForeignLock(_)) => {
77                    return Err(crate::error::CwError::Other(format!(
78                        "{}; exit that session first",
79                        err
80                    )));
81                }
82                Err(e) => {
83                    eprintln!(
84                        "{} could not write session lock: {}",
85                        style("warning:").yellow(),
86                        e
87                    );
88                    None
89                }
90            };
91            launchers::foreground::run(path, cmd);
92        }
93        LaunchMethod::Detach => {
94            launchers::detached::run(path, cmd);
95            println!(
96                "{} {} detached (survives terminal close)\n",
97                style("*").green().bold(),
98                ai_tool_name
99            );
100        }
101        // iTerm
102        LaunchMethod::ItermWindow => launchers::iterm::launch_window(path, cmd, ai_tool_name)?,
103        LaunchMethod::ItermTab => launchers::iterm::launch_tab(path, cmd, ai_tool_name)?,
104        LaunchMethod::ItermPaneH => launchers::iterm::launch_pane(path, cmd, ai_tool_name, true)?,
105        LaunchMethod::ItermPaneV => launchers::iterm::launch_pane(path, cmd, ai_tool_name, false)?,
106        // tmux
107        LaunchMethod::Tmux => {
108            let sn = session_name.unwrap_or_else(|| generate_session_name(path));
109            launchers::tmux::launch_session(path, cmd, ai_tool_name, &sn)?;
110        }
111        LaunchMethod::TmuxWindow => {
112            launchers::tmux::launch_window(path, cmd, ai_tool_name, &tab_label_for(path))?
113        }
114        LaunchMethod::TmuxPaneH => launchers::tmux::launch_pane(path, cmd, ai_tool_name, true)?,
115        LaunchMethod::TmuxPaneV => launchers::tmux::launch_pane(path, cmd, ai_tool_name, false)?,
116        // Zellij
117        LaunchMethod::Zellij => {
118            let sn = session_name.unwrap_or_else(|| generate_session_name(path));
119            launchers::zellij::launch_session(path, cmd, ai_tool_name, &sn)?;
120        }
121        LaunchMethod::ZellijTab => {
122            launchers::zellij::launch_tab(path, cmd, ai_tool_name, &tab_label_for(path))?
123        }
124        LaunchMethod::ZellijPaneH => launchers::zellij::launch_pane(path, cmd, ai_tool_name, true)?,
125        LaunchMethod::ZellijPaneV => {
126            launchers::zellij::launch_pane(path, cmd, ai_tool_name, false)?
127        }
128        // WezTerm
129        LaunchMethod::WeztermWindow => {
130            launchers::wezterm::launch_window(path, cmd, ai_tool_name, &tab_label_for(path))?
131        }
132        LaunchMethod::WeztermTab => {
133            launchers::wezterm::launch_tab(path, cmd, ai_tool_name, &tab_label_for(path))?
134        }
135        LaunchMethod::WeztermTabBg => {
136            launchers::wezterm::launch_tab_bg(path, cmd, ai_tool_name, &tab_label_for(path))?
137        }
138        LaunchMethod::WeztermPaneH => {
139            launchers::wezterm::launch_pane(path, cmd, ai_tool_name, true)?
140        }
141        LaunchMethod::WeztermPaneV => {
142            launchers::wezterm::launch_pane(path, cmd, ai_tool_name, false)?
143        }
144    }
145
146    Ok(())
147}
148
149/// Recognized parent-env prefix to auto-forward, keyed by the AI tool's
150/// binary name. Returns `None` for tools we don't have a convention for —
151/// in that case `--no-env-forward` becomes a no-op (nothing to forward).
152///
153/// Accepts a full path (`/usr/local/bin/claude`) or a bare name (`claude`);
154/// the basename is what we match against. `.exe` suffix is stripped so the
155/// same map works on Windows.
156fn auto_forward_prefix(ai_tool_name: &str) -> Option<&'static str> {
157    let stem = std::path::Path::new(ai_tool_name)
158        .file_stem()
159        .and_then(|s| s.to_str())
160        .unwrap_or(ai_tool_name);
161    match stem {
162        "claude" => Some("CLAUDE_"),
163        "codex" => Some("CODEX_"),
164        "gemini" => Some("GEMINI_"),
165        _ => None,
166    }
167}
168
169/// Vars matching `auto_forward_prefix` that we must NOT forward to the
170/// spawned AI tool, because they describe the *parent* invocation context
171/// rather than user-configurable settings.
172///
173/// `CLAUDE_CODE_ENTRYPOINT` is the load-bearing one: when `gw` is invoked
174/// from inside another Claude Code session (e.g. via its Bash tool), the
175/// parent sets `CLAUDE_CODE_ENTRYPOINT=sdk-cli` on subprocess env to mark
176/// them as SDK-context. Forwarding that into the new terminal makes the
177/// freshly-launched `claude` start in SDK/print mode — answers the
178/// trailing-positional prompt once and exits, instead of opening the TUI.
179///
180/// `CLAUDE_CODE_EXECPATH` points at the parent's bundled binary path; it
181/// has no meaning for an independently-launched child claude.
182///
183/// Scope: only keys currently known to alter the *child's* runtime behavior.
184/// Display-only or informational `CLAUDE_*` vars don't need to be listed.
185const CLAUDE_PARENT_CONTEXT_VARS: &[&str] = &["CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_EXECPATH"];
186
187/// Build the env map injected into the spawned AI tool process.
188///
189/// Auto-forwards `<TOOL>_*` vars from the current (gw) process — the shell
190/// that ran `gw` is the source of truth, so launchers like wezterm/iterm/
191/// tmux/zellij (which spawn their own shells inside the window-server's
192/// environment) still see the user's settings. Suppressed when
193/// `no_env_forward` is set or when `auto_forward_prefix` returns `None`
194/// for this tool. See [`CLAUDE_PARENT_CONTEXT_VARS`] for keys that are
195/// stripped even when forwarding is enabled.
196fn build_env_map(ai_tool_name: &str, no_env_forward: bool) -> BTreeMap<String, String> {
197    let mut env = BTreeMap::new();
198    if no_env_forward {
199        return env;
200    }
201    if let Some(prefix) = auto_forward_prefix(ai_tool_name) {
202        for (k, v) in std::env::vars() {
203            if k.starts_with(prefix) && !CLAUDE_PARENT_CONTEXT_VARS.contains(&k.as_str()) {
204                env.insert(k, v);
205            }
206        }
207    }
208    env
209}
210
211/// Launch AI coding assistant in the specified directory.
212pub fn launch_ai_tool(path: &Path, resume: bool, opts: &LaunchOptions<'_>) -> Result<()> {
213    let (method, session_name) = config::resolve_term_option(opts.term_override, path)?;
214
215    // `-T skip|none|noop` (or config method == "skip"): the user explicitly
216    // asked us not to launch anything. Bail before resolving ai-tool config
217    // or PATH-checking the binary so a Skip launch never errors on missing
218    // tooling.
219    if matches!(method, LaunchMethod::Skip) {
220        return Ok(());
221    }
222
223    // Determine command. Resume always injects the tool's `--continue` /
224    // `--resume` even when the user supplied `forward_args` — the user's
225    // intent ("resume this") is the framing of the whole subcommand, and
226    // having it silently dropped because they also passed `--model opus`
227    // would be a footgun.
228    let mut ai_cmd_parts = if resume {
229        get_ai_tool_resume_command_for_cwd(path)?
230    } else if is_claude_tool_for_cwd(path).unwrap_or(false)
231        && session::claude_native_session_exists(path)
232    {
233        println!("Found existing Claude session, using --continue");
234        get_ai_tool_resume_command_for_cwd(path)?
235    } else {
236        get_ai_tool_command_for_cwd(path)?
237    };
238
239    if ai_cmd_parts.is_empty() {
240        return Ok(());
241    }
242
243    // Forward args slot in *between* the preset's args and the (absent here)
244    // prompt — same position as a hand-typed `claude --model opus`.
245    ai_cmd_parts.extend(opts.forward_args.iter().cloned());
246
247    let ai_tool_name = ai_cmd_parts[0].clone();
248
249    if !git::has_command(&ai_tool_name) {
250        println!(
251            "{} {} not detected. Install it or update config with 'gw config set ai-tool <tool>'.\n",
252            style("!").yellow(),
253            ai_tool_name,
254        );
255        return Ok(());
256    }
257
258    let env = build_env_map(&ai_tool_name, opts.no_env_forward);
259
260    // See `spawn_spec` module docstring for why the emitted line is
261    // `gw _spawn-ai <path>` (no `exec` prefix) and how the raw argv flows
262    // through a 0600 temp file rather than the shell line.
263    maybe_inject_guard(&mut ai_cmd_parts, path)?;
264    let spec = SpawnSpec::new(ai_cmd_parts, path.to_path_buf()).with_env(env);
265    // The spec file is cleaned up by `spawn_spec::execute` after read; the 24h
266    // `sweep_stale` at startup is the safety net for crashes between those points.
267    let (cmd, _) = spawn_spec::materialize(&spec)?;
268
269    // Dispatch to launcher. Foreground blocks on the AI process, so an RAII
270    // lockfile spans the full session. Other launchers detach to a terminal
271    // emulator / multiplexer and return immediately, so a lock acquired here
272    // would be released before the AI session really starts — for those we
273    // rely on process-cwd scanning in `busy::detect_busy` instead.
274    dispatch_launch(path, method, session_name, &cmd, ai_tool_name.as_str())
275}
276
277/// Resume AI work in a worktree with context restoration.
278///
279/// Target resolution uses strict ordered rules: exact worktree name → exact branch
280/// name → exact path. When no target is given, the current working directory is used.
281pub fn resume_worktree(worktree: Option<&str>, opts: &LaunchOptions<'_>) -> Result<()> {
282    let (worktree_path, branch_name) = if let Some(target) = worktree {
283        let main_repo = git::get_main_repo_root(None)?;
284        let strict = resolve_target_strict(&main_repo, target)?;
285        let branch_name = strict.branch.unwrap_or_else(|| {
286            strict
287                .path
288                .file_name()
289                .map(|n| n.to_string_lossy().into_owned())
290                .unwrap_or_else(|| "(detached)".into())
291        });
292        (strict.path, branch_name)
293    } else {
294        // No target — use current working directory.
295        let resolved = resolve_worktree_target(None, None)?;
296        (resolved.path, resolved.branch)
297    };
298
299    // Change directory if specified
300    if worktree.is_some() {
301        let _ = std::env::set_current_dir(&worktree_path);
302        println!(
303            "{}\n",
304            style(messages::switched_to_worktree(&worktree_path)).dim()
305        );
306    }
307
308    // Check for existing session
309    let has_session = is_claude_tool_for_cwd(&worktree_path).unwrap_or(false)
310        && session::claude_native_session_exists(&worktree_path);
311
312    if has_session {
313        println!(
314            "{} Found session for branch: {}",
315            style("*").green(),
316            style(&branch_name).bold()
317        );
318
319        if let Some(metadata) = session::load_session_metadata(&branch_name) {
320            println!("  AI tool: {}", style(&metadata.ai_tool).dim());
321            println!("  Last updated: {}", style(&metadata.updated_at).dim());
322        }
323
324        if let Some(context) = session::load_context(&branch_name) {
325            println!("\n{}", style("Previous context:").cyan());
326            println!("{}", style(&context).dim());
327        }
328        println!();
329    } else {
330        println!(
331            "{} No previous session found for branch: {}",
332            style("i").yellow(),
333            style(&branch_name).bold()
334        );
335        println!("{}\n", style("Starting fresh session...").dim());
336    }
337
338    // `gw resume` is an explicit user intent: re-inject the tool's resume
339    // flag (`--continue` for claude, `--resume` for codex/gemini) regardless
340    // of whether a local session file is detected. Native session detection
341    // is best-effort (depends on tool-specific on-disk artefacts that aren't
342    // always present even when the tool itself can resume — e.g. claude's
343    // `.claude/projects/` cache lives under HOME, and a fresh CLAUDE_CONFIG_DIR
344    // hides it). The tool itself knows whether it has anything to resume; if
345    // it doesn't, `--continue` is harmless. Always passing the flag matches
346    // the README's "always re-injects" promise.
347    let ai_cmd = get_ai_tool_resume_command_for_cwd(&worktree_path)?;
348
349    if !ai_cmd.is_empty() {
350        let ai_tool_name = &ai_cmd[0];
351        let _ = session::save_session_metadata(
352            &branch_name,
353            ai_tool_name,
354            &worktree_path.to_string_lossy(),
355        );
356
357        if has_session {
358            println!(
359                "{} {}\n",
360                style(messages::resuming_ai_tool_in(ai_tool_name)).cyan(),
361                worktree_path.display()
362            );
363        } else {
364            println!(
365                "{} {}\n",
366                style(messages::starting_ai_tool_in(ai_tool_name)).cyan(),
367                worktree_path.display()
368            );
369        }
370
371        launch_ai_tool(&worktree_path, true, opts)?;
372    }
373
374    Ok(())
375}
376
377/// Launch the configured AI tool inside an existing worktree.
378///
379/// Used by both `gw new` (after worktree creation) and `gw spawn`. Honors the
380/// resolved launch method (CLI override > env > config > default).
381pub fn spawn_in_worktree(
382    worktree_path: &Path,
383    prompt: Option<&str>,
384    opts: &LaunchOptions<'_>,
385) -> Result<()> {
386    let (method, session_name) = config::resolve_term_option(opts.term_override, worktree_path)?;
387
388    // `-T skip|none|noop`: caller wants the worktree set up without launching
389    // anything. Skip ai-tool resolution + PATH check entirely.
390    if matches!(method, LaunchMethod::Skip) {
391        return Ok(());
392    }
393
394    // `--prompt` and trailing forward args are mutually exclusive: both
395    // ultimately set the AI tool's prompt. Allowing both lets the user
396    // accidentally end up with two prompts (the explicit one plus one
397    // hidden in `forward_args`) — much better to surface this at the CLI
398    // boundary than to guess an ordering.
399    if prompt.is_some() && !opts.forward_args.is_empty() {
400        return Err(CwError::Other(
401            "--prompt / --prompt-file cannot be combined with trailing AI tool args; \
402             pick one or the other"
403                .to_string(),
404        ));
405    }
406
407    // Build the AI tool command:
408    //   <preset args...> <forward_args...> [<prompt>]
409    // The prompt is appended last so the AI tool sees it as the leading
410    // user message (claude/codex/gemini all accept a trailing positional).
411    let mut ai_cmd_parts = get_ai_tool_command_for_cwd(worktree_path)?;
412    if ai_cmd_parts.is_empty() {
413        return Ok(());
414    }
415    ai_cmd_parts.extend(opts.forward_args.iter().cloned());
416    if let Some(p) = prompt {
417        ai_cmd_parts.push(p.to_string());
418    }
419
420    let ai_tool_name = ai_cmd_parts[0].clone();
421
422    if !git::has_command(&ai_tool_name) {
423        println!(
424            "{} {} not detected. Install it or update config with 'gw config set ai-tool <tool>'.\n",
425            style("!").yellow(),
426            ai_tool_name,
427        );
428        return Ok(());
429    }
430
431    let env = build_env_map(&ai_tool_name, opts.no_env_forward);
432
433    maybe_inject_guard(&mut ai_cmd_parts, worktree_path)?;
434    let spec = SpawnSpec::new(ai_cmd_parts, worktree_path.to_path_buf()).with_env(env);
435    let (cmd, _) = spawn_spec::materialize(&spec)?;
436
437    dispatch_launch(
438        worktree_path,
439        method,
440        session_name,
441        &cmd,
442        ai_tool_name.as_str(),
443    )
444}
445
446/// Inject the gw guard PreToolUse(Bash) hook via `--settings` when the
447/// configured AI tool is Claude and `ai_tool.guard` is enabled.
448///
449/// Inserts `--settings <inline-json>` immediately after argv\[0\], leaving
450/// any subsequent positional args (delegate prompts, `--continue`, etc.)
451/// in their original order.
452fn maybe_inject_guard(argv: &mut Vec<String>, cwd: &Path) -> Result<()> {
453    if argv.is_empty() {
454        return Ok(());
455    }
456    if !is_claude_tool_for_cwd(cwd).unwrap_or(false) {
457        return Ok(());
458    }
459    let cfg = load_effective_config(cwd)?;
460    inject_guard_into_argv(argv, cfg.ai_tool.guard)
461}
462
463/// Pure-data version of `maybe_inject_guard`: decides injection from the
464/// already-resolved `guard` flag, leaving config and tool-detection to the
465/// caller. Kept separate so unit tests can exercise the argv mutation
466/// without driving the config loader.
467fn inject_guard_into_argv(argv: &mut Vec<String>, guard_enabled: bool) -> Result<()> {
468    if !guard_enabled || argv.is_empty() {
469        return Ok(());
470    }
471    let json = claude_settings::guard_settings_json()?;
472    argv.insert(1, "--settings".to_string());
473    argv.insert(2, json);
474    Ok(())
475}
476
477/// Worktree directory basename, or a stable fallback for rootless paths.
478fn dir_name_of(path: &Path) -> String {
479    path.file_name()
480        .map(|n| n.to_string_lossy().to_string())
481        .unwrap_or_else(|| "worktree".to_string())
482}
483
484/// Truncate to at most `MAX_SESSION_NAME_LENGTH` characters, never splitting a
485/// codepoint (tmux/zellij names and sockets are byte-bounded, but a panic on a
486/// multi-byte boundary is worse than a slightly-short label).
487fn cap_session_len(s: String) -> String {
488    if s.chars().count() > MAX_SESSION_NAME_LENGTH {
489        s.chars().take(MAX_SESSION_NAME_LENGTH).collect()
490    } else {
491        s
492    }
493}
494
495/// Derive a tab/window label for terminal multiplexers from the worktree
496/// directory name. Sanitized the same way branch-derived names are, and
497/// capped at `MAX_SESSION_NAME_LENGTH` so tmux/zellij don't choke on it.
498fn tab_label_for(path: &Path) -> String {
499    cap_session_len(crate::constants::sanitize_branch_name(&dir_name_of(path)))
500}
501
502/// Generate a session name from path with length limit.
503fn generate_session_name(path: &Path) -> String {
504    let config = config::load_config().unwrap_or_default();
505    let prefix = &config.launch.tmux_session_prefix;
506    cap_session_len(format!("{}-{}", prefix, dir_name_of(path)))
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512    use crate::operations::test_env::env_lock;
513
514    /// Resolve --settings JSON to the index immediately following argv[0].
515    /// Returns (settings_json, remainder_argv_excluding_inserted_flag_pair).
516    fn extract_settings(argv: &[String]) -> Option<String> {
517        let pos = argv.iter().position(|s| s == "--settings")?;
518        argv.get(pos + 1).cloned()
519    }
520
521    fn with_self_exe<F: FnOnce()>(f: F) {
522        // Hook commands no longer interpolate a path — `guard_settings_json`
523        // emits the bare `gw` name. We still hold the env lock so other env-
524        // mutating tests in this crate stay serialised relative to us.
525        let _lock = env_lock();
526        f();
527    }
528
529    #[test]
530    fn tab_label_uses_sanitized_dir_name() {
531        assert_eq!(
532            tab_label_for(Path::new("/tmp/repo-feat-auth")),
533            "repo-feat-auth"
534        );
535        // Path components are already sanitized on disk, but defensive: a
536        // name with odd chars still comes back hyphen-safe.
537        assert_eq!(tab_label_for(Path::new("/tmp/odd name@v1")), "odd-name-v1");
538        // Empty / rootless paths fall back to a stable default.
539        assert_eq!(tab_label_for(Path::new("/")), "worktree");
540    }
541
542    #[test]
543    fn tab_label_caps_at_max_session_length() {
544        let long = "a".repeat(MAX_SESSION_NAME_LENGTH + 20);
545        let label = tab_label_for(Path::new(&format!("/tmp/{long}")));
546        assert_eq!(label.chars().count(), MAX_SESSION_NAME_LENGTH);
547    }
548
549    #[test]
550    fn injects_settings_after_argv0_when_enabled() {
551        with_self_exe(|| {
552            let mut argv = vec!["claude".to_string()];
553            inject_guard_into_argv(&mut argv, true).unwrap();
554            assert_eq!(argv[0], "claude");
555            assert_eq!(argv[1], "--settings");
556            assert_eq!(argv.len(), 3);
557            let v: serde_json::Value =
558                serde_json::from_str(&argv[2]).expect("settings json parses");
559            assert_eq!(v["hooks"]["PreToolUse"][0]["matcher"], "Bash");
560        });
561    }
562
563    #[test]
564    fn noop_when_guard_disabled() {
565        with_self_exe(|| {
566            let mut argv = vec!["claude".to_string(), "--continue".to_string()];
567            inject_guard_into_argv(&mut argv, false).unwrap();
568            assert_eq!(argv, vec!["claude", "--continue"]);
569        });
570    }
571
572    #[test]
573    fn noop_when_argv_empty() {
574        with_self_exe(|| {
575            let mut argv: Vec<String> = vec![];
576            inject_guard_into_argv(&mut argv, true).unwrap();
577            assert!(argv.is_empty());
578        });
579    }
580
581    #[test]
582    fn preserves_trailing_continue_flag() {
583        with_self_exe(|| {
584            let mut argv = vec!["claude".to_string(), "--continue".to_string()];
585            inject_guard_into_argv(&mut argv, true).unwrap();
586            assert_eq!(argv[0], "claude");
587            assert_eq!(argv[1], "--settings");
588            assert!(extract_settings(&argv).is_some());
589            assert_eq!(argv[3], "--continue");
590        });
591    }
592
593    #[test]
594    fn preserves_delegate_prompt_at_tail() {
595        with_self_exe(|| {
596            let mut argv = vec!["claude".to_string(), "do this task".to_string()];
597            inject_guard_into_argv(&mut argv, true).unwrap();
598            assert_eq!(argv[0], "claude");
599            assert_eq!(argv[1], "--settings");
600            assert!(extract_settings(&argv).is_some());
601            assert_eq!(argv[3], "do this task");
602        });
603    }
604
605    #[test]
606    fn handles_yolo_skip_permissions_argv() {
607        with_self_exe(|| {
608            let mut argv = vec![
609                "claude".to_string(),
610                "--dangerously-skip-permissions".to_string(),
611            ];
612            inject_guard_into_argv(&mut argv, true).unwrap();
613            // --settings goes right after argv[0], skip-permissions stays at the end
614            assert_eq!(argv[0], "claude");
615            assert_eq!(argv[1], "--settings");
616            assert_eq!(argv[3], "--dangerously-skip-permissions");
617        });
618    }
619
620    #[test]
621    fn auto_forward_prefix_known_tools() {
622        assert_eq!(auto_forward_prefix("claude"), Some("CLAUDE_"));
623        assert_eq!(auto_forward_prefix("codex"), Some("CODEX_"));
624        assert_eq!(auto_forward_prefix("gemini"), Some("GEMINI_"));
625        assert_eq!(auto_forward_prefix("unknown-tool"), None);
626    }
627
628    #[test]
629    fn auto_forward_prefix_strips_path_and_extension() {
630        // Users often configure CW_AI_TOOL with an absolute path. The
631        // prefix lookup must still match by basename, including on Windows
632        // where the binary is `claude.exe`.
633        assert_eq!(
634            auto_forward_prefix("/usr/local/bin/claude"),
635            Some("CLAUDE_")
636        );
637        assert_eq!(auto_forward_prefix("./claude"), Some("CLAUDE_"));
638        assert_eq!(auto_forward_prefix("/opt/codex"), Some("CODEX_"));
639        assert_eq!(auto_forward_prefix("claude.exe"), Some("CLAUDE_"));
640    }
641
642    #[test]
643    fn build_env_map_picks_up_prefix_match() {
644        std::env::set_var("CLAUDE_FOO_TEST_PICKUP", "from-parent");
645        let env = build_env_map("claude", false);
646        assert_eq!(
647            env.get("CLAUDE_FOO_TEST_PICKUP").map(String::as_str),
648            Some("from-parent"),
649            "CLAUDE_* var must auto-forward when no_env_forward=false"
650        );
651        std::env::remove_var("CLAUDE_FOO_TEST_PICKUP");
652    }
653
654    #[test]
655    fn build_env_map_no_env_forward_skips_auto() {
656        std::env::set_var("CLAUDE_FOO_TEST_NO_FWD", "from-parent");
657        let env = build_env_map("claude", true);
658        assert!(
659            !env.contains_key("CLAUDE_FOO_TEST_NO_FWD"),
660            "auto-forward must be suppressed by no_env_forward"
661        );
662        std::env::remove_var("CLAUDE_FOO_TEST_NO_FWD");
663    }
664
665    #[test]
666    fn build_env_map_unknown_tool_no_auto() {
667        std::env::set_var("CLAUDE_FOO_TEST_UNK", "from-parent");
668        let env = build_env_map("unknown-tool", false);
669        assert!(env.is_empty());
670        std::env::remove_var("CLAUDE_FOO_TEST_UNK");
671    }
672
673    #[test]
674    fn build_env_map_strips_parent_context_vars() {
675        // When gw runs inside a parent Claude Code's Bash tool, the parent
676        // exports CLAUDE_CODE_ENTRYPOINT=sdk-cli into the subprocess. If we
677        // forward it, the launched terminal's claude inherits the SDK label
678        // and answers the prompt in print mode instead of opening the TUI.
679        std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "sdk-cli");
680        std::env::set_var("CLAUDE_CODE_EXECPATH", "/parent/bundle/path");
681        std::env::set_var("CLAUDE_FOO_TEST_KEEP", "from-parent");
682        let env = build_env_map("claude", false);
683        assert!(
684            !env.contains_key("CLAUDE_CODE_ENTRYPOINT"),
685            "CLAUDE_CODE_ENTRYPOINT must be stripped to avoid forcing SDK/print mode in the child"
686        );
687        assert!(
688            !env.contains_key("CLAUDE_CODE_EXECPATH"),
689            "CLAUDE_CODE_EXECPATH points at the parent's binary; do not forward"
690        );
691        assert_eq!(
692            env.get("CLAUDE_FOO_TEST_KEEP").map(String::as_str),
693            Some("from-parent"),
694            "unrelated CLAUDE_* vars must still forward"
695        );
696        std::env::remove_var("CLAUDE_CODE_ENTRYPOINT");
697        std::env::remove_var("CLAUDE_CODE_EXECPATH");
698        std::env::remove_var("CLAUDE_FOO_TEST_KEEP");
699    }
700}