1use 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#[derive(Debug, Default, Clone)]
28pub struct LaunchOptions<'a> {
29 pub term_override: Option<&'a str>,
31 pub forward_args: &'a [String],
34 pub no_env_forward: bool,
36}
37
38impl<'a> LaunchOptions<'a> {
39 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
49fn 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 }
67 LaunchMethod::Foreground => {
68 println!(
69 "{}\n",
70 style(messages::starting_ai_tool_foreground(ai_tool_name)).cyan()
71 );
72 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 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 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 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 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
139fn 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
159const CLAUDE_PARENT_CONTEXT_VARS: &[&str] = &["CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_EXECPATH"];
176
177fn build_env_map(ai_tool_name: &str, no_env_forward: bool) -> BTreeMap<String, String> {
187 let mut env = BTreeMap::new();
188 if no_env_forward {
189 return env;
190 }
191 if let Some(prefix) = auto_forward_prefix(ai_tool_name) {
192 for (k, v) in std::env::vars() {
193 if k.starts_with(prefix) && !CLAUDE_PARENT_CONTEXT_VARS.contains(&k.as_str()) {
194 env.insert(k, v);
195 }
196 }
197 }
198 env
199}
200
201pub fn launch_ai_tool(path: &Path, resume: bool, opts: &LaunchOptions<'_>) -> Result<()> {
203 let (method, session_name) = config::resolve_term_option(opts.term_override, path)?;
204
205 if matches!(method, LaunchMethod::Skip) {
210 return Ok(());
211 }
212
213 let mut ai_cmd_parts = if resume {
219 get_ai_tool_resume_command()?
220 } else if is_claude_tool().unwrap_or(false) && session::claude_native_session_exists(path) {
221 eprintln!("Found existing Claude session, using --continue");
222 get_ai_tool_resume_command()?
223 } else {
224 get_ai_tool_command()?
225 };
226
227 if ai_cmd_parts.is_empty() {
228 return Ok(());
229 }
230
231 ai_cmd_parts.extend(opts.forward_args.iter().cloned());
234
235 let ai_tool_name = ai_cmd_parts[0].clone();
236
237 if !git::has_command(&ai_tool_name) {
238 println!(
239 "{} {} not detected. Install it or update config with 'gw config set ai-tool <tool>'.\n",
240 style("!").yellow(),
241 ai_tool_name,
242 );
243 return Ok(());
244 }
245
246 let env = build_env_map(&ai_tool_name, opts.no_env_forward);
247
248 maybe_inject_guard(&mut ai_cmd_parts, path)?;
252 let spec = SpawnSpec::new(ai_cmd_parts, path.to_path_buf()).with_env(env);
253 let (cmd, _) = spawn_spec::materialize(&spec)?;
256
257 dispatch_launch(path, method, session_name, &cmd, ai_tool_name.as_str())
263}
264
265pub fn resume_worktree(worktree: Option<&str>, opts: &LaunchOptions<'_>) -> Result<()> {
270 let (worktree_path, branch_name) = if let Some(target) = worktree {
271 let main_repo = git::get_main_repo_root(None)?;
272 let strict = resolve_target_strict(&main_repo, target)?;
273 let branch_name = strict.branch.unwrap_or_else(|| {
274 strict
275 .path
276 .file_name()
277 .map(|n| n.to_string_lossy().into_owned())
278 .unwrap_or_else(|| "(detached)".into())
279 });
280 (strict.path, branch_name)
281 } else {
282 let resolved = resolve_worktree_target(None, None)?;
284 (resolved.path, resolved.branch)
285 };
286
287 if worktree.is_some() {
289 let _ = std::env::set_current_dir(&worktree_path);
290 println!(
291 "{}\n",
292 style(messages::switched_to_worktree(&worktree_path)).dim()
293 );
294 }
295
296 let has_session =
298 is_claude_tool().unwrap_or(false) && session::claude_native_session_exists(&worktree_path);
299
300 if has_session {
301 println!(
302 "{} Found session for branch: {}",
303 style("*").green(),
304 style(&branch_name).bold()
305 );
306
307 if let Some(metadata) = session::load_session_metadata(&branch_name) {
308 println!(" AI tool: {}", style(&metadata.ai_tool).dim());
309 println!(" Last updated: {}", style(&metadata.updated_at).dim());
310 }
311
312 if let Some(context) = session::load_context(&branch_name) {
313 println!("\n{}", style("Previous context:").cyan());
314 println!("{}", style(&context).dim());
315 }
316 println!();
317 } else {
318 println!(
319 "{} No previous session found for branch: {}",
320 style("i").yellow(),
321 style(&branch_name).bold()
322 );
323 println!("{}\n", style("Starting fresh session...").dim());
324 }
325
326 let ai_cmd = get_ai_tool_resume_command()?;
336
337 if !ai_cmd.is_empty() {
338 let ai_tool_name = &ai_cmd[0];
339 let _ = session::save_session_metadata(
340 &branch_name,
341 ai_tool_name,
342 &worktree_path.to_string_lossy(),
343 );
344
345 if has_session {
346 println!(
347 "{} {}\n",
348 style(messages::resuming_ai_tool_in(ai_tool_name)).cyan(),
349 worktree_path.display()
350 );
351 } else {
352 println!(
353 "{} {}\n",
354 style(messages::starting_ai_tool_in(ai_tool_name)).cyan(),
355 worktree_path.display()
356 );
357 }
358
359 launch_ai_tool(&worktree_path, true, opts)?;
360 }
361
362 Ok(())
363}
364
365pub fn spawn_in_worktree(
370 worktree_path: &Path,
371 prompt: Option<&str>,
372 opts: &LaunchOptions<'_>,
373) -> Result<()> {
374 let (method, session_name) = config::resolve_term_option(opts.term_override, worktree_path)?;
375
376 if matches!(method, LaunchMethod::Skip) {
379 return Ok(());
380 }
381
382 if prompt.is_some() && !opts.forward_args.is_empty() {
388 return Err(CwError::Other(
389 "--prompt / --prompt-file cannot be combined with trailing AI tool args; \
390 pick one or the other"
391 .to_string(),
392 ));
393 }
394
395 let mut ai_cmd_parts = get_ai_tool_command()?;
400 if ai_cmd_parts.is_empty() {
401 return Ok(());
402 }
403 ai_cmd_parts.extend(opts.forward_args.iter().cloned());
404 if let Some(p) = prompt {
405 ai_cmd_parts.push(p.to_string());
406 }
407
408 let ai_tool_name = ai_cmd_parts[0].clone();
409
410 if !git::has_command(&ai_tool_name) {
411 println!(
412 "{} {} not detected. Install it or update config with 'gw config set ai-tool <tool>'.\n",
413 style("!").yellow(),
414 ai_tool_name,
415 );
416 return Ok(());
417 }
418
419 let env = build_env_map(&ai_tool_name, opts.no_env_forward);
420
421 maybe_inject_guard(&mut ai_cmd_parts, worktree_path)?;
422 let spec = SpawnSpec::new(ai_cmd_parts, worktree_path.to_path_buf()).with_env(env);
423 let (cmd, _) = spawn_spec::materialize(&spec)?;
424
425 dispatch_launch(
426 worktree_path,
427 method,
428 session_name,
429 &cmd,
430 ai_tool_name.as_str(),
431 )
432}
433
434fn maybe_inject_guard(argv: &mut Vec<String>, cwd: &Path) -> Result<()> {
441 if argv.is_empty() {
442 return Ok(());
443 }
444 if !is_claude_tool_for_cwd(cwd).unwrap_or(false) {
445 return Ok(());
446 }
447 let cfg = load_effective_config(cwd)?;
448 inject_guard_into_argv(argv, cfg.ai_tool.guard)
449}
450
451fn inject_guard_into_argv(argv: &mut Vec<String>, guard_enabled: bool) -> Result<()> {
456 if !guard_enabled || argv.is_empty() {
457 return Ok(());
458 }
459 let json = claude_settings::guard_settings_json()?;
460 argv.insert(1, "--settings".to_string());
461 argv.insert(2, json);
462 Ok(())
463}
464
465fn generate_session_name(path: &Path) -> String {
467 let config = config::load_config().unwrap_or_default();
468 let prefix = &config.launch.tmux_session_prefix;
469 let dir_name = path
470 .file_name()
471 .map(|n| n.to_string_lossy().to_string())
472 .unwrap_or_else(|| "worktree".to_string());
473
474 let name = format!("{}-{}", prefix, dir_name);
475 if name.len() > MAX_SESSION_NAME_LENGTH {
476 name[..MAX_SESSION_NAME_LENGTH].to_string()
477 } else {
478 name
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use crate::operations::test_env::{env_lock, EnvGuard};
486
487 fn extract_settings(argv: &[String]) -> Option<String> {
490 let pos = argv.iter().position(|s| s == "--settings")?;
491 argv.get(pos + 1).cloned()
492 }
493
494 fn with_self_exe<F: FnOnce()>(f: F) {
495 let _lock = env_lock();
496 let _guard = EnvGuard::capture(&["CW_SPAWN_AI_BIN"]);
497 std::env::set_var("CW_SPAWN_AI_BIN", "/usr/local/bin/gw");
498 f();
499 }
500
501 #[test]
502 fn injects_settings_after_argv0_when_enabled() {
503 with_self_exe(|| {
504 let mut argv = vec!["claude".to_string()];
505 inject_guard_into_argv(&mut argv, true).unwrap();
506 assert_eq!(argv[0], "claude");
507 assert_eq!(argv[1], "--settings");
508 assert_eq!(argv.len(), 3);
509 let v: serde_json::Value =
510 serde_json::from_str(&argv[2]).expect("settings json parses");
511 assert_eq!(v["hooks"]["PreToolUse"][0]["matcher"], "Bash");
512 });
513 }
514
515 #[test]
516 fn noop_when_guard_disabled() {
517 with_self_exe(|| {
518 let mut argv = vec!["claude".to_string(), "--continue".to_string()];
519 inject_guard_into_argv(&mut argv, false).unwrap();
520 assert_eq!(argv, vec!["claude", "--continue"]);
521 });
522 }
523
524 #[test]
525 fn noop_when_argv_empty() {
526 with_self_exe(|| {
527 let mut argv: Vec<String> = vec![];
528 inject_guard_into_argv(&mut argv, true).unwrap();
529 assert!(argv.is_empty());
530 });
531 }
532
533 #[test]
534 fn preserves_trailing_continue_flag() {
535 with_self_exe(|| {
536 let mut argv = vec!["claude".to_string(), "--continue".to_string()];
537 inject_guard_into_argv(&mut argv, true).unwrap();
538 assert_eq!(argv[0], "claude");
539 assert_eq!(argv[1], "--settings");
540 assert!(extract_settings(&argv).is_some());
541 assert_eq!(argv[3], "--continue");
542 });
543 }
544
545 #[test]
546 fn preserves_delegate_prompt_at_tail() {
547 with_self_exe(|| {
548 let mut argv = vec!["claude".to_string(), "do this task".to_string()];
549 inject_guard_into_argv(&mut argv, true).unwrap();
550 assert_eq!(argv[0], "claude");
551 assert_eq!(argv[1], "--settings");
552 assert!(extract_settings(&argv).is_some());
553 assert_eq!(argv[3], "do this task");
554 });
555 }
556
557 #[test]
558 fn handles_yolo_skip_permissions_argv() {
559 with_self_exe(|| {
560 let mut argv = vec![
561 "claude".to_string(),
562 "--dangerously-skip-permissions".to_string(),
563 ];
564 inject_guard_into_argv(&mut argv, true).unwrap();
565 assert_eq!(argv[0], "claude");
567 assert_eq!(argv[1], "--settings");
568 assert_eq!(argv[3], "--dangerously-skip-permissions");
569 });
570 }
571
572 #[test]
573 fn auto_forward_prefix_known_tools() {
574 assert_eq!(auto_forward_prefix("claude"), Some("CLAUDE_"));
575 assert_eq!(auto_forward_prefix("codex"), Some("CODEX_"));
576 assert_eq!(auto_forward_prefix("gemini"), Some("GEMINI_"));
577 assert_eq!(auto_forward_prefix("unknown-tool"), None);
578 }
579
580 #[test]
581 fn auto_forward_prefix_strips_path_and_extension() {
582 assert_eq!(
586 auto_forward_prefix("/usr/local/bin/claude"),
587 Some("CLAUDE_")
588 );
589 assert_eq!(auto_forward_prefix("./claude"), Some("CLAUDE_"));
590 assert_eq!(auto_forward_prefix("/opt/codex"), Some("CODEX_"));
591 assert_eq!(auto_forward_prefix("claude.exe"), Some("CLAUDE_"));
592 }
593
594 #[test]
595 fn build_env_map_picks_up_prefix_match() {
596 std::env::set_var("CLAUDE_FOO_TEST_PICKUP", "from-parent");
597 let env = build_env_map("claude", false);
598 assert_eq!(
599 env.get("CLAUDE_FOO_TEST_PICKUP").map(String::as_str),
600 Some("from-parent"),
601 "CLAUDE_* var must auto-forward when no_env_forward=false"
602 );
603 std::env::remove_var("CLAUDE_FOO_TEST_PICKUP");
604 }
605
606 #[test]
607 fn build_env_map_no_env_forward_skips_auto() {
608 std::env::set_var("CLAUDE_FOO_TEST_NO_FWD", "from-parent");
609 let env = build_env_map("claude", true);
610 assert!(
611 !env.contains_key("CLAUDE_FOO_TEST_NO_FWD"),
612 "auto-forward must be suppressed by no_env_forward"
613 );
614 std::env::remove_var("CLAUDE_FOO_TEST_NO_FWD");
615 }
616
617 #[test]
618 fn build_env_map_unknown_tool_no_auto() {
619 std::env::set_var("CLAUDE_FOO_TEST_UNK", "from-parent");
620 let env = build_env_map("unknown-tool", false);
621 assert!(env.is_empty());
622 std::env::remove_var("CLAUDE_FOO_TEST_UNK");
623 }
624
625 #[test]
626 fn build_env_map_strips_parent_context_vars() {
627 std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "sdk-cli");
632 std::env::set_var("CLAUDE_CODE_EXECPATH", "/parent/bundle/path");
633 std::env::set_var("CLAUDE_FOO_TEST_KEEP", "from-parent");
634 let env = build_env_map("claude", false);
635 assert!(
636 !env.contains_key("CLAUDE_CODE_ENTRYPOINT"),
637 "CLAUDE_CODE_ENTRYPOINT must be stripped to avoid forcing SDK/print mode in the child"
638 );
639 assert!(
640 !env.contains_key("CLAUDE_CODE_EXECPATH"),
641 "CLAUDE_CODE_EXECPATH points at the parent's binary; do not forward"
642 );
643 assert_eq!(
644 env.get("CLAUDE_FOO_TEST_KEEP").map(String::as_str),
645 Some("from-parent"),
646 "unrelated CLAUDE_* vars must still forward"
647 );
648 std::env::remove_var("CLAUDE_CODE_ENTRYPOINT");
649 std::env::remove_var("CLAUDE_CODE_EXECPATH");
650 std::env::remove_var("CLAUDE_FOO_TEST_KEEP");
651 }
652}