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
159fn 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
182pub 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 if matches!(method, LaunchMethod::Skip) {
191 return Ok(());
192 }
193
194 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 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 maybe_inject_guard(&mut ai_cmd_parts, path)?;
233 let spec = SpawnSpec::new(ai_cmd_parts, path.to_path_buf()).with_env(env);
234 let (cmd, _) = spawn_spec::materialize(&spec)?;
237
238 dispatch_launch(path, method, session_name, &cmd, ai_tool_name.as_str())
244}
245
246pub 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 let resolved = resolve_worktree_target(None, None)?;
265 (resolved.path, resolved.branch)
266 };
267
268 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 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 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
346pub 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 if matches!(method, LaunchMethod::Skip) {
360 return Ok(());
361 }
362
363 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 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
415fn 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
432fn 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
446fn 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 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 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 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}