use async_trait::async_trait;
use tokio::sync::{Semaphore, SemaphorePermit};
use crate::ai::{AiRequest, DEFAULT_SYSTEM_PROMPT, run_handoff};
use crate::applied::Decision;
use crate::error::{Error, Result};
use crate::interactive::{AiDecision, prompt_ai_decision};
use crate::manifest::AiMode;
use super::{
ActionContext, ActionOutcome, ActionPlan, ApplyMode, OutcomeKind, PlanKind, unified_diff,
};
const MAX_CHAT_TURNS: usize = 8;
pub struct Ai;
#[async_trait]
impl ApplyMode for Ai {
async fn plan(&self, ctx: &ActionContext<'_>) -> Result<ActionPlan> {
let kind = match &ctx.current_body {
None => PlanKind::Create,
Some(_) => PlanKind::Update,
};
Ok(ActionPlan { kind, diff: None })
}
async fn execute(&self, ctx: &ActionContext<'_>, dry_run: bool) -> Result<ActionOutcome> {
if dry_run {
return Ok(skipped(Decision::Defer, None));
}
if !ctx.interactive && !ctx.yes_all {
return Ok(skipped(Decision::Defer, None));
}
let Some(agent) = ctx.agent.clone() else {
const HINT: &str = "no AI agent available (try `--ai claude` / install one of \
claude / codex / gemini, or pass `--no-ai`)";
eprintln!(" ai skip {}: {HINT}", ctx.dst_abs);
return Ok(skipped(Decision::Defer, Some(HINT.into())));
};
let resolved_mode = ctx
.ai_mode_override
.or(ctx.spec.ai_mode)
.unwrap_or_default();
if matches!(resolved_mode, AiMode::Handoff) {
let Some(backend) = ctx.agent_backend else {
return Ok(skipped(
Decision::Defer,
Some("handoff requested but no resolved AI backend was available".into()),
));
};
let user_prompt = compose_user_prompt(ctx.ai_prompt, ctx.spec.prompt.as_deref());
let handoff_prompt = build_handoff_prompt_initial(&user_prompt, ctx);
let _permit = acquire_ai_permit(&ctx.ai_sema).await?;
run_handoff(backend, &handoff_prompt, ctx.dst_abs.as_std_path()).await?;
return Ok(skipped(Decision::Defer, None));
}
let base_prompt = compose_user_prompt(ctx.ai_prompt, ctx.spec.prompt.as_deref());
let mut user_prompt = base_prompt.clone();
for turn in 0..=MAX_CHAT_TURNS {
let req = AiRequest {
system_prompt: DEFAULT_SYSTEM_PROMPT.to_string(),
user_prompt: user_prompt.clone(),
current: ctx.current_body.clone(),
incoming: ctx.rendered_body.clone(),
template_diff: None,
dst: ctx.dst_abs.clone(),
};
let response = {
let _permit = acquire_ai_permit(&ctx.ai_sema).await?;
agent.run(req).await?
};
let body = match response.full_body {
Some(b) => b,
None => {
return Ok(failed(
"AI response did not include a <kata:body>...</kata:body> block; \
leaving destination untouched",
));
}
};
if ctx.current_body.as_deref() == Some(body.as_str()) {
return Ok(ActionOutcome {
kind: OutcomeKind::Unchanged,
decision: Some(Decision::Accept),
diff: None,
error: None,
});
}
let diff = unified_diff(
ctx.current_body.as_deref().unwrap_or(""),
&body,
ctx.dst_abs.as_str(),
);
if !ctx.interactive {
return write_and_return(ctx, &body, diff).await;
}
eprintln!("\n=== AI proposal for {} ===", ctx.dst_abs);
eprintln!("{diff}");
match prompt_ai_decision(ctx.dst_abs.as_str())? {
AiDecision::Accept => {
return write_and_return(ctx, &body, diff).await;
}
AiDecision::Edit => {
let edited = edit_in_editor(ctx.dst_abs.as_str(), &body)?;
if edited == body {
return write_and_return(ctx, &body, diff).await;
}
let new_diff = unified_diff(
ctx.current_body.as_deref().unwrap_or(""),
&edited,
ctx.dst_abs.as_str(),
);
return write_and_return(ctx, &edited, new_diff).await;
}
AiDecision::Handoff => {
let Some(backend) = ctx.agent_backend else {
return Ok(skipped(
Decision::Defer,
Some(
"handoff requested but no resolved AI backend was available".into(),
),
));
};
let handoff_prompt = build_handoff_prompt(&user_prompt, ctx, &body);
let _permit = acquire_ai_permit(&ctx.ai_sema).await?;
run_handoff(backend, &handoff_prompt, ctx.dst_abs.as_std_path()).await?;
return Ok(skipped(Decision::Defer, None));
}
AiDecision::Skip => {
return Ok(skipped(Decision::Skip, None));
}
AiDecision::Defer => {
return Ok(skipped(Decision::Defer, None));
}
AiDecision::Chat(instr) => {
if turn == MAX_CHAT_TURNS {
eprintln!(
" ai chat {}: {MAX_CHAT_TURNS} refinements reached; deferring",
ctx.dst_abs
);
return Ok(skipped(Decision::Defer, None));
}
user_prompt = format!(
"{base_prompt}\n\n[prior AI proposal]\n{body}\n\n[user refinement]\n{instr}"
);
continue;
}
}
}
Ok(skipped(Decision::Defer, None))
}
}
fn skipped(decision: Decision, error: Option<String>) -> ActionOutcome {
ActionOutcome {
kind: OutcomeKind::Skipped,
decision: Some(decision),
diff: None,
error,
}
}
fn failed(msg: &str) -> ActionOutcome {
ActionOutcome {
kind: OutcomeKind::Failed,
decision: None,
diff: None,
error: Some(msg.into()),
}
}
async fn write_and_return(
ctx: &ActionContext<'_>,
body: &str,
diff: String,
) -> Result<ActionOutcome> {
if let Some(parent) = ctx.dst_abs.parent() {
tokio::fs::create_dir_all(parent.as_std_path())
.await
.map_err(|e| Error::io_at(parent.as_std_path(), e))?;
}
tokio::fs::write(ctx.dst_abs.as_std_path(), body)
.await
.map_err(|e| Error::io_at(ctx.dst_abs.as_std_path(), e))?;
Ok(ActionOutcome {
kind: OutcomeKind::Wrote,
decision: Some(Decision::Accept),
diff: Some(diff),
error: None,
})
}
fn compose_user_prompt(run_wide: Option<&str>, per_file: Option<&str>) -> String {
let r = run_wide.map(str::trim).filter(|s| !s.is_empty());
let p = per_file.map(str::trim).filter(|s| !s.is_empty());
match (r, p) {
(Some(r), Some(p)) => {
format!("[run-wide instruction]\n{r}\n\n[per-file instruction]\n{p}")
}
(Some(r), None) => r.to_string(),
(None, Some(p)) => p.to_string(),
(None, None) => String::new(),
}
}
fn edit_in_editor(dst_label: &str, body: &str) -> Result<String> {
use std::io::Write as _;
let editor = std::env::var("VISUAL")
.ok()
.filter(|s| !s.trim().is_empty())
.or_else(|| std::env::var("EDITOR").ok())
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| {
if cfg!(windows) {
"notepad".to_string()
} else {
"vi".to_string()
}
});
let suffix = std::path::Path::new(dst_label)
.extension()
.and_then(|e| e.to_str())
.map(|e| format!(".{e}"))
.unwrap_or_else(|| ".tmp".to_string());
let mut tmp = tempfile::Builder::new()
.prefix("kata-ai-edit-")
.suffix(&suffix)
.tempfile()
.map_err(|e| {
Error::Other(anyhow::Error::from(e).context("creating tmp file for $EDITOR"))
})?;
tmp.write_all(body.as_bytes()).map_err(|e| {
Error::Other(anyhow::Error::from(e).context("seeding tmp file with AI body"))
})?;
let path = tmp.into_temp_path();
let mut parts = editor.split_whitespace();
let prog = parts.next().ok_or_else(|| {
Error::Other(anyhow::anyhow!(
"editor command resolved to an empty string after trimming"
))
})?;
let extra_args: Vec<&str> = parts.collect();
let status = std::process::Command::new(prog)
.args(&extra_args)
.arg(path.as_os_str())
.status()
.map_err(|e| {
Error::Other(
anyhow::Error::from(e).context(format!("failed to spawn editor `{editor}`")),
)
})?;
if !status.success() {
return Err(Error::Other(anyhow::anyhow!(
"editor `{editor}` exited with status {status} — leaving destination untouched",
)));
}
let edited = std::fs::read_to_string(&path)
.map_err(|e| Error::Other(anyhow::Error::from(e).context("reading edited tmp file")))?;
Ok(edited)
}
async fn acquire_ai_permit(sema: &Semaphore) -> Result<SemaphorePermit<'_>> {
sema.acquire().await.map_err(|e| {
Error::Other(anyhow::Error::from(e).context("acquiring AI concurrency permit"))
})
}
fn build_handoff_prompt_initial(user_prompt: &str, ctx: &ActionContext<'_>) -> String {
let current = ctx.current_body.as_deref().unwrap_or("");
format!(
"{user_prompt}\n\n\
[destination]\n{dst}\n\n\
[current contents on disk]\n{current}\n\n\
[freshly-rendered template body]\n{incoming}\n\n\
The user has chosen handoff: kata will not re-import your output. \
Use your own Edit / Write tools to merge the rendered template into \
the destination directly.",
dst = ctx.dst_abs,
incoming = ctx.rendered_body,
)
}
fn build_handoff_prompt(user_prompt: &str, ctx: &ActionContext<'_>, body: &str) -> String {
let current = ctx.current_body.as_deref().unwrap_or("");
format!(
"{user_prompt}\n\n\
[destination]\n{dst}\n\n\
[current contents on disk]\n{current}\n\n\
[latest AI proposal — kata is handing this conversation off to you]\n{body}\n\n\
The user has chosen handoff: kata will not re-import your output. \
Use your own Edit / Write tools to update the destination directly.",
dst = ctx.dst_abs,
)
}
#[cfg(test)]
mod tests {
use super::compose_user_prompt;
#[test]
fn compose_user_prompt_returns_empty_when_neither_provided() {
assert_eq!(compose_user_prompt(None, None), "");
assert_eq!(compose_user_prompt(Some(""), Some("")), "");
}
#[test]
fn compose_user_prompt_returns_only_run_wide_when_per_file_missing() {
assert_eq!(
compose_user_prompt(Some("respond in Japanese"), None),
"respond in Japanese"
);
assert_eq!(
compose_user_prompt(Some("respond in Japanese"), Some("")),
"respond in Japanese"
);
}
#[test]
fn compose_user_prompt_returns_only_per_file_when_run_wide_missing() {
assert_eq!(
compose_user_prompt(None, Some("merge CLAUDE.md")),
"merge CLAUDE.md"
);
}
#[test]
fn compose_user_prompt_combines_both_in_labelled_blocks() {
let out = compose_user_prompt(Some("be terse"), Some("merge CLAUDE.md"));
assert!(
out.contains("[run-wide instruction]\nbe terse"),
"missing run-wide block: {out}"
);
assert!(
out.contains("[per-file instruction]\nmerge CLAUDE.md"),
"missing per-file block: {out}"
);
}
#[test]
fn compose_user_prompt_trims_whitespace_only_inputs_to_empty() {
assert_eq!(compose_user_prompt(Some(" \n "), None), "");
assert_eq!(compose_user_prompt(None, Some("\t\n")), "");
assert_eq!(
compose_user_prompt(Some(" hi "), Some("\nbye\n")),
"[run-wide instruction]\nhi\n\n[per-file instruction]\nbye"
);
}
}