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 => {
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 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 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
149fn 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
169const CLAUDE_PARENT_CONTEXT_VARS: &[&str] = &["CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_EXECPATH"];
186
187fn 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
211pub 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 if matches!(method, LaunchMethod::Skip) {
220 return Ok(());
221 }
222
223 let mut ai_cmd_parts = if resume {
229 get_ai_tool_resume_command()?
230 } else if is_claude_tool().unwrap_or(false) && session::claude_native_session_exists(path) {
231 eprintln!("Found existing Claude session, using --continue");
232 get_ai_tool_resume_command()?
233 } else {
234 get_ai_tool_command()?
235 };
236
237 if ai_cmd_parts.is_empty() {
238 return Ok(());
239 }
240
241 ai_cmd_parts.extend(opts.forward_args.iter().cloned());
244
245 let ai_tool_name = ai_cmd_parts[0].clone();
246
247 if !git::has_command(&ai_tool_name) {
248 println!(
249 "{} {} not detected. Install it or update config with 'gw config set ai-tool <tool>'.\n",
250 style("!").yellow(),
251 ai_tool_name,
252 );
253 return Ok(());
254 }
255
256 let env = build_env_map(&ai_tool_name, opts.no_env_forward);
257
258 maybe_inject_guard(&mut ai_cmd_parts, path)?;
262 let spec = SpawnSpec::new(ai_cmd_parts, path.to_path_buf()).with_env(env);
263 let (cmd, _) = spawn_spec::materialize(&spec)?;
266
267 dispatch_launch(path, method, session_name, &cmd, ai_tool_name.as_str())
273}
274
275pub fn resume_worktree(worktree: Option<&str>, opts: &LaunchOptions<'_>) -> Result<()> {
280 let (worktree_path, branch_name) = if let Some(target) = worktree {
281 let main_repo = git::get_main_repo_root(None)?;
282 let strict = resolve_target_strict(&main_repo, target)?;
283 let branch_name = strict.branch.unwrap_or_else(|| {
284 strict
285 .path
286 .file_name()
287 .map(|n| n.to_string_lossy().into_owned())
288 .unwrap_or_else(|| "(detached)".into())
289 });
290 (strict.path, branch_name)
291 } else {
292 let resolved = resolve_worktree_target(None, None)?;
294 (resolved.path, resolved.branch)
295 };
296
297 if worktree.is_some() {
299 let _ = std::env::set_current_dir(&worktree_path);
300 println!(
301 "{}\n",
302 style(messages::switched_to_worktree(&worktree_path)).dim()
303 );
304 }
305
306 let has_session =
308 is_claude_tool().unwrap_or(false) && session::claude_native_session_exists(&worktree_path);
309
310 if has_session {
311 println!(
312 "{} Found session for branch: {}",
313 style("*").green(),
314 style(&branch_name).bold()
315 );
316
317 if let Some(metadata) = session::load_session_metadata(&branch_name) {
318 println!(" AI tool: {}", style(&metadata.ai_tool).dim());
319 println!(" Last updated: {}", style(&metadata.updated_at).dim());
320 }
321
322 if let Some(context) = session::load_context(&branch_name) {
323 println!("\n{}", style("Previous context:").cyan());
324 println!("{}", style(&context).dim());
325 }
326 println!();
327 } else {
328 println!(
329 "{} No previous session found for branch: {}",
330 style("i").yellow(),
331 style(&branch_name).bold()
332 );
333 println!("{}\n", style("Starting fresh session...").dim());
334 }
335
336 let ai_cmd = get_ai_tool_resume_command()?;
346
347 if !ai_cmd.is_empty() {
348 let ai_tool_name = &ai_cmd[0];
349 let _ = session::save_session_metadata(
350 &branch_name,
351 ai_tool_name,
352 &worktree_path.to_string_lossy(),
353 );
354
355 if has_session {
356 println!(
357 "{} {}\n",
358 style(messages::resuming_ai_tool_in(ai_tool_name)).cyan(),
359 worktree_path.display()
360 );
361 } else {
362 println!(
363 "{} {}\n",
364 style(messages::starting_ai_tool_in(ai_tool_name)).cyan(),
365 worktree_path.display()
366 );
367 }
368
369 launch_ai_tool(&worktree_path, true, opts)?;
370 }
371
372 Ok(())
373}
374
375pub fn spawn_in_worktree(
380 worktree_path: &Path,
381 prompt: Option<&str>,
382 opts: &LaunchOptions<'_>,
383) -> Result<()> {
384 let (method, session_name) = config::resolve_term_option(opts.term_override, worktree_path)?;
385
386 if matches!(method, LaunchMethod::Skip) {
389 return Ok(());
390 }
391
392 if prompt.is_some() && !opts.forward_args.is_empty() {
398 return Err(CwError::Other(
399 "--prompt / --prompt-file cannot be combined with trailing AI tool args; \
400 pick one or the other"
401 .to_string(),
402 ));
403 }
404
405 let mut ai_cmd_parts = get_ai_tool_command()?;
410 if ai_cmd_parts.is_empty() {
411 return Ok(());
412 }
413 ai_cmd_parts.extend(opts.forward_args.iter().cloned());
414 if let Some(p) = prompt {
415 ai_cmd_parts.push(p.to_string());
416 }
417
418 let ai_tool_name = ai_cmd_parts[0].clone();
419
420 if !git::has_command(&ai_tool_name) {
421 println!(
422 "{} {} not detected. Install it or update config with 'gw config set ai-tool <tool>'.\n",
423 style("!").yellow(),
424 ai_tool_name,
425 );
426 return Ok(());
427 }
428
429 let env = build_env_map(&ai_tool_name, opts.no_env_forward);
430
431 maybe_inject_guard(&mut ai_cmd_parts, worktree_path)?;
432 let spec = SpawnSpec::new(ai_cmd_parts, worktree_path.to_path_buf()).with_env(env);
433 let (cmd, _) = spawn_spec::materialize(&spec)?;
434
435 dispatch_launch(
436 worktree_path,
437 method,
438 session_name,
439 &cmd,
440 ai_tool_name.as_str(),
441 )
442}
443
444fn maybe_inject_guard(argv: &mut Vec<String>, cwd: &Path) -> Result<()> {
451 if argv.is_empty() {
452 return Ok(());
453 }
454 if !is_claude_tool_for_cwd(cwd).unwrap_or(false) {
455 return Ok(());
456 }
457 let cfg = load_effective_config(cwd)?;
458 inject_guard_into_argv(argv, cfg.ai_tool.guard)
459}
460
461fn inject_guard_into_argv(argv: &mut Vec<String>, guard_enabled: bool) -> Result<()> {
466 if !guard_enabled || argv.is_empty() {
467 return Ok(());
468 }
469 let json = claude_settings::guard_settings_json()?;
470 argv.insert(1, "--settings".to_string());
471 argv.insert(2, json);
472 Ok(())
473}
474
475fn dir_name_of(path: &Path) -> String {
477 path.file_name()
478 .map(|n| n.to_string_lossy().to_string())
479 .unwrap_or_else(|| "worktree".to_string())
480}
481
482fn cap_session_len(s: String) -> String {
486 if s.chars().count() > MAX_SESSION_NAME_LENGTH {
487 s.chars().take(MAX_SESSION_NAME_LENGTH).collect()
488 } else {
489 s
490 }
491}
492
493fn tab_label_for(path: &Path) -> String {
497 cap_session_len(crate::constants::sanitize_branch_name(&dir_name_of(path)))
498}
499
500fn generate_session_name(path: &Path) -> String {
502 let config = config::load_config().unwrap_or_default();
503 let prefix = &config.launch.tmux_session_prefix;
504 cap_session_len(format!("{}-{}", prefix, dir_name_of(path)))
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::operations::test_env::{env_lock, EnvGuard};
511
512 fn extract_settings(argv: &[String]) -> Option<String> {
515 let pos = argv.iter().position(|s| s == "--settings")?;
516 argv.get(pos + 1).cloned()
517 }
518
519 fn with_self_exe<F: FnOnce()>(f: F) {
520 let _lock = env_lock();
521 let _guard = EnvGuard::capture(&["CW_SPAWN_AI_BIN"]);
522 std::env::set_var("CW_SPAWN_AI_BIN", "/usr/local/bin/gw");
523 f();
524 }
525
526 #[test]
527 fn tab_label_uses_sanitized_dir_name() {
528 assert_eq!(
529 tab_label_for(Path::new("/tmp/repo-feat-auth")),
530 "repo-feat-auth"
531 );
532 assert_eq!(tab_label_for(Path::new("/tmp/odd name@v1")), "odd-name-v1");
535 assert_eq!(tab_label_for(Path::new("/")), "worktree");
537 }
538
539 #[test]
540 fn tab_label_caps_at_max_session_length() {
541 let long = "a".repeat(MAX_SESSION_NAME_LENGTH + 20);
542 let label = tab_label_for(Path::new(&format!("/tmp/{long}")));
543 assert_eq!(label.chars().count(), MAX_SESSION_NAME_LENGTH);
544 }
545
546 #[test]
547 fn injects_settings_after_argv0_when_enabled() {
548 with_self_exe(|| {
549 let mut argv = vec!["claude".to_string()];
550 inject_guard_into_argv(&mut argv, true).unwrap();
551 assert_eq!(argv[0], "claude");
552 assert_eq!(argv[1], "--settings");
553 assert_eq!(argv.len(), 3);
554 let v: serde_json::Value =
555 serde_json::from_str(&argv[2]).expect("settings json parses");
556 assert_eq!(v["hooks"]["PreToolUse"][0]["matcher"], "Bash");
557 });
558 }
559
560 #[test]
561 fn noop_when_guard_disabled() {
562 with_self_exe(|| {
563 let mut argv = vec!["claude".to_string(), "--continue".to_string()];
564 inject_guard_into_argv(&mut argv, false).unwrap();
565 assert_eq!(argv, vec!["claude", "--continue"]);
566 });
567 }
568
569 #[test]
570 fn noop_when_argv_empty() {
571 with_self_exe(|| {
572 let mut argv: Vec<String> = vec![];
573 inject_guard_into_argv(&mut argv, true).unwrap();
574 assert!(argv.is_empty());
575 });
576 }
577
578 #[test]
579 fn preserves_trailing_continue_flag() {
580 with_self_exe(|| {
581 let mut argv = vec!["claude".to_string(), "--continue".to_string()];
582 inject_guard_into_argv(&mut argv, true).unwrap();
583 assert_eq!(argv[0], "claude");
584 assert_eq!(argv[1], "--settings");
585 assert!(extract_settings(&argv).is_some());
586 assert_eq!(argv[3], "--continue");
587 });
588 }
589
590 #[test]
591 fn preserves_delegate_prompt_at_tail() {
592 with_self_exe(|| {
593 let mut argv = vec!["claude".to_string(), "do this task".to_string()];
594 inject_guard_into_argv(&mut argv, true).unwrap();
595 assert_eq!(argv[0], "claude");
596 assert_eq!(argv[1], "--settings");
597 assert!(extract_settings(&argv).is_some());
598 assert_eq!(argv[3], "do this task");
599 });
600 }
601
602 #[test]
603 fn handles_yolo_skip_permissions_argv() {
604 with_self_exe(|| {
605 let mut argv = vec![
606 "claude".to_string(),
607 "--dangerously-skip-permissions".to_string(),
608 ];
609 inject_guard_into_argv(&mut argv, true).unwrap();
610 assert_eq!(argv[0], "claude");
612 assert_eq!(argv[1], "--settings");
613 assert_eq!(argv[3], "--dangerously-skip-permissions");
614 });
615 }
616
617 #[test]
618 fn auto_forward_prefix_known_tools() {
619 assert_eq!(auto_forward_prefix("claude"), Some("CLAUDE_"));
620 assert_eq!(auto_forward_prefix("codex"), Some("CODEX_"));
621 assert_eq!(auto_forward_prefix("gemini"), Some("GEMINI_"));
622 assert_eq!(auto_forward_prefix("unknown-tool"), None);
623 }
624
625 #[test]
626 fn auto_forward_prefix_strips_path_and_extension() {
627 assert_eq!(
631 auto_forward_prefix("/usr/local/bin/claude"),
632 Some("CLAUDE_")
633 );
634 assert_eq!(auto_forward_prefix("./claude"), Some("CLAUDE_"));
635 assert_eq!(auto_forward_prefix("/opt/codex"), Some("CODEX_"));
636 assert_eq!(auto_forward_prefix("claude.exe"), Some("CLAUDE_"));
637 }
638
639 #[test]
640 fn build_env_map_picks_up_prefix_match() {
641 std::env::set_var("CLAUDE_FOO_TEST_PICKUP", "from-parent");
642 let env = build_env_map("claude", false);
643 assert_eq!(
644 env.get("CLAUDE_FOO_TEST_PICKUP").map(String::as_str),
645 Some("from-parent"),
646 "CLAUDE_* var must auto-forward when no_env_forward=false"
647 );
648 std::env::remove_var("CLAUDE_FOO_TEST_PICKUP");
649 }
650
651 #[test]
652 fn build_env_map_no_env_forward_skips_auto() {
653 std::env::set_var("CLAUDE_FOO_TEST_NO_FWD", "from-parent");
654 let env = build_env_map("claude", true);
655 assert!(
656 !env.contains_key("CLAUDE_FOO_TEST_NO_FWD"),
657 "auto-forward must be suppressed by no_env_forward"
658 );
659 std::env::remove_var("CLAUDE_FOO_TEST_NO_FWD");
660 }
661
662 #[test]
663 fn build_env_map_unknown_tool_no_auto() {
664 std::env::set_var("CLAUDE_FOO_TEST_UNK", "from-parent");
665 let env = build_env_map("unknown-tool", false);
666 assert!(env.is_empty());
667 std::env::remove_var("CLAUDE_FOO_TEST_UNK");
668 }
669
670 #[test]
671 fn build_env_map_strips_parent_context_vars() {
672 std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "sdk-cli");
677 std::env::set_var("CLAUDE_CODE_EXECPATH", "/parent/bundle/path");
678 std::env::set_var("CLAUDE_FOO_TEST_KEEP", "from-parent");
679 let env = build_env_map("claude", false);
680 assert!(
681 !env.contains_key("CLAUDE_CODE_ENTRYPOINT"),
682 "CLAUDE_CODE_ENTRYPOINT must be stripped to avoid forcing SDK/print mode in the child"
683 );
684 assert!(
685 !env.contains_key("CLAUDE_CODE_EXECPATH"),
686 "CLAUDE_CODE_EXECPATH points at the parent's binary; do not forward"
687 );
688 assert_eq!(
689 env.get("CLAUDE_FOO_TEST_KEEP").map(String::as_str),
690 Some("from-parent"),
691 "unrelated CLAUDE_* vars must still forward"
692 );
693 std::env::remove_var("CLAUDE_CODE_ENTRYPOINT");
694 std::env::remove_var("CLAUDE_CODE_EXECPATH");
695 std::env::remove_var("CLAUDE_FOO_TEST_KEEP");
696 }
697}