diff --git a/README.md b/README.md index d478142..67af7e9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ The two binaries are: qqqa runs on macOS, Linux, and Windows. -By default the repo includes profiles for OpenRouter (default), OpenAI, Groq, a local Ollama runtime, and the Codex CLI (so you can piggyback on a paid ChatGPT subscription). An Anthropic profile stub exists in the config for future work but is not wired up yet. +By default the repo includes profiles for OpenRouter (default), OpenAI, Groq, a local Ollama runtime, the Codex CLI (piggyback on ChatGPT), and the Claude Code CLI (reuse your Claude subscription). An Anthropic profile stub exists in the config for future work but is not wired up yet. @@ -72,6 +72,29 @@ Example `~/.qq/config.json` fragment that pins Codex as the default profile: } ``` +### Claude Code CLI profile (bring-your-own Claude desktop subscription) + +Have a Claude subscription? Select the `claude_cli` profile and qqqa will use the `claude` binary. That keeps usage effectively free if you already pay for Claude for Desktop. + +What to know: + +- Install Claude Code so the `claude` binary is on your `PATH`, then run `claude login` once. +- Claude Code streams responses the same way API-based LLMs do. + +Minimal config snippet: + +```json +{ + "default_profile": "claude_cli", + "profiles": { + "claude_cli": { + "model_provider": "claude_cli", + "model": "sonnet" + } + } +} +``` + ## Features - OpenAI compatible API client with streaming and non streaming calls. @@ -121,6 +144,7 @@ The initializer lets you choose the default provider: - Anthropic + `claude-3-5-sonnet-20241022` (placeholder until their Messages API finalizes) - Ollama (runs locally, adjust port if needed) - Codex CLI + `gpt-5` (wraps the `codex exec` binary so you can reuse a ChatGPT subscription; no API key needed, buffered output only) +- Claude Code CLI + `sonnet` (wraps the `claude` binary; `qq` streams live, `qa` buffers so it can parse tool calls) It also offers to store an API key in the config (optional). If you prefer environment variables, leave it blank and set one of: @@ -128,7 +152,7 @@ It also offers to store an API key in the config (optional). If you prefer envir - `GROQ_API_KEY` for Groq - `OPENAI_API_KEY` for OpenAI - `OLLAMA_API_KEY` (optional; any non-empty string works—even `local`—because the Authorization header cannot be blank) -- No API key is required for the Codex CLI profile—the ChatGPT desktop app or `codex` binary handles auth. +- No API key is required for the Codex or Claude CLI profiles—their binaries handle auth (`codex login` / `claude login`). Defaults written to `~/.qq/config.json`: @@ -138,6 +162,8 @@ Defaults written to `~/.qq/config.json`: - `groq` → base `https://api.groq.com/openai/v1`, env `GROQ_API_KEY` - `ollama` → base `http://127.0.0.1:11434/v1`, env `OLLAMA_API_KEY` (qqqa auto-injects a non-empty placeholder if you leave it unset) - `anthropic` → base `https://api.anthropic.com/v1`, env `ANTHROPIC_API_KEY` (present in the config schema for future support; not usable yet) + - `codex` → mode `cli`, binary `codex` with base args `exec` (install Codex CLI; auth handled by `codex login`) + - `claude_cli` → mode `cli`, binary `claude` (install `@anthropic-ai/claude-code`; auth handled by `claude login`) - `codex` → CLI provider, binary `codex` - fails if the binary is missing - Profiles - `openrouter` → model `openai/gpt-4.1-nano` (default) diff --git a/src/ai.rs b/src/ai.rs index d86740a..93e4cd7 100644 --- a/src/ai.rs +++ b/src/ai.rs @@ -576,7 +576,7 @@ fn find_single_newline(buf: &[u8]) -> Option { mod cli_backend { use super::*; use std::process::Stdio; - use tokio::io::AsyncWriteExt; + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; #[derive(Debug)] @@ -594,6 +594,23 @@ mod cli_backend { pub async fn run_cli_completion(req: CliCompletionRequest<'_>) -> Result { match req.engine { CliEngine::Codex => run_codex(req).await, + CliEngine::Claude => run_claude(req).await, + } + } + + pub async fn run_cli_completion_streaming( + req: CliCompletionRequest<'_>, + on_token: F, + ) -> Result + where + F: FnMut(&str) + Send, + { + match req.engine { + CliEngine::Claude => run_claude_streaming(req, on_token).await, + CliEngine::Codex => Err(anyhow!( + "CLI provider '{}' does not support streaming", + req.binary + )), } } @@ -614,13 +631,11 @@ mod cli_backend { cmd.arg("tools.web_search=false"); cmd.arg("-"); - let mut prompt = String::new(); - prompt.push_str("\n"); - prompt.push_str(req.system_prompt); - prompt.push_str("\n\n\n"); - prompt.push_str("\n"); - prompt.push_str(req.user_prompt); - prompt.push_str("\n\n"); + let prompt = format!( + "{}\n\n{}\n", + tagged_system_prompt(req.system_prompt), + tagged_user_prompt(req.user_prompt) + ); if req.debug { eprintln!( @@ -678,6 +693,194 @@ mod cli_backend { parse_codex_response(&stdout) } + async fn run_claude(req: CliCompletionRequest<'_>) -> Result { + let mut cmd = build_claude_command(&req, false); + + if req.debug { + eprintln!( + "[debug] Running CLI provider '{}' with args: {:?}", + req.binary, cmd + ); + } + + let output = cmd + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .with_context(|| { + format!( + "Failed to spawn CLI provider '{}'. Is it installed and on your PATH?", + req.binary + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(anyhow!( + "CLI provider '{}' exited with status {}.{}{}", + req.binary, + output + .status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "signal".to_string()), + if stdout.trim().is_empty() { + "".to_string() + } else { + format!("\nstdout: {}", stdout.trim()) + }, + if stderr.trim().is_empty() { + "".to_string() + } else { + format!("\nstderr: {}", stderr.trim()) + } + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_claude_response(&stdout) + } + + async fn run_claude_streaming( + req: CliCompletionRequest<'_>, + mut on_token: F, + ) -> Result + where + F: FnMut(&str) + Send, + { + let mut cmd = build_claude_command(&req, true); + + if req.debug { + eprintln!( + "[debug] Running CLI provider '{}' with args: {:?}", + req.binary, cmd + ); + } + + let mut child = cmd + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| { + format!( + "Failed to spawn CLI provider '{}'. Is it installed and on your PATH?", + req.binary + ) + })?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("CLI provider '{}' did not expose stdout", req.binary))?; + let stderr = child.stderr.take().map(|stderr| { + tokio::spawn(async move { + let mut reader = BufReader::new(stderr); + let mut buf = Vec::new(); + reader.read_to_end(&mut buf).await.ok(); + String::from_utf8_lossy(&buf).trim().to_string() + }) + }); + + let mut reader = BufReader::new(stdout).lines(); + let mut aggregated = String::new(); + let mut fallback = None; + + while let Some(line) = reader.next_line().await? { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + match parse_claude_stream_line(trimmed) { + Ok((token, result_text)) => { + if let Some(t) = token { + if !t.is_empty() { + aggregated.push_str(&t); + on_token(&t); + } + } + if let Some(res) = result_text { + if !res.trim().is_empty() { + fallback = Some(res); + } + } + } + Err(e) => { + if req.debug { + eprintln!( + "[debug] Failed to parse Claude stream line '{}': {}", + trimmed, e + ); + } + } + } + } + + let status = child.wait().await?; + let stderr = if let Some(handle) = stderr { + match handle.await { + Ok(s) => s, + Err(_) => String::new(), + } + } else { + String::new() + }; + + if !status.success() { + return Err(anyhow!( + "CLI provider '{}' exited with status {}.{}", + req.binary, + status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "signal".to_string()), + if stderr.is_empty() { + String::new() + } else { + format!("\nstderr: {}", stderr) + } + )); + } + + if aggregated.is_empty() { + if let Some(res) = fallback { + return Ok(res); + } + return Err(anyhow!("CLI provider returned no stream tokens.")); + } + + Ok(aggregated) + } + + fn build_claude_command(req: &CliCompletionRequest<'_>, streaming: bool) -> Command { + let mut cmd = Command::new(req.binary); + if !req.base_args.is_empty() { + cmd.args(req.base_args.iter().filter(|arg| !arg.trim().is_empty())); + } + cmd.arg("-p"); + if streaming { + cmd.arg("--verbose"); + cmd.arg("--output-format"); + cmd.arg("stream-json"); + cmd.arg("--include-partial-messages"); + } else { + cmd.arg("--output-format"); + cmd.arg("json"); + } + if !req.model.trim().is_empty() { + cmd.arg("--model"); + cmd.arg(req.model); + } + cmd.arg("--append-system-prompt"); + cmd.arg(tagged_system_prompt(req.system_prompt)); + cmd.arg("--disallowed-tools"); + cmd.arg("Bash(*) Edit"); + cmd.arg("--"); + cmd.arg(tagged_user_prompt(req.user_prompt)); + cmd + } + #[derive(Debug, Deserialize)] struct CodexEvent { #[serde(rename = "type")] @@ -726,17 +929,175 @@ mod cli_backend { Ok(messages.join("\n\n")) } + fn parse_claude_response(stdout: &str) -> Result { + let value = parse_single_json_value(stdout) + .with_context(|| "CLI provider returned non-JSON output")?; + + if let Some(result_field) = value.get("result") { + if let Some(text) = extract_text(result_field) { + return Ok(text); + } + } + + if let Some(text) = extract_text(&value) { + return Ok(text); + } + + Err(anyhow!( + "CLI provider returned JSON without a usable message." + )) + } + + #[derive(Debug, Deserialize)] + struct ClaudeStreamEnvelope { + #[serde(rename = "type")] + event_type: String, + #[serde(default)] + event: Option, + #[serde(default)] + result: Option, + } + + #[derive(Debug, Deserialize)] + struct ClaudeStreamEvent { + #[serde(rename = "type")] + event_type: String, + #[serde(default)] + delta: Option, + } + + #[derive(Debug, Deserialize)] + struct ClaudeStreamDelta { + #[serde(default)] + text: Option, + } + + fn parse_claude_stream_line( + line: &str, + ) -> Result<(Option, Option), serde_json::Error> { + let env: ClaudeStreamEnvelope = serde_json::from_str(line)?; + let mut token = None; + let mut result_text = None; + + if env.event_type == "stream_event" { + if let Some(event) = env.event { + if event.event_type == "content_block_delta" { + if let Some(delta) = event.delta { + if let Some(text) = delta.text { + if !text.is_empty() { + token = Some(text); + } + } + } + } + } + } else if env.event_type == "result" { + if let Some(result) = env.result { + if let Some(text) = extract_text(&result) { + result_text = Some(text); + } + } + } + + Ok((token, result_text)) + } + + fn parse_single_json_value(stdout: &str) -> Result { + let trimmed = stdout.trim(); + if !trimmed.is_empty() { + if let Ok(value) = serde_json::from_str::(trimmed) { + return Ok(value); + } + } + + for line in stdout.lines().rev() { + let candidate = line.trim(); + if candidate.is_empty() { + continue; + } + if let Ok(value) = serde_json::from_str::(candidate) { + return Ok(value); + } + } + + Err(anyhow!("Unable to parse CLI JSON output")) + } + + fn extract_text(value: &Value) -> Option { + match value { + Value::String(s) => { + if s.trim().is_empty() { + None + } else { + Some(s.to_string()) + } + } + Value::Array(items) => { + let mut parts = Vec::new(); + for item in items { + if let Some(text) = extract_text(item) { + parts.push(text); + } + } + if parts.is_empty() { + None + } else { + Some(parts.join("\n\n")) + } + } + Value::Object(map) => { + if let Some(text) = map.get("text").and_then(|t| t.as_str()) { + if !text.trim().is_empty() { + return Some(text.to_string()); + } + } + for key in ["content", "messages", "output_text", "result"] { + if let Some(val) = map.get(key) { + if let Some(text) = extract_text(val) { + return Some(text); + } + } + } + None + } + _ => None, + } + } + + fn tagged_system_prompt(prompt: &str) -> String { + format!("\n{}\n", prompt) + } + + fn tagged_user_prompt(prompt: &str) -> String { + format!("\n{}\n", prompt) + } + #[cfg(test)] pub(super) fn parse_codex_response_for_test(input: &str) -> Result { parse_codex_response(input) } + + #[cfg(test)] + pub(super) fn parse_claude_response_for_test(input: &str) -> Result { + parse_claude_response(input) + } + + #[cfg(test)] + pub(super) fn parse_claude_stream_line_for_test( + line: &str, + ) -> Result<(Option, Option), serde_json::Error> { + parse_claude_stream_line(line) + } } -pub use cli_backend::{CliCompletionRequest, run_cli_completion}; +pub use cli_backend::{CliCompletionRequest, run_cli_completion, run_cli_completion_streaming}; #[cfg(test)] mod tests { - use super::cli_backend::parse_codex_response_for_test; + use super::cli_backend::{ + parse_claude_response_for_test, parse_claude_stream_line_for_test, + parse_codex_response_for_test, + }; use super::load_root_certificates; use rcgen::{CertifiedKey, generate_simple_self_signed}; use std::fs; @@ -778,4 +1139,34 @@ mod tests { let parsed = parse_codex_response_for_test(&merged).expect("parse"); assert_eq!(parsed, "First\n\nSecond"); } + + #[test] + fn claude_parser_reads_result_string() { + let payload = r#"{"type":"result","subtype":"success","result":"echo hi"}"#; + let parsed = parse_claude_response_for_test(payload).expect("parse"); + assert_eq!(parsed, "echo hi"); + } + + #[test] + fn claude_parser_walks_nested_messages() { + let payload = r#"{"type":"result","subtype":"success","result":{"messages":[{"content":[{"type":"text","text":"Hello"},{"type":"text","text":"World"}]}]}}"#; + let parsed = parse_claude_response_for_test(payload).expect("parse"); + assert_eq!(parsed, "Hello\n\nWorld"); + } + + #[test] + fn claude_stream_parser_emits_deltas() { + let payload = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"echo hi"}}}"#; + let (token, result) = parse_claude_stream_line_for_test(payload).expect("parse"); + assert_eq!(token.as_deref(), Some("echo hi")); + assert!(result.is_none()); + } + + #[test] + fn claude_stream_parser_handles_result_objects() { + let payload = r#"{"type":"result","result":{"messages":[{"content":[{"type":"text","text":"All done"}]}]}}"#; + let (token, result) = parse_claude_stream_line_for_test(payload).expect("parse"); + assert!(token.is_none()); + assert_eq!(result.as_deref(), Some("All done")); + } } diff --git a/src/bin/qq.rs b/src/bin/qq.rs index 814c26c..385d0b3 100644 --- a/src/bin/qq.rs +++ b/src/bin/qq.rs @@ -1,6 +1,8 @@ use anyhow::{Result, anyhow}; use clap::{ArgAction, Parser}; -use qqqa::ai::{ChatClient, CliCompletionRequest, Msg, run_cli_completion}; +use qqqa::ai::{ + ChatClient, CliCompletionRequest, Msg, run_cli_completion, run_cli_completion_streaming, +}; use qqqa::clipboard; use qqqa::config::{Config, InitExistsError, ProviderConnection}; use qqqa::formatting::{ @@ -309,14 +311,7 @@ async fn main() -> Result<()> { } } (ProviderConnection::Cli(cli_conn), _) => { - if !cli.no_stream && cli.debug { - eprintln!( - "[debug] CLI provider '{}' does not support streaming; buffering output.", - eff.provider_key - ); - } - let loading = start_loading_animation(); - let response = run_cli_completion(CliCompletionRequest { + let make_request = || CliCompletionRequest { engine: cli_conn.engine, binary: &cli_conn.binary, base_args: &cli_conn.base_args, @@ -325,12 +320,60 @@ async fn main() -> Result<()> { model: &eff.model, reasoning_effort: eff.reasoning_effort.as_deref(), debug: cli.debug, - }) - .await?; - drop(loading); - println!(""); - print_assistant_text(&response, cli.raw); - maybe_copy_first_command(&response, copy_enabled, cli.raw, cli.debug); + }; + + let streaming_enabled = !cli.no_stream && cli_conn.engine.supports_streaming(); + + if streaming_enabled { + println!(""); + if cli.raw { + let mut collected = String::new(); + let fallback = run_cli_completion_streaming(make_request(), |tok| { + collected.push_str(tok); + print_stream_token(tok); + }) + .await?; + if collected.is_empty() { + collected = fallback; + } + println!(""); + maybe_copy_first_command(&collected, copy_enabled, cli.raw, cli.debug); + } else { + let mut formatter = StreamingFormatter::new(); + let mut writer = PrettyStreamWriter::new(); + let mut collected = String::new(); + let fallback = run_cli_completion_streaming(make_request(), |tok| { + collected.push_str(tok); + if let Some(delta) = formatter.push(tok) { + writer.write(&delta); + } + }) + .await?; + if let Some(tail) = formatter.flush() { + if !tail.is_empty() { + writer.write(&tail); + } + } + if collected.is_empty() { + collected = fallback; + } + println!(""); + maybe_copy_first_command(&collected, copy_enabled, cli.raw, cli.debug); + } + } else { + if !cli.no_stream && cli.debug { + eprintln!( + "[debug] CLI provider '{}' does not support streaming; buffering output.", + eff.provider_key + ); + } + let loading = start_loading_animation(); + let response = run_cli_completion(make_request()).await?; + drop(loading); + println!(""); + print_assistant_text(&response, cli.raw); + maybe_copy_first_command(&response, copy_enabled, cli.raw, cli.debug); + } } _ => unreachable!("Provider/client mismatch"), } diff --git a/src/config.rs b/src/config.rs index 9009676..69f7bc0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -39,6 +39,13 @@ impl Default for ProviderMode { #[serde(rename_all = "snake_case")] pub enum CliEngine { Codex, + Claude, +} + +impl CliEngine { + pub fn supports_streaming(self) -> bool { + matches!(self, CliEngine::Claude) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -205,6 +212,23 @@ impl Default for Config { }), }, ); + model_providers.insert( + "claude_cli".to_string(), + ModelProvider { + name: "Claude Code CLI".to_string(), + base_url: "cli://claude".to_string(), + env_key: "CLAUDE_CLI_API_KEY".to_string(), + api_key: None, + local: true, + tls: None, + mode: ProviderMode::Cli, + cli: Some(CliProviderConfig { + engine: CliEngine::Claude, + binary: "claude".to_string(), + base_args: Vec::new(), + }), + }, + ); let mut profiles = HashMap::new(); profiles.insert( @@ -267,6 +291,16 @@ impl Default for Config { timeout: None, }, ); + profiles.insert( + "claude_cli".to_string(), + Profile { + model_provider: "claude_cli".to_string(), + model: "sonnet".to_string(), + reasoning_effort: None, + temperature: None, + timeout: None, + }, + ); Self { default_profile: "openrouter".to_string(), @@ -569,7 +603,10 @@ impl Config { println!( " [6] Codex CLI — leverage the installed `codex` binary (uses your ChatGPT subscription)" ); - print!("Enter 1, 2, 3, 4, 5, or 6 [1]: "); + println!( + " [7] Claude Code CLI — use the local `claude` binary (Claude desktop / npm package)" + ); + print!("Enter 1, 2, 3, 4, 5, 6, or 7 [1]: "); io::stdout().flush().ok(); let mut choice = String::new(); io::stdin().read_line(&mut choice).ok(); @@ -580,6 +617,9 @@ impl Config { "4" | "anthropic" => cfg.default_profile = "anthropic".to_string(), "5" | "ollama" => cfg.default_profile = "ollama".to_string(), "6" | "codex" => cfg.default_profile = "codex".to_string(), + "7" | "claude" | "claude-cli" | "claude_code" => { + cfg.default_profile = "claude_cli".to_string() + } "1" | "openrouter" => cfg.default_profile = "openrouter".to_string(), _ => cfg.default_profile = "openrouter".to_string(), } @@ -643,19 +683,44 @@ impl Config { } } } else { - let binary = cfg + let (binary, instructions, auth_note) = cfg .model_providers .get(&provider_key) .and_then(|mp| mp.cli.as_ref()) .map(|cli| cli.binary.clone()) - .unwrap_or_else(|| "codex".to_string()); - println!( - "\n{} uses the '{}' CLI binary. Install Codex CLI (part of the ChatGPT desktop app or `pip install codex-cli`) and ensure it is on your PATH.", - provider.name, binary - ); - println!( - "No API key is required; authentication happens through the Codex CLI itself." - ); + .map(|bin| { + let install = match provider_key.as_str() { + "codex" => format!( + "Install Codex CLI (ChatGPT desktop or `pip install codex-cli`) and ensure '{}' is on your PATH.", + bin + ), + "claude_cli" => format!( + "Install Claude Code (`npm install -g @anthropic-ai/claude-code`) so '{}' is available on your PATH.", + bin + ), + _ => format!( + "Ensure the '{}' CLI binary is installed and on your PATH.", + bin + ), + }; + let auth = match provider_key.as_str() { + "codex" => "No API key is required; the Codex CLI handles auth using your ChatGPT subscription.", + "claude_cli" => "No API key is required; run `claude login` so the CLI can reuse your Claude subscription.", + _ => "Authentication is handled by the CLI runtime itself.", + } + .to_string(); + (bin, install, auth) + }) + .unwrap_or_else(|| { + ( + "codex".to_string(), + "Ensure the CLI binary is installed and on your PATH.".to_string(), + "Authentication is handled by the CLI runtime itself.".to_string(), + ) + }); + println!("\n{} uses the '{}' CLI binary.", provider.name, binary); + println!("{}", instructions); + println!("{}", auth_note); } println!("\nShare recent `qq` / `qa` commands with the model?"); diff --git a/tests/cli_backend_tests.rs b/tests/cli_backend_tests.rs index c6b6825..d88c724 100644 --- a/tests/cli_backend_tests.rs +++ b/tests/cli_backend_tests.rs @@ -4,8 +4,35 @@ use qqqa::ai::{CliCompletionRequest, run_cli_completion}; use qqqa::config::CliEngine; use std::fs; use std::os::unix::fs::PermissionsExt; +use std::path::Path; use tempfile::tempdir; +fn read_args(path: &Path) -> Vec { + let data = fs::read(path).expect("args file"); + data.split(|b| *b == 0) + .filter(|chunk| !chunk.is_empty()) + .map(|chunk| String::from_utf8_lossy(chunk).to_string()) + .collect() +} + +fn write_executable_script(path: &Path, contents: &str) { + use std::io::Write; + + let mut file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path) + .expect("create script"); + file.write_all(contents.as_bytes()).expect("write script"); + file.sync_all().ok(); + drop(file); + + let mut perms = fs::metadata(path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).unwrap(); +} + #[tokio::test] async fn run_cli_completion_returns_agent_message_from_script() { let dir = tempdir().unwrap(); @@ -14,10 +41,7 @@ async fn run_cli_completion_returns_agent_message_from_script() { printf '%s\n' '{"type":"item.completed","item":{"type":"reasoning","text":"thinking"}}' printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"hello"}}' "#; - fs::write(&script_path, script).unwrap(); - let mut perms = fs::metadata(&script_path).unwrap().permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).unwrap(); + write_executable_script(&script_path, script); let text = run_cli_completion(CliCompletionRequest { engine: CliEngine::Codex, @@ -45,17 +69,14 @@ async fn run_cli_completion_writes_tagged_prompts_to_stdin() { r#"#!/bin/sh set -eu DIR="$(dirname "$0")" -printf '%s\n' "$@" > "{args}" +printf '%s\0' "$@" > "{args}" cat > "{prompt}" printf '%s\n' '{{"type":"item.completed","item":{{"type":"agent_message","text":"ok"}}}}' "#, args = args_dump.display(), prompt = prompt_dump.display() ); - fs::write(&script_path, script).unwrap(); - let mut perms = fs::metadata(&script_path).unwrap().permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).unwrap(); + write_executable_script(&script_path, &script); let base_args = vec!["exec".to_string()]; let text = run_cli_completion(CliCompletionRequest { @@ -73,24 +94,75 @@ printf '%s\n' '{{"type":"item.completed","item":{{"type":"agent_message","text": assert_eq!(text.trim(), "ok"); - let args_contents = fs::read_to_string(&args_dump).expect("args file"); - let args: Vec<&str> = args_contents.lines().collect(); - assert_eq!( - args, - vec![ - "exec", - "--json", - "-c", - "model_reasoning_effort=minimal", - "-c", - "sandbox_mode=read-only", - "-c", - "tools.web_search=false", - "-", - ] - ); + let args = read_args(&args_dump); + let expected: Vec = vec![ + "exec", + "--json", + "-c", + "model_reasoning_effort=minimal", + "-c", + "sandbox_mode=read-only", + "-c", + "tools.web_search=false", + "-", + ] + .into_iter() + .map(String::from) + .collect(); + assert_eq!(args, expected); let prompt_contents = fs::read_to_string(&prompt_dump).expect("prompt file"); - let expected_prompt = "\nSYSTEM\n\n\n\nUSER\n\n"; + let expected_prompt = + "\nSYSTEM\n\n\n\nUSER\n\n"; assert_eq!(prompt_contents, expected_prompt); } + +#[tokio::test] +async fn run_cli_completion_invokes_claude_with_expected_args() { + let dir = tempdir().unwrap(); + let script_path = dir.path().join("fake_claude"); + let args_dump = dir.path().join("claude_args.txt"); + let script = format!( + r#"#!/bin/sh +set -eu +printf '%s\0' "$@" > "{args}" +printf '%s' '{{"type":"result","subtype":"success","result":"echo hi"}}' +"#, + args = args_dump.display() + ); + write_executable_script(&script_path, &script); + + let text = run_cli_completion(CliCompletionRequest { + engine: CliEngine::Claude, + binary: script_path.to_str().unwrap(), + base_args: &[], + system_prompt: "SYSTEM", + user_prompt: "USER", + model: "sonnet", + reasoning_effort: None, + debug: false, + }) + .await + .expect("cli run succeeds"); + + assert_eq!(text, "echo hi"); + + let args = read_args(&args_dump); + let expected: Vec = vec![ + "-p", + "--output-format", + "json", + "--model", + "sonnet", + "--append-system-prompt", + "\nSYSTEM\n", + "--disallowed-tools", + "Bash(*) Edit", + "--", + "\nUSER\n", + ] + .into_iter() + .map(String::from) + .collect(); + assert_eq!(args, expected); +} diff --git a/tests/config_tests.rs b/tests/config_tests.rs index a07c9b5..4cf35ae 100644 --- a/tests/config_tests.rs +++ b/tests/config_tests.rs @@ -78,6 +78,22 @@ fn codex_profile_resolves_to_cli_backend() { } } +#[test] +#[serial] +fn claude_cli_profile_resolves_to_cli_backend() { + let cfg = Config::default(); + let eff = cfg + .resolve_profile(Some("claude_cli"), None, None) + .expect("claude_cli profile should resolve"); + match eff.connection { + ProviderConnection::Cli(ref conn) => { + assert_eq!(conn.binary, "claude"); + assert!(conn.base_args.is_empty()); + } + _ => panic!("claude_cli profile should resolve to CLI backend"), + } +} + #[test] fn cli_mode_inferred_from_cli_block_when_mode_missing() { let json = r#"{ diff --git a/tests/tools_tests.rs b/tests/tools_tests.rs index 361be25..db90de8 100644 --- a/tests/tools_tests.rs +++ b/tests/tools_tests.rs @@ -5,16 +5,23 @@ use qqqa::tools::read_file; use qqqa::tools::write_file; use serial_test::serial; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard, OnceLock}; struct TempCwdGuard { previous: PathBuf, + _lock: MutexGuard<'static, ()>, } impl TempCwdGuard { fn new(dir: &Path) -> Self { + static CWD_LOCK: OnceLock> = OnceLock::new(); + let lock = CWD_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap(); let previous = std::env::current_dir().expect("read current dir"); std::env::set_current_dir(dir).expect("set current dir"); - Self { previous } + Self { + previous, + _lock: lock, + } } }