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;
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();
524 f();
525 }
526
527 #[test]
528 fn tab_label_uses_sanitized_dir_name() {
529 assert_eq!(
530 tab_label_for(Path::new("/tmp/repo-feat-auth")),
531 "repo-feat-auth"
532 );
533 assert_eq!(tab_label_for(Path::new("/tmp/odd name@v1")), "odd-name-v1");
536 assert_eq!(tab_label_for(Path::new("/")), "worktree");
538 }
539
540 #[test]
541 fn tab_label_caps_at_max_session_length() {
542 let long = "a".repeat(MAX_SESSION_NAME_LENGTH + 20);
543 let label = tab_label_for(Path::new(&format!("/tmp/{long}")));
544 assert_eq!(label.chars().count(), MAX_SESSION_NAME_LENGTH);
545 }
546
547 #[test]
548 fn injects_settings_after_argv0_when_enabled() {
549 with_self_exe(|| {
550 let mut argv = vec!["claude".to_string()];
551 inject_guard_into_argv(&mut argv, true).unwrap();
552 assert_eq!(argv[0], "claude");
553 assert_eq!(argv[1], "--settings");
554 assert_eq!(argv.len(), 3);
555 let v: serde_json::Value =
556 serde_json::from_str(&argv[2]).expect("settings json parses");
557 assert_eq!(v["hooks"]["PreToolUse"][0]["matcher"], "Bash");
558 });
559 }
560
561 #[test]
562 fn noop_when_guard_disabled() {
563 with_self_exe(|| {
564 let mut argv = vec!["claude".to_string(), "--continue".to_string()];
565 inject_guard_into_argv(&mut argv, false).unwrap();
566 assert_eq!(argv, vec!["claude", "--continue"]);
567 });
568 }
569
570 #[test]
571 fn noop_when_argv_empty() {
572 with_self_exe(|| {
573 let mut argv: Vec<String> = vec![];
574 inject_guard_into_argv(&mut argv, true).unwrap();
575 assert!(argv.is_empty());
576 });
577 }
578
579 #[test]
580 fn preserves_trailing_continue_flag() {
581 with_self_exe(|| {
582 let mut argv = vec!["claude".to_string(), "--continue".to_string()];
583 inject_guard_into_argv(&mut argv, true).unwrap();
584 assert_eq!(argv[0], "claude");
585 assert_eq!(argv[1], "--settings");
586 assert!(extract_settings(&argv).is_some());
587 assert_eq!(argv[3], "--continue");
588 });
589 }
590
591 #[test]
592 fn preserves_delegate_prompt_at_tail() {
593 with_self_exe(|| {
594 let mut argv = vec!["claude".to_string(), "do this task".to_string()];
595 inject_guard_into_argv(&mut argv, true).unwrap();
596 assert_eq!(argv[0], "claude");
597 assert_eq!(argv[1], "--settings");
598 assert!(extract_settings(&argv).is_some());
599 assert_eq!(argv[3], "do this task");
600 });
601 }
602
603 #[test]
604 fn handles_yolo_skip_permissions_argv() {
605 with_self_exe(|| {
606 let mut argv = vec![
607 "claude".to_string(),
608 "--dangerously-skip-permissions".to_string(),
609 ];
610 inject_guard_into_argv(&mut argv, true).unwrap();
611 assert_eq!(argv[0], "claude");
613 assert_eq!(argv[1], "--settings");
614 assert_eq!(argv[3], "--dangerously-skip-permissions");
615 });
616 }
617
618 #[test]
619 fn auto_forward_prefix_known_tools() {
620 assert_eq!(auto_forward_prefix("claude"), Some("CLAUDE_"));
621 assert_eq!(auto_forward_prefix("codex"), Some("CODEX_"));
622 assert_eq!(auto_forward_prefix("gemini"), Some("GEMINI_"));
623 assert_eq!(auto_forward_prefix("unknown-tool"), None);
624 }
625
626 #[test]
627 fn auto_forward_prefix_strips_path_and_extension() {
628 assert_eq!(
632 auto_forward_prefix("/usr/local/bin/claude"),
633 Some("CLAUDE_")
634 );
635 assert_eq!(auto_forward_prefix("./claude"), Some("CLAUDE_"));
636 assert_eq!(auto_forward_prefix("/opt/codex"), Some("CODEX_"));
637 assert_eq!(auto_forward_prefix("claude.exe"), Some("CLAUDE_"));
638 }
639
640 #[test]
641 fn build_env_map_picks_up_prefix_match() {
642 std::env::set_var("CLAUDE_FOO_TEST_PICKUP", "from-parent");
643 let env = build_env_map("claude", false);
644 assert_eq!(
645 env.get("CLAUDE_FOO_TEST_PICKUP").map(String::as_str),
646 Some("from-parent"),
647 "CLAUDE_* var must auto-forward when no_env_forward=false"
648 );
649 std::env::remove_var("CLAUDE_FOO_TEST_PICKUP");
650 }
651
652 #[test]
653 fn build_env_map_no_env_forward_skips_auto() {
654 std::env::set_var("CLAUDE_FOO_TEST_NO_FWD", "from-parent");
655 let env = build_env_map("claude", true);
656 assert!(
657 !env.contains_key("CLAUDE_FOO_TEST_NO_FWD"),
658 "auto-forward must be suppressed by no_env_forward"
659 );
660 std::env::remove_var("CLAUDE_FOO_TEST_NO_FWD");
661 }
662
663 #[test]
664 fn build_env_map_unknown_tool_no_auto() {
665 std::env::set_var("CLAUDE_FOO_TEST_UNK", "from-parent");
666 let env = build_env_map("unknown-tool", false);
667 assert!(env.is_empty());
668 std::env::remove_var("CLAUDE_FOO_TEST_UNK");
669 }
670
671 #[test]
672 fn build_env_map_strips_parent_context_vars() {
673 std::env::set_var("CLAUDE_CODE_ENTRYPOINT", "sdk-cli");
678 std::env::set_var("CLAUDE_CODE_EXECPATH", "/parent/bundle/path");
679 std::env::set_var("CLAUDE_FOO_TEST_KEEP", "from-parent");
680 let env = build_env_map("claude", false);
681 assert!(
682 !env.contains_key("CLAUDE_CODE_ENTRYPOINT"),
683 "CLAUDE_CODE_ENTRYPOINT must be stripped to avoid forcing SDK/print mode in the child"
684 );
685 assert!(
686 !env.contains_key("CLAUDE_CODE_EXECPATH"),
687 "CLAUDE_CODE_EXECPATH points at the parent's binary; do not forward"
688 );
689 assert_eq!(
690 env.get("CLAUDE_FOO_TEST_KEEP").map(String::as_str),
691 Some("from-parent"),
692 "unrelated CLAUDE_* vars must still forward"
693 );
694 std::env::remove_var("CLAUDE_CODE_ENTRYPOINT");
695 std::env::remove_var("CLAUDE_CODE_EXECPATH");
696 std::env::remove_var("CLAUDE_FOO_TEST_KEEP");
697 }
698}