1use 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#[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_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 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 maybe_inject_guard(&mut ai_cmd_parts, path)?;
264 let spec = SpawnSpec::new(ai_cmd_parts, path.to_path_buf()).with_env(env);
265 let (cmd, _) = spawn_spec::materialize(&spec)?;
268
269 dispatch_launch(path, method, session_name, &cmd, ai_tool_name.as_str())
275}
276
277pub 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 let resolved = resolve_worktree_target(None, None)?;
296 (resolved.path, resolved.branch)
297 };
298
299 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 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 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
377pub 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 if matches!(method, LaunchMethod::Skip) {
391 return Ok(());
392 }
393
394 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 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
446fn 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
463fn 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
477fn 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
484fn 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
495fn tab_label_for(path: &Path) -> String {
499 cap_session_len(crate::constants::sanitize_branch_name(&dir_name_of(path)))
500}
501
502fn 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 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 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 assert_eq!(tab_label_for(Path::new("/tmp/odd name@v1")), "odd-name-v1");
538 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 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 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 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}