use serde_json::{json, Value};
use super::{extract_str, parse_json_input};
use crate::missions::{
active_mission, add_marker_to_git_exclude, clear_active, list_missions, list_orphan_slugs,
load_marker, missions_root, save_marker, set_active, validate_slug, Mission,
};
use crate::test_runner::run_command_with_timeout;
const CLONE_TIMEOUT_SECS: u64 = 120;
const GIT_TIMEOUT_SECS: u64 = 30;
const PUSH_TIMEOUT_SECS: u64 = 60;
pub(super) fn schemas() -> Vec<Value> {
vec![
json!({
"type": "function",
"function": {
"name": "mission_start",
"description": "Clone a brownfield repo into ~/.claudette/missions/<slug>/ and make it the session's active mission. While active, git_*, bash, edit_file, read_file, write_file, and search calls run inside the mission tree. Use mission_exit to clear.",
"parameters": {
"type": "object",
"properties": {
"target": { "type": "string", "description": "Either 'owner/repo' (assumed GitHub via https) or a full git URL (https://, http://, git@, ssh://)" },
"dest": { "type": "string", "description": "Optional dest slug under ~/.claudette/missions/. Defaults to the repo name." }
},
"required": ["target"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "mission_status",
"description": "Show the currently active mission (slug, path, GitHub repo, current branch). Returns null when no mission is active.",
"parameters": { "type": "object", "properties": {}, "required": [] }
}
}),
json!({
"type": "function",
"function": {
"name": "mission_list",
"description": "List every mission registered under ~/.claudette/missions/ (active or not). Also reports 'orphans' — directories under that root with no marker (likely pre-T2 git_clone leftovers; mission_attach won't find them).",
"parameters": { "type": "object", "properties": {}, "required": [] }
}
}),
json!({
"type": "function",
"function": {
"name": "mission_attach",
"description": "Re-attach to a mission previously started in another session by reading its on-disk marker. Use after restart to resume cwd-routing into the mission tree. Errors if a mission is already active or the slug has no marker.",
"parameters": {
"type": "object",
"properties": {
"slug": { "type": "string", "description": "Mission slug (directory name under ~/.claudette/missions/) — same value mission_start returned." }
},
"required": ["slug"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "mission_exit",
"description": "Clear the active mission. The cloned tree is left intact for resumption.",
"parameters": { "type": "object", "properties": {}, "required": [] }
}
}),
json!({
"type": "function",
"function": {
"name": "mission_submit",
"description": "Capstone: stage all changes (add -A), commit, push -u, and open a PR against the mission's GitHub repo. Auto-creates 'claudette-mission/<slug>' branch if currently on main/master.",
"parameters": {
"type": "object",
"properties": {
"title": { "type": "string", "description": "PR title (also used as commit message subject)" },
"body": { "type": "string", "description": "PR/commit body (Markdown). Optional." },
"fixes_issue": { "type": "number", "description": "Issue number to auto-close via 'Fixes #N'. Optional." },
"draft": { "type": "boolean", "description": "Open as draft (default: false)" }
},
"required": ["title"]
}
}
}),
]
}
pub(super) fn dispatch(name: &str, input: &str) -> Option<Result<String, String>> {
let r = match name {
"mission_start" => run_mission_start(input),
"mission_status" => run_mission_status(),
"mission_list" => run_mission_list(),
"mission_attach" => run_mission_attach(input),
"mission_exit" => run_mission_exit(),
"mission_submit" => run_mission_submit(input),
_ => return None,
};
Some(r)
}
#[derive(Debug)]
struct ParsedTarget {
clone_url: String,
repo: Option<String>,
default_dest: String,
}
fn parse_target(target: &str) -> Result<ParsedTarget, String> {
let t = target.trim();
if t.is_empty() {
return Err("mission_start: empty target".to_string());
}
if !t.contains("://") && !t.contains('@') && !t.contains(' ') {
let parts: Vec<&str> = t.split('/').collect();
if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
let owner = parts[0];
let repo_name = parts[1].trim_end_matches(".git");
if owner.contains("..") || repo_name.contains("..") {
return Err(format!("mission_start: invalid owner/repo: {t}"));
}
let canonical = format!("{owner}/{repo_name}");
return Ok(ParsedTarget {
clone_url: format!("https://github.com/{canonical}.git"),
repo: Some(canonical),
default_dest: repo_name.to_string(),
});
}
}
let scheme_ok = t.starts_with("https://")
|| t.starts_with("http://")
|| t.starts_with("git@")
|| t.starts_with("ssh://");
if !scheme_ok {
return Err(format!(
"mission_start: unsupported target — must be 'owner/repo' or a git URL (https://, http://, git@, ssh://), got `{t}`"
));
}
Ok(ParsedTarget {
clone_url: t.to_string(),
repo: parse_github_canonical(t),
default_dest: derive_dest_from_url(t),
})
}
fn parse_github_canonical(url: &str) -> Option<String> {
let stripped = url
.strip_prefix("https://github.com/")
.or_else(|| url.strip_prefix("http://github.com/"))
.or_else(|| url.strip_prefix("git@github.com:"))
.or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
let stripped = stripped.trim_end_matches('/').trim_end_matches(".git");
let mut parts = stripped.splitn(3, '/');
let owner = parts.next()?;
let repo = parts.next()?;
if owner.is_empty() || repo.is_empty() {
return None;
}
Some(format!("{owner}/{repo}"))
}
fn derive_dest_from_url(url: &str) -> String {
let trimmed = url.trim_end_matches('/').trim_end_matches(".git");
trimmed
.rsplit(['/', ':'])
.next()
.unwrap_or("repo")
.to_string()
}
fn run_mission_start(input: &str) -> Result<String, String> {
let v = parse_json_input(input, "mission_start")?;
let target = extract_str(&v, "target", "mission_start")?;
if let Some(active) = active_mission() {
return Err(format!(
"mission_start: '{}' is already active — exit it first with mission_exit",
active.slug
));
}
let parsed = parse_target(target)?;
let dest_raw = v
.get("dest")
.and_then(Value::as_str)
.unwrap_or(&parsed.default_dest);
let dest = validate_slug(dest_raw)?;
let root = missions_root();
std::fs::create_dir_all(&root)
.map_err(|e| format!("mission_start: create {} failed: {e}", root.display()))?;
let target_path = root.join(&dest);
if target_path.exists() {
return Err(format!(
"mission_start: target already exists at {} — pick a different dest or remove it first",
target_path.display()
));
}
let target_str = target_path
.to_str()
.ok_or_else(|| {
format!(
"mission_start: target path is not utf-8: {}",
target_path.display()
)
})?
.to_string();
let git_exe = super::git::resolve_git_path();
let args: Vec<&str> = vec!["clone", "--", &parsed.clone_url, &target_str];
let result = run_command_with_timeout(&git_exe, &args, CLONE_TIMEOUT_SECS, None);
if result.timed_out {
let _ = std::fs::remove_dir_all(&target_path);
return Err(format!(
"mission_start: timed out after {CLONE_TIMEOUT_SECS}s for {}",
parsed.clone_url
));
}
if !result.success {
let _ = std::fs::remove_dir_all(&target_path);
let stderr = if result.stderr.is_empty() {
result.stdout.clone()
} else {
result.stderr.clone()
};
return Err(format!(
"mission_start: clone failed (exit {:?}): {}",
result.exit_code,
stderr.chars().take(500).collect::<String>()
));
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
let mission = Mission {
slug: dest.clone(),
path: target_path.clone(),
repo: parsed.repo.clone(),
created_at: now,
ephemeral: false,
};
let _ = add_marker_to_git_exclude(&mission.path);
save_marker(&mission)?;
set_active(mission)?;
Ok(json!({
"ok": true,
"slug": dest,
"path": target_str,
"repo": parsed.repo,
"url": parsed.clone_url,
"note": "mission active — git_*, bash, edit_file, read_file, write_file, and search now run in this tree",
})
.to_string())
}
#[allow(clippy::unnecessary_wraps)]
fn run_mission_status() -> Result<String, String> {
match active_mission() {
None => Ok(json!({ "active": null }).to_string()),
Some(m) => {
let branch = current_branch_in(&m.path).unwrap_or_else(|_| "?".to_string());
Ok(json!({
"active": {
"slug": m.slug,
"path": m.path.display().to_string(),
"repo": m.repo,
"branch": branch,
"created_at": m.created_at,
}
})
.to_string())
}
}
}
fn run_mission_list() -> Result<String, String> {
let missions = list_missions()?;
let active_slug = active_mission().map(|m| m.slug);
let items: Vec<Value> = missions
.iter()
.map(|m| {
json!({
"slug": m.slug,
"path": m.path.display().to_string(),
"repo": m.repo,
"active": active_slug.as_deref() == Some(&m.slug),
"created_at": m.created_at,
})
})
.collect();
let orphans = list_orphan_slugs()?;
let mut out = json!({
"count": items.len(),
"items": items,
"orphans": orphans,
});
if !orphans.is_empty() {
let n = orphans.len();
let plural = if n == 1 { "y" } else { "ies" };
out["note"] = Value::String(format!(
"{n} director{plural} under ~/.claudette/missions/ have no marker — likely pre-T2 git_clone leftovers; mission_attach won't find them. Investigate or remove."
));
}
Ok(out.to_string())
}
fn run_mission_exit() -> Result<String, String> {
crate::missions::clear_brownfield_failed();
match clear_active() {
Some(slug) => Ok(json!({ "ok": true, "exited": slug }).to_string()),
None => Err("mission_exit: no active mission".to_string()),
}
}
fn run_mission_attach(input: &str) -> Result<String, String> {
let v = parse_json_input(input, "mission_attach")?;
let slug_raw = extract_str(&v, "slug", "mission_attach")?;
let slug = validate_slug(slug_raw)?;
if let Some(active) = active_mission() {
return Err(format!(
"mission_attach: '{}' is already active — exit it first with mission_exit",
active.slug
));
}
let path = missions_root().join(&slug);
if !path.is_dir() {
return Err(format!(
"mission_attach: no mission directory at {} — start one with mission_start or pick a different slug",
path.display()
));
}
let mission = load_marker(&path).map_err(|e| format!("mission_attach: {e}"))?;
set_active(mission.clone())?;
Ok(json!({
"ok": true,
"slug": mission.slug,
"path": mission.path.display().to_string(),
"repo": mission.repo,
"note": "mission re-attached — git_*, bash, edit_file, read_file, write_file, and search now run in this tree",
})
.to_string())
}
fn run_mission_submit(input: &str) -> Result<String, String> {
let v = parse_json_input(input, "mission_submit")?;
let title = extract_str(&v, "title", "mission_submit")?;
let body_in = v.get("body").and_then(Value::as_str).unwrap_or("");
let fixes_issue = v.get("fixes_issue").and_then(Value::as_u64);
let draft = v.get("draft").and_then(Value::as_bool).unwrap_or(false);
let mission =
active_mission().ok_or("mission_submit: no active mission — run mission_start first")?;
let repo = mission
.repo
.clone()
.ok_or("mission_submit: this mission was not started from a GitHub repo, so PR creation is not supported. Use git_push + the lower-level GitHub tools manually.")?;
let (owner, repo_name) = repo.split_once('/').ok_or_else(|| {
format!("mission_submit: malformed repo identifier `{repo}` (expected owner/repo)")
})?;
let porcelain = git_in(&mission.path, &["status", "--porcelain"], GIT_TIMEOUT_SECS)?;
if porcelain.trim().is_empty() {
return Err(
"mission_submit: working tree clean — nothing to commit. Edit some files first."
.to_string(),
);
}
let starting_branch = current_branch_in(&mission.path)?;
let branch = if matches!(starting_branch.as_str(), "main" | "master") {
let new_branch = format!("claudette-mission/{}", mission.slug);
git_in(
&mission.path,
&["checkout", "-b", &new_branch],
GIT_TIMEOUT_SECS,
)?;
new_branch
} else {
starting_branch
};
git_in(&mission.path, &["add", "-A"], GIT_TIMEOUT_SECS)?;
use std::fmt::Write as _;
let mut body_full = body_in.to_string();
if let Some(num) = fixes_issue {
if !body_full.is_empty() {
body_full.push_str("\n\n");
}
let _ = write!(body_full, "Fixes #{num}");
}
let commit_msg = if body_full.is_empty() {
title.to_string()
} else {
format!("{title}\n\n{body_full}")
};
git_in(
&mission.path,
&["commit", "-m", &commit_msg],
GIT_TIMEOUT_SECS,
)?;
git_in(
&mission.path,
&["push", "-u", "origin", &branch],
PUSH_TIMEOUT_SECS,
)?;
let pr_input = json!({
"owner": owner,
"repo": repo_name,
"title": title,
"body": body_full,
"head": branch,
"base": "main",
"draft": draft,
})
.to_string();
let pr_response = match crate::tools::dispatch_tool("gh_create_pr", &pr_input) {
Ok(s) => s,
Err(e) => {
if e.contains("base") || e.contains("master") {
let retry = json!({
"owner": owner,
"repo": repo_name,
"title": title,
"body": body_full,
"head": branch,
"base": "master",
"draft": draft,
})
.to_string();
crate::tools::dispatch_tool("gh_create_pr", &retry)?
} else {
return Err(e);
}
}
};
let pr: Value = serde_json::from_str(&pr_response)
.map_err(|e| format!("mission_submit: gh_create_pr returned non-json: {e}"))?;
Ok(json!({
"ok": true,
"slug": mission.slug,
"branch": branch,
"pr_number": pr.get("number"),
"pr_url": pr.get("url"),
"draft": draft,
})
.to_string())
}
fn git_in(cwd: &std::path::Path, args: &[&str], timeout: u64) -> Result<String, String> {
let git_exe = super::git::resolve_git_path();
let result = run_command_with_timeout(&git_exe, args, timeout, Some(cwd));
if result.timed_out {
return Err(format!(
"mission: git {} timed out after {timeout}s",
args.first().unwrap_or(&"")
));
}
if !result.success {
let stderr = if result.stderr.is_empty() {
result.stdout.clone()
} else {
result.stderr.clone()
};
return Err(format!(
"mission: git {} failed (exit {:?}): {}",
args.first().unwrap_or(&""),
result.exit_code,
stderr.chars().take(500).collect::<String>()
));
}
Ok(result.stdout)
}
fn current_branch_in(cwd: &std::path::Path) -> Result<String, String> {
let out = git_in(
cwd,
&["rev-parse", "--abbrev-ref", "HEAD"],
GIT_TIMEOUT_SECS,
)?;
Ok(out.trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_target_owner_repo_form() {
let p = parse_target("octocat/Hello-World").unwrap();
assert_eq!(p.clone_url, "https://github.com/octocat/Hello-World.git");
assert_eq!(p.repo.as_deref(), Some("octocat/Hello-World"));
assert_eq!(p.default_dest, "Hello-World");
}
#[test]
fn parse_target_owner_repo_strips_dot_git() {
let p = parse_target("octocat/Hello-World.git").unwrap();
assert_eq!(p.clone_url, "https://github.com/octocat/Hello-World.git");
assert_eq!(p.default_dest, "Hello-World");
}
#[test]
fn parse_target_https_github() {
let p = parse_target("https://github.com/octocat/Hello-World.git").unwrap();
assert_eq!(p.repo.as_deref(), Some("octocat/Hello-World"));
assert_eq!(p.default_dest, "Hello-World");
}
#[test]
fn parse_target_https_github_no_dot_git() {
let p = parse_target("https://github.com/octocat/Hello-World").unwrap();
assert_eq!(p.repo.as_deref(), Some("octocat/Hello-World"));
assert_eq!(p.default_dest, "Hello-World");
}
#[test]
fn parse_target_ssh_github() {
let p = parse_target("git@github.com:octocat/Hello-World.git").unwrap();
assert_eq!(p.repo.as_deref(), Some("octocat/Hello-World"));
assert_eq!(p.default_dest, "Hello-World");
}
#[test]
fn parse_target_non_github_url_keeps_repo_none() {
let p = parse_target("https://gitlab.com/group/proj.git").unwrap();
assert!(p.repo.is_none());
assert_eq!(p.default_dest, "proj");
}
#[test]
fn parse_target_rejects_unsupported_scheme() {
let err = parse_target("file:///etc/passwd").unwrap_err();
assert!(err.contains("unsupported"), "got: {err}");
}
#[test]
fn parse_target_rejects_empty() {
assert!(parse_target("").is_err());
assert!(parse_target(" ").is_err());
}
#[test]
fn parse_target_rejects_traversal_in_owner_repo() {
assert!(parse_target("../etc/passwd").is_err());
assert!(parse_target("foo/../bar").is_err());
}
#[test]
fn parse_target_rejects_three_slash_form() {
let err = parse_target("octocat/Hello-World/branch").unwrap_err();
assert!(err.contains("unsupported"), "got: {err}");
}
#[test]
fn derive_dest_from_url_handles_trailing_slash_and_dot_git() {
assert_eq!(
derive_dest_from_url("https://example.com/foo/bar.git"),
"bar"
);
assert_eq!(
derive_dest_from_url("https://example.com/foo/bar.git/"),
"bar"
);
assert_eq!(derive_dest_from_url("git@host:owner/repo"), "repo");
}
#[test]
fn parse_github_canonical_recognises_known_forms() {
for url in [
"https://github.com/octocat/Hello-World.git",
"https://github.com/octocat/Hello-World",
"http://github.com/octocat/Hello-World",
"git@github.com:octocat/Hello-World.git",
"ssh://git@github.com/octocat/Hello-World.git",
] {
assert_eq!(
parse_github_canonical(url).as_deref(),
Some("octocat/Hello-World"),
"wrong canonical for {url}"
);
}
}
#[test]
fn parse_github_canonical_returns_none_for_other_hosts() {
assert!(parse_github_canonical("https://gitlab.com/group/proj").is_none());
assert!(parse_github_canonical("https://bitbucket.org/owner/repo").is_none());
}
#[test]
fn run_mission_start_rejects_missing_target() {
let err = run_mission_start("{}").unwrap_err();
assert!(err.contains("missing"), "got: {err}");
}
#[test]
fn run_mission_submit_rejects_missing_title() {
let err = run_mission_submit("{}").unwrap_err();
assert!(err.contains("missing"), "got: {err}");
}
#[test]
fn run_mission_submit_rejects_when_no_active_mission() {
if active_mission().is_some() {
return; }
let err = run_mission_submit(r#"{"title":"x"}"#).unwrap_err();
assert!(err.contains("no active mission"), "got: {err}");
}
#[test]
fn run_mission_exit_errors_when_nothing_active() {
if active_mission().is_some() {
return; }
let err = run_mission_exit().unwrap_err();
assert!(err.contains("no active mission"), "got: {err}");
}
#[test]
fn run_mission_status_returns_null_when_inactive() {
if active_mission().is_some() {
return;
}
let out = run_mission_status().unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert!(v.get("active").is_some_and(Value::is_null));
}
#[test]
fn run_mission_list_succeeds_even_when_root_absent() {
let out = run_mission_list().unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
assert!(v.get("count").is_some());
assert!(v.get("items").and_then(Value::as_array).is_some());
assert!(
v.get("orphans").and_then(Value::as_array).is_some(),
"orphans field must always be present"
);
}
#[test]
fn run_mission_list_note_present_iff_orphans_nonempty() {
let out = run_mission_list().unwrap();
let v: Value = serde_json::from_str(&out).unwrap();
let orphans = v
.get("orphans")
.and_then(Value::as_array)
.expect("orphans array");
let has_note = v.get("note").is_some();
assert_eq!(
has_note,
!orphans.is_empty(),
"note presence ({has_note}) must match orphans non-empty ({})",
!orphans.is_empty()
);
}
#[test]
fn schemas_lists_six_tools() {
let schemas = schemas();
assert_eq!(schemas.len(), 6);
let names: Vec<&str> = schemas
.iter()
.filter_map(|v| v.pointer("/function/name").and_then(Value::as_str))
.collect();
assert_eq!(
names,
[
"mission_start",
"mission_status",
"mission_list",
"mission_attach",
"mission_exit",
"mission_submit",
]
);
}
#[test]
fn run_mission_attach_rejects_missing_slug() {
let err = run_mission_attach("{}").unwrap_err();
assert!(err.contains("missing"), "got: {err}");
}
#[test]
fn run_mission_attach_rejects_invalid_slug() {
let err = run_mission_attach(r#"{"slug":"../etc"}"#).unwrap_err();
assert!(err.contains("..") || err.contains("slug"), "got: {err}");
}
#[test]
fn run_mission_attach_errors_when_marker_missing() {
if active_mission().is_some() {
return;
}
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let slug = format!("mission-attach-test-{nanos}");
let err = run_mission_attach(&format!(r#"{{"slug":"{slug}"}}"#)).unwrap_err();
assert!(
err.contains("no mission directory") || err.contains("read"),
"got: {err}"
);
}
#[test]
fn run_mission_attach_errors_when_slot_occupied() {
if let Some(m) = active_mission() {
let payload = format!(r#"{{"slug":"{}"}}"#, m.slug);
let err = run_mission_attach(&payload).unwrap_err();
assert!(err.contains("already active"), "got: {err}");
}
}
}