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, get_ai_tool_resume_command, is_claude_tool, 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 => launchers::tmux::launch_window(path, cmd, ai_tool_name)?,
112        LaunchMethod::TmuxPaneH => launchers::tmux::launch_pane(path, cmd, ai_tool_name, true)?,
113        LaunchMethod::TmuxPaneV => launchers::tmux::launch_pane(path, cmd, ai_tool_name, false)?,
114        // Zellij
115        LaunchMethod::Zellij => {
116            let sn = session_name.unwrap_or_else(|| generate_session_name(path));
117            launchers::zellij::launch_session(path, cmd, ai_tool_name, &sn)?;
118        }
119        LaunchMethod::ZellijTab => launchers::zellij::launch_tab(path, cmd, ai_tool_name)?,
120        LaunchMethod::ZellijPaneH => launchers::zellij::launch_pane(path, cmd, ai_tool_name, true)?,
121        LaunchMethod::ZellijPaneV => {
122            launchers::zellij::launch_pane(path, cmd, ai_tool_name, false)?
123        }
124        // WezTerm
125        LaunchMethod::WeztermWindow => launchers::wezterm::launch_window(path, cmd, ai_tool_name)?,
126        LaunchMethod::WeztermTab => launchers::wezterm::launch_tab(path, cmd, ai_tool_name)?,
127        LaunchMethod::WeztermTabBg => launchers::wezterm::launch_tab_bg(path, cmd, ai_tool_name)?,
128        LaunchMethod::WeztermPaneH => {
129            launchers::wezterm::launch_pane(path, cmd, ai_tool_name, true)?
130        }
131        LaunchMethod::WeztermPaneV => {
132            launchers::wezterm::launch_pane(path, cmd, ai_tool_name, false)?
133        }
134    }
135
136    Ok(())
137}
138
139/// Recognized parent-env prefix to auto-forward, keyed by the AI tool's
140/// binary name. Returns `None` for tools we don't have a convention for —
141/// in that case `--no-env-forward` becomes a no-op (nothing to forward).
142///
143/// Accepts a full path (`/usr/local/bin/claude`) or a bare name (`claude`);
144/// the basename is what we match against. `.exe` suffix is stripped so the
145/// same map works on Windows.
146fn auto_forward_prefix(ai_tool_name: &str) -> Option<&'static str> {
147    let stem = std::path::Path::new(ai_tool_name)
148        .file_stem()
149        .and_then(|s| s.to_str())
150        .unwrap_or(ai_tool_name);
151    match stem {
152        "claude" => Some("CLAUDE_"),
153        "codex" => Some("CODEX_"),
154        "gemini" => Some("GEMINI_"),
155        _ => None,
156    }
157}
158
159/// Build the env map injected into the spawned AI tool process.
160///
161/// Auto-forwards `<TOOL>_*` vars from the current (gw) process — the shell
162/// that ran `gw` is the source of truth, so launchers like wezterm/iterm/
163/// tmux/zellij (which spawn their own shells inside the window-server's
164/// environment) still see the user's settings. Suppressed when
165/// `no_env_forward` is set or when `auto_forward_prefix` returns `None`
166/// for this tool.
167fn build_env_map(ai_tool_name: &str, no_env_forward: bool) -> BTreeMap<String, String> {
168    let mut env = BTreeMap::new();
169    if no_env_forward {
170        return env;
171    }
172    if let Some(prefix) = auto_forward_prefix(ai_tool_name) {
173        for (k, v) in std::env::vars() {
174            if k.starts_with(prefix) {
175                env.insert(k, v);
176            }
177        }
178    }
179    env
180}
181
182/// Launch AI coding assistant in the specified directory.
183pub fn launch_ai_tool(path: &Path, resume: bool, opts: &LaunchOptions<'_>) -> Result<()> {
184    let (method, session_name) = config::resolve_term_option(opts.term_override, path)?;
185
186    // `-T skip|none|noop` (or config method == "skip"): the user explicitly
187    // asked us not to launch anything. Bail before resolving ai-tool config
188    // or PATH-checking the binary so a Skip launch never errors on missing
189    // tooling.
190    if matches!(method, LaunchMethod::Skip) {
191        return Ok(());
192    }
193
194    // Determine command. Resume always injects the tool's `--continue` /
195    // `--resume` even when the user supplied `forward_args` — the user's
196    // intent ("resume this") is the framing of the whole subcommand, and
197    // having it silently dropped because they also passed `--model opus`
198    // would be a footgun.
199    let mut ai_cmd_parts = if resume {
200        get_ai_tool_resume_command()?
201    } else if is_claude_tool().unwrap_or(false) && session::claude_native_session_exists(path) {
202        eprintln!("Found existing Claude session, using --continue");
203        get_ai_tool_resume_command()?
204    } else {
205        get_ai_tool_command()?
206    };
207
208    if ai_cmd_parts.is_empty() {
209        return Ok(());
210    }
211
212    // Forward args slot in *between* the preset's args and the (absent here)
213    // prompt — same position as a hand-typed `claude --model opus`.
214    ai_cmd_parts.extend(opts.forward_args.iter().cloned());
215
216    let ai_tool_name = ai_cmd_parts[0].clone();
217
218    if !git::has_command(&ai_tool_name) {
219        println!(
220            "{} {} not detected. Install it or update config with 'gw config set ai-tool <tool>'.\n",
221            style("!").yellow(),
222            ai_tool_name,
223        );
224        return Ok(());
225    }
226
227    let env = build_env_map(&ai_tool_name, opts.no_env_forward);
228
229    // See `spawn_spec` module docstring for why the emitted line is
230    // `gw _spawn-ai <path>` (no `exec` prefix) and how the raw argv flows
231    // through a 0600 temp file rather than the shell line.
232    maybe_inject_guard(&mut ai_cmd_parts, path)?;
233    let spec = SpawnSpec::new(ai_cmd_parts, path.to_path_buf()).with_env(env);
234    // The spec file is cleaned up by `spawn_spec::execute` after read; the 24h
235    // `sweep_stale` at startup is the safety net for crashes between those points.
236    let (cmd, _) = spawn_spec::materialize(&spec)?;
237
238    // Dispatch to launcher. Foreground blocks on the AI process, so an RAII
239    // lockfile spans the full session. Other launchers detach to a terminal
240    // emulator / multiplexer and return immediately, so a lock acquired here
241    // would be released before the AI session really starts — for those we
242    // rely on process-cwd scanning in `busy::detect_busy` instead.
243    dispatch_launch(path, method, session_name, &cmd, ai_tool_name.as_str())
244}
245
246/// Resume AI work in a worktree with context restoration.
247///
248/// Target resolution uses strict ordered rules: exact worktree name → exact branch
249/// name → exact path. When no target is given, the current working directory is used.
250pub fn resume_worktree(worktree: Option<&str>, opts: &LaunchOptions<'_>) -> Result<()> {
251    let (worktree_path, branch_name) = if let Some(target) = worktree {
252        let main_repo = git::get_main_repo_root(None)?;
253        let strict = resolve_target_strict(&main_repo, target)?;
254        let branch_name = strict.branch.unwrap_or_else(|| {
255            strict
256                .path
257                .file_name()
258                .map(|n| n.to_string_lossy().into_owned())
259                .unwrap_or_else(|| "(detached)".into())
260        });
261        (strict.path, branch_name)
262    } else {
263        // No target — use current working directory.
264        let resolved = resolve_worktree_target(None, None)?;
265        (resolved.path, resolved.branch)
266    };
267
268    // Change directory if specified
269    if worktree.is_some() {
270        let _ = std::env::set_current_dir(&worktree_path);
271        println!(
272            "{}\n",
273            style(messages::switched_to_worktree(&worktree_path)).dim()
274        );
275    }
276
277    // Check for existing session
278    let has_session =
279        is_claude_tool().unwrap_or(false) && session::claude_native_session_exists(&worktree_path);
280
281    if has_session {
282        println!(
283            "{} Found session for branch: {}",
284            style("*").green(),
285            style(&branch_name).bold()
286        );
287
288        if let Some(metadata) = session::load_session_metadata(&branch_name) {
289            println!("  AI tool: {}", style(&metadata.ai_tool).dim());
290            println!("  Last updated: {}", style(&metadata.updated_at).dim());
291        }
292
293        if let Some(context) = session::load_context(&branch_name) {
294            println!("\n{}", style("Previous context:").cyan());
295            println!("{}", style(&context).dim());
296        }
297        println!();
298    } else {
299        println!(
300            "{} No previous session found for branch: {}",
301            style("i").yellow(),
302            style(&branch_name).bold()
303        );
304        println!("{}\n", style("Starting fresh session...").dim());
305    }
306
307    // `gw resume` is an explicit user intent: re-inject the tool's resume
308    // flag (`--continue` for claude, `--resume` for codex/gemini) regardless
309    // of whether a local session file is detected. Native session detection
310    // is best-effort (depends on tool-specific on-disk artefacts that aren't
311    // always present even when the tool itself can resume — e.g. claude's
312    // `.claude/projects/` cache lives under HOME, and a fresh CLAUDE_CONFIG_DIR
313    // hides it). The tool itself knows whether it has anything to resume; if
314    // it doesn't, `--continue` is harmless. Always passing the flag matches
315    // the README's "always re-injects" promise.
316    let ai_cmd = get_ai_tool_resume_command()?;
317
318    if !ai_cmd.is_empty() {
319        let ai_tool_name = &ai_cmd[0];
320        let _ = session::save_session_metadata(
321            &branch_name,
322            ai_tool_name,
323            &worktree_path.to_string_lossy(),
324        );
325
326        if has_session {
327            println!(
328                "{} {}\n",
329                style(messages::resuming_ai_tool_in(ai_tool_name)).cyan(),
330                worktree_path.display()
331            );
332        } else {
333            println!(
334                "{} {}\n",
335                style(messages::starting_ai_tool_in(ai_tool_name)).cyan(),
336                worktree_path.display()
337            );
338        }
339
340        launch_ai_tool(&worktree_path, true, opts)?;
341    }
342
343    Ok(())
344}
345
346/// Launch the configured AI tool inside an existing worktree.
347///
348/// Used by both `gw new` (after worktree creation) and `gw spawn`. Honors the
349/// resolved launch method (CLI override > env > config > default).
350pub fn spawn_in_worktree(
351    worktree_path: &Path,
352    prompt: Option<&str>,
353    opts: &LaunchOptions<'_>,
354) -> Result<()> {
355    let (method, session_name) = config::resolve_term_option(opts.term_override, worktree_path)?;
356
357    // `-T skip|none|noop`: caller wants the worktree set up without launching
358    // anything. Skip ai-tool resolution + PATH check entirely.
359    if matches!(method, LaunchMethod::Skip) {
360        return Ok(());
361    }
362
363    // `--prompt` and trailing forward args are mutually exclusive: both
364    // ultimately set the AI tool's prompt. Allowing both lets the user
365    // accidentally end up with two prompts (the explicit one plus one
366    // hidden in `forward_args`) — much better to surface this at the CLI
367    // boundary than to guess an ordering.
368    if prompt.is_some() && !opts.forward_args.is_empty() {
369        return Err(CwError::Other(
370            "--prompt / --prompt-file cannot be combined with trailing AI tool args; \
371             pick one or the other"
372                .to_string(),
373        ));
374    }
375
376    // Build the AI tool command:
377    //   <preset args...> <forward_args...> [<prompt>]
378    // The prompt is appended last so the AI tool sees it as the leading
379    // user message (claude/codex/gemini all accept a trailing positional).
380    let mut ai_cmd_parts = get_ai_tool_command()?;
381    if ai_cmd_parts.is_empty() {
382        return Ok(());
383    }
384    ai_cmd_parts.extend(opts.forward_args.iter().cloned());
385    if let Some(p) = prompt {
386        ai_cmd_parts.push(p.to_string());
387    }
388
389    let ai_tool_name = ai_cmd_parts[0].clone();
390
391    if !git::has_command(&ai_tool_name) {
392        println!(
393            "{} {} not detected. Install it or update config with 'gw config set ai-tool <tool>'.\n",
394            style("!").yellow(),
395            ai_tool_name,
396        );
397        return Ok(());
398    }
399
400    let env = build_env_map(&ai_tool_name, opts.no_env_forward);
401
402    maybe_inject_guard(&mut ai_cmd_parts, worktree_path)?;
403    let spec = SpawnSpec::new(ai_cmd_parts, worktree_path.to_path_buf()).with_env(env);
404    let (cmd, _) = spawn_spec::materialize(&spec)?;
405
406    dispatch_launch(
407        worktree_path,
408        method,
409        session_name,
410        &cmd,
411        ai_tool_name.as_str(),
412    )
413}
414
415/// Inject the gw guard PreToolUse(Bash) hook via `--settings` when the
416/// configured AI tool is Claude and `ai_tool.guard` is enabled.
417///
418/// Inserts `--settings <inline-json>` immediately after argv\[0\], leaving
419/// any subsequent positional args (delegate prompts, `--continue`, etc.)
420/// in their original order.
421fn maybe_inject_guard(argv: &mut Vec<String>, cwd: &Path) -> Result<()> {
422    if argv.is_empty() {
423        return Ok(());
424    }
425    if !is_claude_tool_for_cwd(cwd).unwrap_or(false) {
426        return Ok(());
427    }
428    let cfg = load_effective_config(cwd)?;
429    inject_guard_into_argv(argv, cfg.ai_tool.guard)
430}
431
432/// Pure-data version of `maybe_inject_guard`: decides injection from the
433/// already-resolved `guard` flag, leaving config and tool-detection to the
434/// caller. Kept separate so unit tests can exercise the argv mutation
435/// without driving the config loader.
436fn inject_guard_into_argv(argv: &mut Vec<String>, guard_enabled: bool) -> Result<()> {
437    if !guard_enabled || argv.is_empty() {
438        return Ok(());
439    }
440    let json = claude_settings::guard_settings_json()?;
441    argv.insert(1, "--settings".to_string());
442    argv.insert(2, json);
443    Ok(())
444}
445
446/// Generate a session name from path with length limit.
447fn generate_session_name(path: &Path) -> String {
448    let config = config::load_config().unwrap_or_default();
449    let prefix = &config.launch.tmux_session_prefix;
450    let dir_name = path
451        .file_name()
452        .map(|n| n.to_string_lossy().to_string())
453        .unwrap_or_else(|| "worktree".to_string());
454
455    let name = format!("{}-{}", prefix, dir_name);
456    if name.len() > MAX_SESSION_NAME_LENGTH {
457        name[..MAX_SESSION_NAME_LENGTH].to_string()
458    } else {
459        name
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use crate::operations::test_env::{env_lock, EnvGuard};
467
468    /// Resolve --settings JSON to the index immediately following argv[0].
469    /// Returns (settings_json, remainder_argv_excluding_inserted_flag_pair).
470    fn extract_settings(argv: &[String]) -> Option<String> {
471        let pos = argv.iter().position(|s| s == "--settings")?;
472        argv.get(pos + 1).cloned()
473    }
474
475    fn with_self_exe<F: FnOnce()>(f: F) {
476        let _lock = env_lock();
477        let _guard = EnvGuard::capture(&["CW_SPAWN_AI_BIN"]);
478        std::env::set_var("CW_SPAWN_AI_BIN", "/usr/local/bin/gw");
479        f();
480    }
481
482    #[test]
483    fn injects_settings_after_argv0_when_enabled() {
484        with_self_exe(|| {
485            let mut argv = vec!["claude".to_string()];
486            inject_guard_into_argv(&mut argv, true).unwrap();
487            assert_eq!(argv[0], "claude");
488            assert_eq!(argv[1], "--settings");
489            assert_eq!(argv.len(), 3);
490            let v: serde_json::Value =
491                serde_json::from_str(&argv[2]).expect("settings json parses");
492            assert_eq!(v["hooks"]["PreToolUse"][0]["matcher"], "Bash");
493        });
494    }
495
496    #[test]
497    fn noop_when_guard_disabled() {
498        with_self_exe(|| {
499            let mut argv = vec!["claude".to_string(), "--continue".to_string()];
500            inject_guard_into_argv(&mut argv, false).unwrap();
501            assert_eq!(argv, vec!["claude", "--continue"]);
502        });
503    }
504
505    #[test]
506    fn noop_when_argv_empty() {
507        with_self_exe(|| {
508            let mut argv: Vec<String> = vec![];
509            inject_guard_into_argv(&mut argv, true).unwrap();
510            assert!(argv.is_empty());
511        });
512    }
513
514    #[test]
515    fn preserves_trailing_continue_flag() {
516        with_self_exe(|| {
517            let mut argv = vec!["claude".to_string(), "--continue".to_string()];
518            inject_guard_into_argv(&mut argv, true).unwrap();
519            assert_eq!(argv[0], "claude");
520            assert_eq!(argv[1], "--settings");
521            assert!(extract_settings(&argv).is_some());
522            assert_eq!(argv[3], "--continue");
523        });
524    }
525
526    #[test]
527    fn preserves_delegate_prompt_at_tail() {
528        with_self_exe(|| {
529            let mut argv = vec!["claude".to_string(), "do this task".to_string()];
530            inject_guard_into_argv(&mut argv, true).unwrap();
531            assert_eq!(argv[0], "claude");
532            assert_eq!(argv[1], "--settings");
533            assert!(extract_settings(&argv).is_some());
534            assert_eq!(argv[3], "do this task");
535        });
536    }
537
538    #[test]
539    fn handles_yolo_skip_permissions_argv() {
540        with_self_exe(|| {
541            let mut argv = vec![
542                "claude".to_string(),
543                "--dangerously-skip-permissions".to_string(),
544            ];
545            inject_guard_into_argv(&mut argv, true).unwrap();
546            // --settings goes right after argv[0], skip-permissions stays at the end
547            assert_eq!(argv[0], "claude");
548            assert_eq!(argv[1], "--settings");
549            assert_eq!(argv[3], "--dangerously-skip-permissions");
550        });
551    }
552
553    #[test]
554    fn auto_forward_prefix_known_tools() {
555        assert_eq!(auto_forward_prefix("claude"), Some("CLAUDE_"));
556        assert_eq!(auto_forward_prefix("codex"), Some("CODEX_"));
557        assert_eq!(auto_forward_prefix("gemini"), Some("GEMINI_"));
558        assert_eq!(auto_forward_prefix("unknown-tool"), None);
559    }
560
561    #[test]
562    fn auto_forward_prefix_strips_path_and_extension() {
563        // Users often configure CW_AI_TOOL with an absolute path. The
564        // prefix lookup must still match by basename, including on Windows
565        // where the binary is `claude.exe`.
566        assert_eq!(
567            auto_forward_prefix("/usr/local/bin/claude"),
568            Some("CLAUDE_")
569        );
570        assert_eq!(auto_forward_prefix("./claude"), Some("CLAUDE_"));
571        assert_eq!(auto_forward_prefix("/opt/codex"), Some("CODEX_"));
572        assert_eq!(auto_forward_prefix("claude.exe"), Some("CLAUDE_"));
573    }
574
575    #[test]
576    fn build_env_map_picks_up_prefix_match() {
577        std::env::set_var("CLAUDE_FOO_TEST_PICKUP", "from-parent");
578        let env = build_env_map("claude", false);
579        assert_eq!(
580            env.get("CLAUDE_FOO_TEST_PICKUP").map(String::as_str),
581            Some("from-parent"),
582            "CLAUDE_* var must auto-forward when no_env_forward=false"
583        );
584        std::env::remove_var("CLAUDE_FOO_TEST_PICKUP");
585    }
586
587    #[test]
588    fn build_env_map_no_env_forward_skips_auto() {
589        std::env::set_var("CLAUDE_FOO_TEST_NO_FWD", "from-parent");
590        let env = build_env_map("claude", true);
591        assert!(
592            !env.contains_key("CLAUDE_FOO_TEST_NO_FWD"),
593            "auto-forward must be suppressed by no_env_forward"
594        );
595        std::env::remove_var("CLAUDE_FOO_TEST_NO_FWD");
596    }
597
598    #[test]
599    fn build_env_map_unknown_tool_no_auto() {
600        std::env::set_var("CLAUDE_FOO_TEST_UNK", "from-parent");
601        let env = build_env_map("unknown-tool", false);
602        assert!(env.is_empty());
603        std::env::remove_var("CLAUDE_FOO_TEST_UNK");
604    }
605}