use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
use crate::error::{CwError, Result};
pub const SPEC_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SpawnSpec {
pub version: u32,
pub argv: Vec<String>,
pub cwd: PathBuf,
pub self_unlink: bool,
}
impl SpawnSpec {
pub fn new(argv: Vec<String>, cwd: PathBuf) -> Self {
Self {
version: SPEC_VERSION,
argv,
cwd,
self_unlink: true,
}
}
}
fn write_last_to_git_dir(spec: &SpawnSpec, git_dir: &Path) -> Result<()> {
let mut persistent = spec.clone();
persistent.self_unlink = false;
let path = git_dir.join("gw-spawn-last.json");
let tmp = git_dir.join("gw-spawn-last.json.tmp");
let json = serde_json::to_vec(&persistent)?;
fs::write(&tmp, json)?;
fs::rename(&tmp, &path)?;
Ok(())
}
fn git_dir_for(cwd: &Path) -> Result<PathBuf> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(cwd)
.output()
.map_err(|e| CwError::Other(format!("spawn-ai: git rev-parse failed: {}", e)))?;
if !output.status.success() {
return Err(CwError::Other(format!(
"spawn-ai: not a git worktree: {}",
cwd.display()
)));
}
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
let p = PathBuf::from(&raw);
let absolute = if p.is_absolute() { p } else { cwd.join(p) };
Ok(absolute)
}
pub fn materialize(spec: &SpawnSpec) -> Result<(String, PathBuf)> {
materialize_in_dir(spec, &std::env::temp_dir())
}
pub fn materialize_in_dir(spec: &SpawnSpec, dir: &Path) -> Result<(String, PathBuf)> {
fs::create_dir_all(dir)?;
let named = tempfile::Builder::new()
.prefix("gw-spawn-")
.suffix(".json")
.rand_bytes(16)
.tempfile_in(dir)?;
let json = serde_json::to_vec(spec)?;
{
let mut f = named.as_file();
f.write_all(&json)?;
f.flush()?;
}
let (_file, path) = named.keep().map_err(|e| e.error)?;
if let Ok(git_dir) = git_dir_for(&spec.cwd) {
let _ = write_last_to_git_dir(spec, &git_dir);
}
let self_exe = if let Some(s) = std::env::var_os("CW_SPAWN_AI_BIN") {
quote_path_for_shell(Path::new(&s))
} else {
let exe = std::env::current_exe().map_err(|e| {
CwError::Other(format!(
"spawn-ai: cannot resolve current executable path: {}. \
Set CW_SPAWN_AI_BIN to override.",
e
))
})?;
quote_path_for_shell(&exe)
};
let shell_line = format!("{} _spawn-ai {}", self_exe, quote_path_for_shell(&path));
Ok((shell_line, path))
}
fn quote_path_for_shell(path: &Path) -> String {
let s = path.to_string_lossy();
let safe = s
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '.' | '-' | ':'));
if safe {
s.into_owned()
} else {
format!("\"{}\"", s)
}
}
pub fn read_spec(path: &Path) -> Result<SpawnSpec> {
let bytes = fs::read(path)
.map_err(|e| CwError::Other(format!("spawn-ai: read {} failed: {}", path.display(), e)))?;
let spec: SpawnSpec = serde_json::from_slice(&bytes)
.map_err(|e| CwError::Other(format!("spawn-ai: parse {} failed: {}", path.display(), e)))?;
if spec.version != SPEC_VERSION {
return Err(CwError::Other(format!(
"spawn-ai: unsupported spawn spec version: {} (expected {})",
spec.version, SPEC_VERSION
)));
}
if spec.argv.is_empty() {
return Err(CwError::Other("spawn-ai: spawn spec has empty argv".into()));
}
Ok(spec)
}
pub fn execute(spec_path: &Path) -> Result<()> {
let spec = read_spec(spec_path)?;
if spec.self_unlink {
let _ = fs::remove_file(spec_path);
}
std::env::set_current_dir(&spec.cwd).map_err(|e| {
CwError::Other(format!(
"spawn-ai: chdir to {} failed: {}",
spec.cwd.display(),
e
))
})?;
let program = &spec.argv[0];
let args = &spec.argv[1..];
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let err = std::process::Command::new(program).args(args).exec();
eprintln!("spawn-ai: exec {} failed: {}", program, err);
std::process::exit(127);
}
#[cfg(windows)]
{
let status = match std::process::Command::new(program).args(args).status() {
Ok(s) => s,
Err(e) => {
eprintln!("spawn-ai: spawn {} failed: {}", program, e);
std::process::exit(127);
}
};
let code = status.code().unwrap_or(1);
std::process::exit(code);
}
}
pub fn resolve_last_for_cwd() -> Result<PathBuf> {
let cwd = std::env::current_dir()
.map_err(|e| CwError::Other(format!("spawn-ai: cannot read current directory: {}", e)))?;
let git_dir = git_dir_for(&cwd)?;
let last = git_dir.join("gw-spawn-last.json");
if !last.exists() {
return Err(CwError::Other(format!(
"spawn-ai: no recent spawn found for this worktree (looked for {})",
last.display()
)));
}
Ok(last)
}
pub fn sweep_stale() {
sweep_stale_in(&std::env::temp_dir(), Duration::from_secs(24 * 3600));
}
fn sweep_stale_in(dir: &Path, max_age: Duration) {
let entries = match fs::read_dir(dir) {
Ok(it) => it,
Err(_) => return,
};
let now = SystemTime::now();
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("gw-spawn-") || !name_str.ends_with(".json") {
continue;
}
let metadata = match fs::symlink_metadata(entry.path()) {
Ok(m) => m,
Err(_) => continue,
};
if !metadata.is_file() {
continue;
}
let mtime = match metadata.modified() {
Ok(t) => t,
Err(_) => continue,
};
if now.duration_since(mtime).unwrap_or_default() > max_age {
let _ = fs::remove_file(entry.path());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct CwdGuard(std::path::PathBuf);
impl Drop for CwdGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.0);
}
}
impl CwdGuard {
fn enter(target: &std::path::Path) -> Self {
let prev = std::env::current_dir().unwrap();
std::env::set_current_dir(target).unwrap();
Self(prev)
}
}
#[test]
fn round_trip_preserves_killer_prompts() {
let killers = [
r#"Fix the bug where user can "escape" quotes"#,
r#"$(rm -rf /) — literal, not an expansion"#,
"한글 테스트 🚀 ${PATH}",
"multi\nline\n<<'EOF'\nnot a heredoc\nEOF\n",
r"C:\Users\foo\bar \\path\\with\\backslashes",
"`backtick` and 'single' and \"double\"",
];
for prompt in killers {
let spec = SpawnSpec::new(
vec!["claude".into(), "--print".into(), prompt.into()],
PathBuf::from("/tmp/wt"),
);
let json = serde_json::to_string(&spec).unwrap();
let back: SpawnSpec = serde_json::from_str(&json).unwrap();
assert_eq!(spec, back, "round-trip mismatch for: {:?}", prompt);
assert_eq!(back.argv[2], prompt);
}
}
#[test]
fn large_prompt_round_trips() {
let big = "x".repeat(64 * 1024);
let spec = SpawnSpec::new(vec!["claude".into(), big.clone()], PathBuf::from("/tmp"));
let json = serde_json::to_string(&spec).unwrap();
let back: SpawnSpec = serde_json::from_str(&json).unwrap();
assert_eq!(back.argv[1], big);
}
#[test]
fn materialize_writes_spec_and_returns_shell_line() {
let dir = tempfile::tempdir().unwrap();
let spec = SpawnSpec::new(
vec!["/bin/echo".into(), "hello \"world\"".into()],
dir.path().to_path_buf(),
);
let (shell_line, spec_path) = materialize_in_dir(&spec, dir.path()).unwrap();
let exe = std::env::current_exe().unwrap();
let exe_token = quote_path_for_shell(&exe);
assert!(
shell_line.starts_with(&format!("{} _spawn-ai ", exe_token)),
"expected line to start with {:?} _spawn-ai, got {:?}",
exe_token,
shell_line
);
assert!(
!shell_line.starts_with("exec "),
"shell_line must not use exec: {:?}",
shell_line
);
assert!(spec_path.exists());
let loaded: SpawnSpec =
serde_json::from_str(&std::fs::read_to_string(&spec_path).unwrap()).unwrap();
assert_eq!(loaded, spec);
}
#[test]
fn materialize_filename_is_shell_safe() {
let dir = tempfile::tempdir().unwrap();
let spec = SpawnSpec::new(vec!["/bin/true".into()], dir.path().into());
let (line, _path) = materialize_in_dir(&spec, dir.path()).unwrap();
let exe = std::env::current_exe().unwrap();
let exe_token = quote_path_for_shell(&exe);
let prefix = format!("{} _spawn-ai ", exe_token);
let tail = line
.strip_prefix(&prefix)
.unwrap_or_else(|| panic!("line does not start with {:?}: {:?}", prefix, line));
let quoted = tail.starts_with('"') && tail.ends_with('"');
let bare_safe = tail
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '/' | '.' | '-' | ':' | '\\'));
assert!(quoted || bare_safe, "unsafe tail: {:?}", tail);
}
#[cfg(unix)]
#[test]
fn materialize_file_is_mode_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let spec = SpawnSpec::new(vec!["/bin/true".into()], dir.path().into());
let (_line, path) = materialize_in_dir(&spec, dir.path()).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600, got {:o}", mode);
}
#[test]
fn quote_path_for_shell_quotes_windows_backslashes() {
use std::path::PathBuf;
let win = PathBuf::from(r"C:\Users\me\AppData\Local\Temp\gw-spawn-abcdef0123456789.json");
let out = super::quote_path_for_shell(&win);
assert!(
out.starts_with('"') && out.ends_with('"'),
"expected quoted, got {:?}",
out
);
}
#[test]
fn quote_path_for_shell_bare_for_unix_paths() {
use std::path::PathBuf;
let unix = PathBuf::from("/tmp/gw-spawn-abcdef0123456789.json");
let out = super::quote_path_for_shell(&unix);
assert!(!out.starts_with('"'), "expected bare, got {:?}", out);
}
#[test]
fn read_spec_rejects_wrong_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(
&path,
r#"{"version":999,"argv":["x"],"cwd":"/","self_unlink":false}"#,
)
.unwrap();
let err = read_spec(&path).unwrap_err();
assert!(format!("{err}").contains("unsupported spawn spec version"));
}
#[test]
fn read_spec_rejects_empty_argv() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.json");
std::fs::write(
&path,
r#"{"version":1,"argv":[],"cwd":"/","self_unlink":false}"#,
)
.unwrap();
let err = read_spec(&path).unwrap_err();
assert!(format!("{err}").contains("empty argv"));
}
#[test]
fn read_spec_round_trip() {
let dir = tempfile::tempdir().unwrap();
let spec = SpawnSpec::new(
vec!["/bin/echo".into(), "hi".into()],
dir.path().to_path_buf(),
);
let path = dir.path().join("ok.json");
std::fs::write(&path, serde_json::to_vec(&spec).unwrap()).unwrap();
let loaded = read_spec(&path).unwrap();
assert_eq!(loaded, spec);
}
#[test]
fn materialize_writes_last_copy_into_git_dir() {
let dir = tempfile::tempdir().unwrap();
let worktree = dir.path();
let status = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(worktree)
.status()
.unwrap();
assert!(status.success());
let git_dir = worktree.join(".git");
let spec = SpawnSpec::new(
vec!["claude".into(), "--print".into(), "hello".into()],
worktree.to_path_buf(),
);
let temp = tempfile::tempdir().unwrap();
let (_line, _path) = materialize_in_dir(&spec, temp.path()).unwrap();
let last = git_dir.join("gw-spawn-last.json");
assert!(
last.exists(),
"expected persistent copy at {}",
last.display()
);
let loaded: SpawnSpec =
serde_json::from_str(&std::fs::read_to_string(&last).unwrap()).unwrap();
assert!(
!loaded.self_unlink,
"persistent copy must have self_unlink=false"
);
assert_eq!(loaded.argv, spec.argv);
assert_eq!(loaded.cwd, spec.cwd);
}
#[test]
fn read_spec_does_not_observe_unlink_for_persistent_copy() {
let dir = tempfile::tempdir().unwrap();
let worktree = dir.path();
let status = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(worktree)
.status()
.unwrap();
assert!(status.success());
let spec = SpawnSpec::new(vec!["/bin/true".into()], worktree.to_path_buf());
let temp = tempfile::tempdir().unwrap();
let (_line, _path) = materialize_in_dir(&spec, temp.path()).unwrap();
let last = worktree.join(".git").join("gw-spawn-last.json");
let loaded = read_spec(&last).expect("read_spec should succeed");
assert!(
!loaded.self_unlink,
"persistent copy must keep self_unlink=false"
);
}
#[test]
fn sweep_stale_removes_old_spec_files_only() {
use std::time::{Duration, SystemTime};
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join("gw-spawn-old.json");
std::fs::write(&old, "{}").unwrap();
let past = SystemTime::now() - Duration::from_secs(48 * 3600);
filetime::set_file_mtime(&old, filetime::FileTime::from_system_time(past)).unwrap();
let recent = dir.path().join("gw-spawn-recent.json");
std::fs::write(&recent, "{}").unwrap();
let unrelated = dir.path().join("something-else.json");
std::fs::write(&unrelated, "{}").unwrap();
filetime::set_file_mtime(&unrelated, filetime::FileTime::from_system_time(past)).unwrap();
sweep_stale_in(dir.path(), Duration::from_secs(24 * 3600));
assert!(!old.exists(), "old gw-spawn file should be removed");
assert!(recent.exists(), "recent gw-spawn file should remain");
assert!(unrelated.exists(), "unrelated file should be untouched");
}
#[test]
#[serial_test::serial]
fn resolve_last_for_cwd_finds_persisted_spec() {
let dir = tempfile::tempdir().unwrap();
let worktree = dir.path();
let status = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(worktree)
.status()
.unwrap();
assert!(status.success());
let spec = SpawnSpec::new(
vec!["/bin/echo".into(), "hi".into()],
worktree.to_path_buf(),
);
let temp = tempfile::tempdir().unwrap();
let (_line, _path) = materialize_in_dir(&spec, temp.path()).unwrap();
let _guard = CwdGuard::enter(worktree);
let resolved = resolve_last_for_cwd().expect("resolve_last_for_cwd should succeed");
assert!(
resolved.exists(),
"resolved path must exist: {}",
resolved.display()
);
assert!(
resolved.ends_with("gw-spawn-last.json"),
"unexpected resolved path: {}",
resolved.display()
);
}
#[test]
#[serial_test::serial]
fn resolve_last_for_cwd_errors_when_no_spec_persisted() {
let dir = tempfile::tempdir().unwrap();
let worktree = dir.path();
let status = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(worktree)
.status()
.unwrap();
assert!(status.success());
let _guard = CwdGuard::enter(worktree);
let result = resolve_last_for_cwd();
let err = result.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("no recent spawn") || msg.contains("gw-spawn-last.json"),
"unexpected error: {}",
msg
);
assert!(msg.starts_with("spawn-ai:"), "missing prefix: {}", msg);
}
#[test]
#[serial_test::serial]
fn resolve_last_for_cwd_errors_outside_a_git_worktree() {
let dir = tempfile::tempdir().unwrap();
let _guard = CwdGuard::enter(dir.path());
let result = resolve_last_for_cwd();
let err = result.unwrap_err();
let msg = format!("{err}");
assert!(msg.starts_with("spawn-ai:"), "missing prefix: {}", msg);
}
}