use anyhow::{Context, Result, anyhow};
use std::path::{Path, PathBuf};
mod chat;
mod prompt;
pub use chat::{ChatOutcome, run_ai_add};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Backend {
Claude,
Gemini,
Codex,
}
impl Backend {
pub fn cli_name(self) -> &'static str {
match self {
Backend::Claude => "claude",
Backend::Gemini => "gemini",
Backend::Codex => "codex",
}
}
pub fn is_available(self) -> bool {
resolve_cli(self.cli_name()).is_some()
}
pub fn label(self) -> &'static str {
match self {
Backend::Claude => "Claude",
Backend::Gemini => "Gemini",
Backend::Codex => "Codex",
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedCli {
pub program: PathBuf,
pub prefix_args: Vec<String>,
}
pub fn resolve_cli(name: &str) -> Option<ResolvedCli> {
if let Ok(p) = which::which(name) {
return Some(wrap_if_powershell(p));
}
#[cfg(windows)]
{
for ext in ["ps1", "cmd", "bat", "exe"] {
if let Ok(p) = which::which(format!("{name}.{ext}")) {
return Some(wrap_if_powershell(p));
}
}
}
None
}
fn wrap_if_powershell(path: PathBuf) -> ResolvedCli {
let is_ps1 = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("ps1"))
.unwrap_or(false);
if is_ps1 {
let ps_exe = if which::which("pwsh").is_ok() {
"pwsh.exe"
} else {
"powershell.exe"
};
ResolvedCli {
program: PathBuf::from(ps_exe),
prefix_args: vec![
"-NoProfile".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-File".to_string(),
path.to_string_lossy().into_owned(),
],
}
} else {
ResolvedCli {
program: path,
prefix_args: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Proposal {
pub plugin_entry_toml: String,
pub init_lua: Option<String>,
pub before_lua: Option<String>,
pub after_lua: Option<String>,
pub explanation: String,
}
pub fn ensure_cli_installed(backend: Backend) -> Result<()> {
if backend.is_available() {
return Ok(());
}
let cli = backend.cli_name();
let hint = match backend {
Backend::Claude => "https://docs.claude.com/claude-code",
Backend::Gemini => "https://ai.google.dev/gemini-api/docs/cli",
Backend::Codex => "https://github.com/openai/codex",
};
Err(anyhow!(
"AI backend `{cli}` is not on PATH. Install it first ({hint}) or pass a different `--ai` flag."
))
}
pub async fn invoke_oneshot(backend: Backend, prompt_text: &str) -> Result<String> {
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::time::{Duration, timeout};
ensure_cli_installed(backend)?;
let resolved = resolve_cli(backend.cli_name())
.ok_or_else(|| anyhow!("AI CLI `{}` is not on PATH", backend.cli_name()))?;
eprintln!(
" (prompt size: {} bytes / {} lines)",
prompt_text.len(),
prompt_text.lines().count()
);
let mut cmd = Command::new(&resolved.program);
cmd.args(&resolved.prefix_args);
match backend {
Backend::Claude | Backend::Gemini => {
cmd.arg("-p").arg("-");
}
Backend::Codex => {
cmd.arg("exec").arg("-");
}
}
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn().with_context(|| {
format!(
"failed to spawn AI CLI `{}` (is it installed and on PATH?)",
backend.cli_name()
)
})?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(prompt_text.as_bytes())
.await
.context("failed to write prompt to AI CLI stdin")?;
}
let timeout_secs = std::env::var("RVPM_AI_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(300);
let output = timeout(Duration::from_secs(timeout_secs), child.wait_with_output())
.await
.map_err(|_| {
anyhow!(
"AI CLI `{}` timed out after {timeout_secs}s. \
The chat follow-up prompt grows with conversation history; \
set RVPM_AI_TIMEOUT_SECS=600 or longer if your network is slow.",
backend.cli_name()
)
})?
.with_context(|| format!("AI CLI `{}` failed to produce output", backend.cli_name()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"AI CLI `{}` exited with status {}: {}",
backend.cli_name(),
output.status,
stderr.trim()
));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub fn parse_proposal(response: &str) -> Result<Proposal> {
let entry = extract_tag(response, "plugin_entry")
.ok_or_else(|| anyhow!("AI response missing required <rvpm:plugin_entry> tag"))?;
let init = extract_optional_lua(response, "init_lua");
let before = extract_optional_lua(response, "before_lua");
let after = extract_optional_lua(response, "after_lua");
let explanation =
extract_tag(response, "explanation").unwrap_or_else(|| "(no explanation given)".into());
Ok(Proposal {
plugin_entry_toml: entry.trim().to_string(),
init_lua: init,
before_lua: before,
after_lua: after,
explanation: explanation.trim().to_string(),
})
}
fn extract_tag(text: &str, name: &str) -> Option<String> {
let open = format!("<rvpm:{name}>");
let close = format!("</rvpm:{name}>");
let start_off = text.rfind(&open)? + open.len();
let close_off = text[start_off..].find(&close)? + start_off;
Some(text[start_off..close_off].to_string())
}
fn extract_optional_lua(text: &str, name: &str) -> Option<String> {
let body = extract_tag(text, name)?;
let trimmed = body.trim();
if trimmed.eq_ignore_ascii_case("(none)") || trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub fn validate_proposal_toml(toml_src: &str) -> Result<()> {
let value: toml::Value =
toml::from_str(toml_src).context("AI-proposed TOML failed to parse")?;
let plugins = value
.get("plugins")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow!("AI proposal missing `[[plugins]]` array"))?;
if plugins.is_empty() {
return Err(anyhow!("AI proposal contains 0 plugin entries"));
}
if plugins.len() > 1 {
return Err(anyhow!(
"AI proposed {} plugin entries; expected exactly 1 for `rvpm add`",
plugins.len()
));
}
Ok(())
}
pub async fn run_handoff(backend: Backend, prompt_text: &str) -> Result<()> {
ensure_cli_installed(backend)?;
let resolved = resolve_cli(backend.cli_name())
.ok_or_else(|| anyhow!("AI CLI `{}` is not on PATH", backend.cli_name()))?;
let mut tmp_path = std::env::temp_dir();
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
tmp_path.push(format!("rvpm-ai-prompt-{stamp}.md"));
std::fs::write(&tmp_path, prompt_text)
.with_context(|| format!("failed to write prompt to {}", tmp_path.display()))?;
eprintln!();
eprintln!(
"\u{1f4dd} Prompt saved to: {}\n\
Starting `{}` interactively. Paste the prompt or load it via your CLI's file-reading mechanism.\n",
tmp_path.display(),
backend.cli_name()
);
let label = backend.cli_name().to_string();
tokio::task::spawn_blocking(move || -> Result<()> {
let status = std::process::Command::new(&resolved.program)
.args(&resolved.prefix_args)
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.with_context(|| format!("failed to spawn AI CLI `{label}`"))?;
let _ = status; Ok(())
})
.await
.context("failed to join blocking handoff task")??;
Ok(())
}
pub async fn write_hook_files(
plugin_dir: &Path,
proposal: &Proposal,
chezmoi_enabled: bool,
) -> Result<Vec<PathBuf>> {
std::fs::create_dir_all(plugin_dir).with_context(|| {
format!(
"failed to create plugin config dir {}",
plugin_dir.display()
)
})?;
let mut written = Vec::new();
for (name, body) in [
("init.lua", proposal.init_lua.as_deref()),
("before.lua", proposal.before_lua.as_deref()),
("after.lua", proposal.after_lua.as_deref()),
] {
let Some(body) = body else { continue };
let target = plugin_dir.join(name);
if target.exists() {
eprintln!(
"\u{26a0} {} already exists, skipping AI-generated content. Apply manually if desired.",
target.display()
);
continue;
}
crate::chezmoi::write_routed(chezmoi_enabled, &target, format!("{}\n", body.trim_end()))
.await
.with_context(|| format!("failed to write {}", target.display()))?;
written.push(target);
}
Ok(written)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_proposal_extracts_required_tags() {
let response = r#"
some preamble that should be ignored
<rvpm:plugin_entry>
[[plugins]]
url = "owner/repo"
on_cmd = ["Foo"]
</rvpm:plugin_entry>
<rvpm:init_lua>
vim.g.foo = 1
</rvpm:init_lua>
<rvpm:before_lua>(none)</rvpm:before_lua>
<rvpm:after_lua>
require('foo').setup({})
</rvpm:after_lua>
<rvpm:explanation>
README shows :Foo as the entry command.
</rvpm:explanation>
"#;
let p = parse_proposal(response).unwrap();
assert!(p.plugin_entry_toml.contains("[[plugins]]"));
assert!(p.plugin_entry_toml.contains(r#"url = "owner/repo""#));
assert_eq!(p.init_lua.as_deref(), Some("vim.g.foo = 1"));
assert_eq!(p.before_lua, None, "(none) must collapse to None");
assert_eq!(p.after_lua.as_deref(), Some("require('foo').setup({})"));
assert!(p.explanation.contains("README shows"));
}
#[test]
fn parse_proposal_missing_plugin_entry_errors() {
let response = "<rvpm:explanation>nothing else</rvpm:explanation>";
assert!(parse_proposal(response).is_err());
}
#[test]
fn parse_proposal_ignores_tag_name_in_preamble() {
let response = r#"
I will populate the <rvpm:plugin_entry> tag below with the proposal.
<rvpm:plugin_entry>
[[plugins]]
url = "real/entry"
</rvpm:plugin_entry>
<rvpm:init_lua>(none)</rvpm:init_lua>
<rvpm:before_lua>(none)</rvpm:before_lua>
<rvpm:after_lua>(none)</rvpm:after_lua>
<rvpm:explanation>ok</rvpm:explanation>
"#;
let p = parse_proposal(response).unwrap();
assert!(p.plugin_entry_toml.contains("real/entry"));
assert!(!p.plugin_entry_toml.contains("populate"));
}
#[test]
fn parse_proposal_extracts_when_wrapped_in_markdown_fences() {
let response = r#"
```
<rvpm:plugin_entry>
[[plugins]]
url = "x/y"
</rvpm:plugin_entry>
<rvpm:init_lua>(none)</rvpm:init_lua>
<rvpm:before_lua>(none)</rvpm:before_lua>
<rvpm:after_lua>(none)</rvpm:after_lua>
<rvpm:explanation>ok</rvpm:explanation>
```
"#;
let p = parse_proposal(response).unwrap();
assert!(p.plugin_entry_toml.contains(r#"url = "x/y""#));
assert_eq!(p.init_lua, None);
}
#[test]
fn validate_proposal_toml_accepts_single_plugin_entry() {
let toml_src = r#"
[[plugins]]
url = "owner/repo"
on_cmd = ["Foo"]
"#;
validate_proposal_toml(toml_src).unwrap();
}
#[test]
fn validate_proposal_toml_rejects_multiple_plugin_entries() {
let toml_src = r#"
[[plugins]]
url = "a/b"
[[plugins]]
url = "c/d"
"#;
assert!(validate_proposal_toml(toml_src).is_err());
}
#[test]
fn validate_proposal_toml_rejects_invalid_syntax() {
let toml_src = "[[plugins]\nurl = ";
assert!(validate_proposal_toml(toml_src).is_err());
}
#[test]
fn validate_proposal_toml_rejects_no_plugins_array() {
let toml_src = r#"name = "ignored""#;
assert!(validate_proposal_toml(toml_src).is_err());
}
#[test]
fn wrap_if_powershell_wraps_ps1_path() {
let p = std::path::PathBuf::from("C:/foo/gemini.ps1");
let r = wrap_if_powershell(p);
let prog = r.program.to_string_lossy().to_ascii_lowercase();
assert!(
prog == "pwsh.exe" || prog == "powershell.exe",
"expected pwsh.exe or powershell.exe, got {prog}"
);
assert!(r.prefix_args.iter().any(|a| a == "-File"));
assert!(r.prefix_args.iter().any(|a| a.contains("gemini.ps1")));
assert!(r.prefix_args.iter().any(|a| a == "Bypass"));
assert!(r.prefix_args.iter().any(|a| a == "-NoProfile"));
}
#[test]
fn wrap_if_powershell_passes_exe_through() {
let p = std::path::PathBuf::from("C:/foo/claude.exe");
let r = wrap_if_powershell(p.clone());
assert_eq!(r.program, p);
assert!(r.prefix_args.is_empty());
}
#[test]
fn wrap_if_powershell_passes_unix_path_through() {
let p = std::path::PathBuf::from("/usr/local/bin/codex");
let r = wrap_if_powershell(p.clone());
assert_eq!(r.program, p);
assert!(r.prefix_args.is_empty());
}
#[tokio::test]
async fn write_hook_files_writes_only_present_lua_blocks() {
let tmp = tempfile::tempdir().unwrap();
let plugin_dir = tmp
.path()
.join("plugins")
.join("github.com")
.join("o")
.join("r");
let p = Proposal {
plugin_entry_toml: r#"[[plugins]]
url = "o/r""#
.to_string(),
init_lua: Some("vim.g.x = 1".to_string()),
before_lua: None,
after_lua: Some("require('o').setup({})".to_string()),
explanation: "test".to_string(),
};
let written = write_hook_files(&plugin_dir, &p, false).await.unwrap();
assert_eq!(written.len(), 2);
assert!(plugin_dir.join("init.lua").exists());
assert!(!plugin_dir.join("before.lua").exists());
assert!(plugin_dir.join("after.lua").exists());
}
#[test]
fn extract_optional_lua_collapses_none_marker() {
let resp = "<rvpm:init_lua> (none) </rvpm:init_lua>";
assert_eq!(extract_optional_lua(resp, "init_lua"), None);
}
#[test]
fn extract_optional_lua_keeps_real_content() {
let resp = "<rvpm:init_lua>vim.g.x = 1</rvpm:init_lua>";
assert_eq!(
extract_optional_lua(resp, "init_lua").as_deref(),
Some("vim.g.x = 1")
);
}
}