use super::{LlmConfig, LlmError, LlmProvider, Message, Role};
#[derive(Debug, Clone, Copy)]
pub struct CliSpec {
pub binary: &'static str,
pub provider_slug: &'static str,
pub default_model: &'static str,
pub prompt_style: PromptStyle,
pub system_flag: Option<&'static str>,
}
#[derive(Debug, Clone, Copy)]
pub enum PromptStyle {
DashP,
RunSubcommand,
}
pub mod specs {
use super::*;
pub const CLAUDE: CliSpec = CliSpec {
binary: "claude",
provider_slug: "anthropic-cli",
default_model: "claude-desktop",
prompt_style: PromptStyle::DashP,
system_flag: Some("--append-system-prompt"),
};
pub const GEMINI: CliSpec = CliSpec {
binary: "gemini",
provider_slug: "google-cli",
default_model: "gemini-desktop",
prompt_style: PromptStyle::DashP,
system_flag: None,
};
pub const CURSOR: CliSpec = CliSpec {
binary: "cursor-agent",
provider_slug: "cursor-cli",
default_model: "cursor-desktop",
prompt_style: PromptStyle::DashP,
system_flag: None,
};
pub const OPENCODE: CliSpec = CliSpec {
binary: "opencode",
provider_slug: "opencode",
default_model: "opencode-default",
prompt_style: PromptStyle::RunSubcommand,
system_flag: None,
};
pub const ALL: &[CliSpec] = &[CLAUDE, GEMINI, CURSOR, OPENCODE];
}
#[derive(Debug, Clone)]
pub struct CliConfig {
pub binary: Option<String>,
pub timeout_secs: u64,
}
impl Default for CliConfig {
fn default() -> Self {
Self {
binary: None,
timeout_secs: 25,
}
}
}
pub fn cli_providers_suppressed() -> bool {
std::env::var("NOETHER_LLM_SKIP_CLI")
.map(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
}
pub struct CliProvider {
spec: CliSpec,
config: CliConfig,
}
impl CliProvider {
pub fn new(spec: CliSpec) -> Self {
Self::with_config(spec, CliConfig::default())
}
pub fn with_config(spec: CliSpec, config: CliConfig) -> Self {
Self { spec, config }
}
pub fn binary(&self) -> &str {
self.config.binary.as_deref().unwrap_or(self.spec.binary)
}
pub fn available(&self) -> bool {
if cli_providers_suppressed() {
return false;
}
binary_runs(self.binary())
}
pub fn spec(&self) -> CliSpec {
self.spec
}
}
impl LlmProvider for CliProvider {
fn complete(&self, messages: &[Message], config: &LlmConfig) -> Result<String, LlmError> {
if cli_providers_suppressed() {
return Err(LlmError::Provider(
"CLI providers suppressed via NOETHER_LLM_SKIP_CLI".into(),
));
}
let (system_text, dialogue) = split_system_from_dialogue(messages);
let prompt = compose_prompt(&dialogue, &system_text, self.spec.system_flag);
let mut cmd = std::process::Command::new(self.binary());
match self.spec.prompt_style {
PromptStyle::DashP => {
if self.spec.binary == "claude" {
cmd.arg("--dangerously-skip-permissions");
}
if self.spec.binary == "gemini" {
cmd.arg("-y");
}
if let (Some(flag), Some(sys)) = (self.spec.system_flag, system_text.as_ref()) {
cmd.arg(flag).arg(sys);
}
if !config.model.is_empty()
&& config.model != self.spec.default_model
&& config.model != "unknown"
{
cmd.arg("--model").arg(&config.model);
}
cmd.arg("-p").arg(&prompt);
if self.spec.binary == "cursor-agent" {
cmd.arg("--output-format").arg("text");
}
}
PromptStyle::RunSubcommand => {
cmd.arg("run").arg(&prompt);
}
}
run_with_timeout(cmd, self.config.timeout_secs)
}
}
fn split_system_from_dialogue(messages: &[Message]) -> (Option<String>, Vec<String>) {
let mut system_parts: Vec<String> = Vec::new();
let mut dialogue: Vec<String> = Vec::new();
for m in messages {
match m.role {
Role::System => system_parts.push(m.content.clone()),
Role::User => dialogue.push(format!("USER: {}", m.content)),
Role::Assistant => dialogue.push(format!("ASSISTANT: {}", m.content)),
}
}
let system = if system_parts.is_empty() {
None
} else {
Some(system_parts.join("\n\n"))
};
(system, dialogue)
}
fn compose_prompt(
dialogue: &[String],
system: &Option<String>,
system_flag: Option<&str>,
) -> String {
let body = dialogue.join("\n\n");
match (system, system_flag) {
(Some(sys), None) => format!("SYSTEM: {sys}\n\n{body}"),
_ => body,
}
}
fn binary_runs(binary: &str) -> bool {
std::process::Command::new(binary)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn run_with_timeout(mut cmd: std::process::Command, timeout_secs: u64) -> Result<String, LlmError> {
let timeout = std::time::Duration::from_secs(timeout_secs);
let (tx, rx) = std::sync::mpsc::channel();
let child = std::thread::spawn(move || {
let out = cmd
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output();
let _ = tx.send(out);
});
let out = match rx.recv_timeout(timeout) {
Ok(Ok(o)) => o,
Ok(Err(e)) => return Err(LlmError::Provider(format!("CLI spawn failed: {e}"))),
Err(_) => {
return Err(LlmError::Provider(format!(
"CLI exceeded {timeout_secs}s timeout"
)))
}
};
let _ = child.join();
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(LlmError::Provider(format!(
"CLI exit {}: {}",
out.status.code().unwrap_or(-1),
stderr.trim()
)));
}
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if stdout.is_empty() {
return Err(LlmError::Provider("CLI produced empty output".into()));
}
Ok(stdout)
}
#[deprecated(note = "use CliProvider::new(specs::CLAUDE)")]
pub type ClaudeCliProvider = CliProvider;
pub fn new_claude_cli() -> CliProvider {
CliProvider::new(specs::CLAUDE)
}
#[cfg(test)]
mod tests {
use super::*;
fn provider_for(spec: CliSpec, binary_override: &str) -> CliProvider {
CliProvider::with_config(
spec,
CliConfig {
binary: Some(binary_override.into()),
timeout_secs: 2,
},
)
}
#[test]
fn missing_binary_is_not_available() {
for spec in specs::ALL {
let p = provider_for(*spec, "/nonexistent/never-here-xyz");
assert!(!p.available(), "should be unavailable for {}", spec.binary);
}
}
#[test]
fn missing_binary_completion_returns_provider_error() {
let p = provider_for(specs::CLAUDE, "/nonexistent/never-here-xyz");
let err = p
.complete(
&[Message::user("hi")],
&LlmConfig {
model: "claude-desktop".into(),
..Default::default()
},
)
.unwrap_err();
assert!(matches!(err, LlmError::Provider(_)));
}
#[test]
fn skip_cli_env_suppresses_all_providers() {
let prev = std::env::var("NOETHER_LLM_SKIP_CLI").ok();
std::env::set_var("NOETHER_LLM_SKIP_CLI", "1");
let p = provider_for(specs::CLAUDE, "/bin/true");
assert!(!p.available());
let err = p
.complete(
&[Message::user("hi")],
&LlmConfig {
model: "claude-desktop".into(),
..Default::default()
},
)
.unwrap_err();
match err {
LlmError::Provider(m) => assert!(m.contains("suppressed")),
_ => panic!("expected Provider(suppressed)"),
}
match prev {
Some(v) => std::env::set_var("NOETHER_LLM_SKIP_CLI", v),
None => std::env::remove_var("NOETHER_LLM_SKIP_CLI"),
}
}
#[test]
fn compose_prompt_inlines_system_when_no_flag() {
let body = compose_prompt(&["USER: hello".into()], &Some("be terse".into()), None);
assert!(body.contains("SYSTEM: be terse"));
assert!(body.contains("USER: hello"));
}
#[test]
fn compose_prompt_omits_inline_system_when_flag_exists() {
let body = compose_prompt(
&["USER: hi".into()],
&Some("be terse".into()),
Some("--append-system-prompt"),
);
assert!(!body.contains("SYSTEM:"));
assert!(body.contains("USER: hi"));
}
#[test]
fn all_specs_have_distinct_binaries_and_slugs() {
let binaries: std::collections::HashSet<_> = specs::ALL.iter().map(|s| s.binary).collect();
let slugs: std::collections::HashSet<_> =
specs::ALL.iter().map(|s| s.provider_slug).collect();
assert_eq!(binaries.len(), specs::ALL.len());
assert_eq!(slugs.len(), specs::ALL.len());
}
}