diff --git a/Cargo.toml b/Cargo.toml index 1c94b45..b0134d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "qqqa" -version = "1.0.0" +version = "1.0.1" edition = "2024" license = "MIT" description = "qqqa CLI (qq and qa) - fast, stateless LLM in the terminal" diff --git a/README.md b/README.md index b855313..eb53eb2 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, Gemini, 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. +By default the repo includes profiles for OpenRouter (default), OpenAI, Groq, Gemini (API + CLI), 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. @@ -124,13 +124,17 @@ brew install qqqa Download a prebuilt archive from the [GitHub Releases](https://github.com/iagooar/qqqa/releases) page, extract it, and place `qq`/`qa` somewhere on your `PATH` (e.g., `/usr/local/bin`). +On Arch Linux, `/usr/bin/qq` may already belong to another package. Install the release binaries to a custom directory (for example `~/bin`) and rename them if needed, or use `cargo install` from this repo. + ### Windows Download the Windows archive from Releases (choose the architecture that matches your machine), extract `qq.exe` and `qa.exe`, and add them to your `%PATH%`. ## Configure -On first run qqqa creates `~/.qq/config.json` with safe permissions. For a smooth first interaction, run the init flow: +On first run qqqa creates a config file with safe permissions. The default path is `~/.qq/config.json`. If `XDG_CONFIG_HOME` is set and no legacy `~/.qq/config.json` exists, config is stored at `$XDG_CONFIG_HOME/qq/config.json` instead. + +For a smooth first interaction, run the init flow: ```sh # Interactive setup (choose provider and set key) @@ -152,6 +156,7 @@ The initializer lets you choose the default provider: - Claude Code CLI + `claude-haiku-4-5` (wraps the `claude` binary; `qq` streams live, `qa` buffers so it can parse tool calls) - Need to force a different desktop model? Add `"model_override"` under the provider's `cli` block (supported for both Codex and Claude). That override wins over the profile default but still yields to the per-run `--model` flag. - Gemini + `gemini-3-flash-preview` (Google's Gemini via OpenAI-compatible API) +- Gemini CLI + `gemini-3-flash-preview` (wraps the `gemini` binary; sign in once or set `GEMINI_API_KEY`; buffered output only) It also offers to store an API key in the config (optional). If you prefer environment variables, leave it blank and set one of: @@ -160,7 +165,7 @@ It also offers to store an API key in the config (optional). If you prefer envir - `OPENAI_API_KEY` for OpenAI - `GEMINI_API_KEY` for Gemini (Google AI Studio) - `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 or Claude CLI profiles—their binaries handle auth (`codex login` / `claude login`). +- No API key is required for the Codex or Claude CLI profiles—their binaries handle auth (`codex login` / `claude login`). Gemini CLI accepts Google sign-in or `GEMINI_API_KEY`. Defaults written to `~/.qq/config.json`: @@ -171,6 +176,7 @@ Defaults written to `~/.qq/config.json`: - `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) - `gemini` → base `https://generativelanguage.googleapis.com/v1beta/openai`, env `GEMINI_API_KEY` + - `gemini_cli` → mode `cli`, binary `gemini` (install `@google/gemini-cli`; auth via Google sign-in or `GEMINI_API_KEY`) - `codex` → mode `cli`, binary `codex` with base args `exec` (install Codex CLI; auth handled by `codex login`). Optional `"model_override"` in the `cli` block forces a fallback ChatGPT model if OpenAI retires the default. - `claude_cli` → mode `cli`, binary `claude` (install `@anthropic-ai/claude-code`; auth handled by `claude login`). Optional `"model_override"` pins Claude Code’s `--model` flag without touching your profile’s model. - `codex` → CLI provider, binary `codex` - fails if the binary is missing @@ -181,7 +187,9 @@ Defaults written to `~/.qq/config.json`: - `ollama` → model `llama3.1` - `anthropic` → model `claude-3-5-sonnet-20241022` (inactive placeholder until Anthropic integration lands) - `gemini` → model `gemini-3-flash-preview` + - `gemini_cli` → model `gemini-3-flash-preview` (passed to `gemini -m`) - `codex` → model label `gpt-5` (only used for display; Codex CLI picks the backing ChatGPT model) +- Optional per-profile or global `prompt_suffix` text appended to the built-in system prompt (does not replace it). Useful for rules like "assume tools are installed" or "be less chatty". - Optional per-profile `reasoning_effort` for GPT-5 family models. If you leave it unset, qqqa sends `"reasoning_effort": "minimal"` for any `gpt-5*` model to keep responses fast. Set it to `"low"`, `"medium"`, or `"high"` when you want deeper reasoning. - (discouraged) Optional per-profile `temperature`. Most models default to `0.15` unless you set it in `~/.qq/config.json` or pass `--temperature ` for a single run. GPT-5 models ignore custom temperatures; qqqa forces them to `1.0`. - (discouraged): you can change the timeout, e.g. `"timeout": "240"` under a model profile in `~/.qq/config.json` to raise the per-request limit (`qq` + `qa` default to 180 s - this is SLOW; faster models are a better fix). diff --git a/src/ai.rs b/src/ai.rs index 551dade..08650f1 100644 --- a/src/ai.rs +++ b/src/ai.rs @@ -614,6 +614,7 @@ mod cli_backend { match req.engine { CliEngine::Codex => run_codex(req).await, CliEngine::Claude => run_claude(req).await, + CliEngine::Gemini => run_gemini(req).await, } } @@ -626,7 +627,7 @@ mod cli_backend { { match req.engine { CliEngine::Claude => run_claude_streaming(req, on_token).await, - CliEngine::Codex => Err(anyhow!( + CliEngine::Codex | CliEngine::Gemini => Err(anyhow!( "CLI provider '{}' does not support streaming", req.binary )), @@ -717,6 +718,83 @@ mod cli_backend { parse_codex_response(&stdout) } + async fn run_gemini(req: CliCompletionRequest<'_>) -> Result { + let mut cmd = Command::new(req.binary); + if !req.base_args.is_empty() { + cmd.args(req.base_args); + } + cmd.arg("-o"); + cmd.arg("json"); + cmd.arg("--skip-trust"); + cmd.arg("--approval-mode"); + cmd.arg("plan"); + if !req.model.trim().is_empty() { + cmd.arg("-m"); + cmd.arg(req.model); + } + + let prompt = format!( + "{}\n\n{}\n", + tagged_system_prompt(req.system_prompt), + tagged_user_prompt(req.user_prompt) + ); + + if req.debug { + eprintln!( + "[debug] Running CLI provider '{}' with args: {:?}", + req.binary, cmd + ); + } + + let mut child = cmd + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| { + format!( + "Failed to spawn CLI provider '{}'. Is it installed and on your PATH?", + req.binary + ) + })?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(prompt.as_bytes()) + .await + .context("Writing prompt to CLI provider stdin")?; + } + + let output = wait_child_output_with_timeout(child, req.timeout, req.binary).await?; + + 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_gemini_response(&stdout) + } + async fn run_claude(req: CliCompletionRequest<'_>) -> Result { let mut cmd = build_claude_command(&req, false); @@ -1046,6 +1124,32 @@ mod cli_backend { Ok(messages.join("\n\n")) } + fn parse_gemini_response(stdout: &str) -> Result { + let value = parse_single_json_value(stdout) + .with_context(|| "CLI provider returned non-JSON output")?; + + if let Some(err) = value.get("error") { + if let Some(msg) = err.get("message").and_then(|m| m.as_str()) { + return Err(anyhow!("CLI provider error: {}", msg)); + } + return Err(anyhow!("CLI provider returned error JSON: {}", err)); + } + + if let Some(response) = value.get("response") { + if let Some(text) = extract_text(response) { + return Ok(text); + } + } + + if let Some(text) = extract_text(&value) { + return Ok(text); + } + + Err(anyhow!( + "CLI provider returned JSON without a usable message." + )) + } + fn parse_claude_response(stdout: &str) -> Result { let value = parse_single_json_value(stdout) .with_context(|| "CLI provider returned non-JSON output")?; @@ -1168,7 +1272,7 @@ mod cli_backend { return Some(text.to_string()); } } - for key in ["content", "messages", "output_text", "result"] { + for key in ["content", "messages", "output_text", "result", "output"] { if let Some(val) = map.get(key) { if let Some(text) = extract_text(val) { return Some(text); @@ -1194,6 +1298,11 @@ mod cli_backend { parse_codex_response(input) } + #[cfg(test)] + pub(super) fn parse_gemini_response_for_test(input: &str) -> Result { + parse_gemini_response(input) + } + #[cfg(test)] pub(super) fn parse_claude_response_for_test(input: &str) -> Result { parse_claude_response(input) @@ -1213,7 +1322,7 @@ pub use cli_backend::{CliCompletionRequest, run_cli_completion, run_cli_completi mod tests { use super::cli_backend::{ parse_claude_response_for_test, parse_claude_stream_line_for_test, - parse_codex_response_for_test, + parse_codex_response_for_test, parse_gemini_response_for_test, }; use super::load_root_certificates; use rcgen::{CertifiedKey, generate_simple_self_signed}; @@ -1272,6 +1381,27 @@ mod tests { assert_eq!(certs.len(), 1); } + #[test] + fn gemini_parser_reads_response_string() { + let payload = r#"{"session_id":"abc","response":"hello gemini"}"#; + let parsed = parse_gemini_response_for_test(payload).expect("parse"); + assert_eq!(parsed, "hello gemini"); + } + + #[test] + fn gemini_parser_reads_response_output_object() { + let payload = r#"{"session_id":"abc","response":{"output":"hello output"}}"#; + let parsed = parse_gemini_response_for_test(payload).expect("parse"); + assert_eq!(parsed, "hello output"); + } + + #[test] + fn gemini_parser_surfaces_error_message() { + let payload = r#"{"session_id":"abc","error":{"message":"auth failed","code":41}}"#; + let err = parse_gemini_response_for_test(payload).unwrap_err(); + assert!(err.to_string().contains("auth failed")); + } + #[test] fn codex_parser_returns_last_agent_message() { let payload = r#"{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Reasoning"}} diff --git a/src/bin/qa.rs b/src/bin/qa.rs index 83234bf..9e95b75 100644 --- a/src/bin/qa.rs +++ b/src/bin/qa.rs @@ -181,6 +181,10 @@ async fn main() -> Result<()> { if cfg.no_emoji_enabled() { system_prompt.push_str("\nHard rule: You MUST NOT use emojis anywhere in the response.\n"); } + if let Some(suffix) = &eff.prompt_suffix { + system_prompt.push('\n'); + system_prompt.push_str(suffix); + } let shell_hint = shell_hint_for_prompt(shell_kind); let user_msg = build_qa_user_message( Some(os_type), diff --git a/src/bin/qq.rs b/src/bin/qq.rs index 2f6be45..2406521 100644 --- a/src/bin/qq.rs +++ b/src/bin/qq.rs @@ -225,6 +225,10 @@ async fn main() -> Result<()> { if cfg.no_emoji_enabled() { system.push_str("\nHard rule: You MUST NOT use emojis anywhere in the response.\n"); } + if let Some(suffix) = &eff.prompt_suffix { + system.push('\n'); + system.push_str(suffix); + } let os_details = os_info::get(); let os_type = os_details.os_type(); let shell_kind = detect_shell(os_type); diff --git a/src/config.rs b/src/config.rs index 62e9229..048dd2f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,6 +40,7 @@ impl Default for ProviderMode { pub enum CliEngine { Codex, Claude, + Gemini, } impl CliEngine { @@ -108,6 +109,9 @@ pub struct Profile { /// Optional per-profile request timeout override (seconds as string) #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout: Option, + /// Optional text appended to the built-in system prompt for this profile. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_suffix: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -127,6 +131,26 @@ pub struct Config { /// Optional list of additional commands allowed for qa execute_command. #[serde(default, skip_serializing_if = "Option::is_none")] pub command_allowlist: Option>, + /// Optional text appended to the built-in system prompt for every profile. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_suffix: Option, +} + +/// Resolve the qqqa config directory for a given home path. +/// Keeps `~/.qq` when that directory already has a config file; otherwise +/// honors `XDG_CONFIG_HOME/qq` when the variable is set. +pub fn config_dir_for_home(home: &Path) -> PathBuf { + let legacy = home.join(CONFIG_DIR_NAME); + if legacy.join(CONFIG_FILE_NAME).exists() { + return legacy; + } + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + let trimmed = xdg.trim(); + if !trimmed.is_empty() { + return PathBuf::from(trimmed).join("qq"); + } + } + legacy } impl Default for Config { @@ -247,6 +271,24 @@ impl Default for Config { cli: None, }, ); + model_providers.insert( + "gemini_cli".to_string(), + ModelProvider { + name: "Gemini CLI".to_string(), + base_url: "cli://gemini".to_string(), + env_key: "GEMINI_CLI_API_KEY".to_string(), + api_key: None, + local: true, + tls: None, + mode: ProviderMode::Cli, + cli: Some(CliProviderConfig { + engine: CliEngine::Gemini, + binary: "gemini".to_string(), + base_args: Vec::new(), + model_override: None, + }), + }, + ); let mut profiles = HashMap::new(); profiles.insert( @@ -257,6 +299,7 @@ impl Default for Config { reasoning_effort: None, temperature: None, timeout: None, + prompt_suffix: None, }, ); profiles.insert( @@ -267,6 +310,7 @@ impl Default for Config { reasoning_effort: None, temperature: None, timeout: None, + prompt_suffix: None, }, ); profiles.insert( @@ -277,6 +321,7 @@ impl Default for Config { reasoning_effort: None, temperature: None, timeout: None, + prompt_suffix: None, }, ); profiles.insert( @@ -287,6 +332,7 @@ impl Default for Config { reasoning_effort: None, temperature: None, timeout: None, + prompt_suffix: None, }, ); profiles.insert( @@ -297,6 +343,7 @@ impl Default for Config { reasoning_effort: None, temperature: None, timeout: None, + prompt_suffix: None, }, ); profiles.insert( @@ -307,6 +354,7 @@ impl Default for Config { reasoning_effort: Some("low".to_string()), temperature: None, timeout: None, + prompt_suffix: None, }, ); profiles.insert( @@ -317,6 +365,7 @@ impl Default for Config { reasoning_effort: None, temperature: None, timeout: None, + prompt_suffix: None, }, ); profiles.insert( @@ -327,6 +376,18 @@ impl Default for Config { reasoning_effort: None, temperature: None, timeout: None, + prompt_suffix: None, + }, + ); + profiles.insert( + "gemini_cli".to_string(), + Profile { + model_provider: "gemini_cli".to_string(), + model: "gemini-3-flash-preview".to_string(), + reasoning_effort: None, + temperature: None, + timeout: None, + prompt_suffix: None, }, ); @@ -338,6 +399,7 @@ impl Default for Config { copy_first_command: false, no_emoji: None, command_allowlist: None, + prompt_suffix: None, } } } @@ -374,6 +436,7 @@ pub struct EffectiveProfile { pub connection: ProviderConnection, pub reasoning_effort: Option, pub temperature: Option, + pub prompt_suffix: Option, } impl EffectiveProfile { @@ -403,7 +466,7 @@ impl Config { /// Load config from disk or create a default one on first run. pub fn load_or_init(debug: bool) -> Result<(Self, PathBuf)> { let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?; - let dir = home.join(CONFIG_DIR_NAME); + let dir = config_dir_for_home(&home); let path = dir.join(CONFIG_FILE_NAME); if !dir.exists() { @@ -614,6 +677,11 @@ impl Config { connection, reasoning_effort: profile.reasoning_effort.clone(), temperature: profile.temperature, + prompt_suffix: profile + .prompt_suffix + .clone() + .or_else(|| self.prompt_suffix.clone()) + .filter(|suffix| !suffix.trim().is_empty()), }) } @@ -624,7 +692,7 @@ impl Config { use std::io::{self, Write}; let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?; - let dir = home.join(CONFIG_DIR_NAME); + let dir = config_dir_for_home(&home); let path = dir.join(CONFIG_FILE_NAME); if !dir.exists() { @@ -653,7 +721,10 @@ impl Config { " [7] Claude Code CLI — use the local `claude` binary (Claude desktop / npm package)" ); println!(" [8] Gemini — gemini-3-flash-preview (Google's Gemini via OpenAI-compatible API)"); - print!("Enter 1-8 [1]: "); + println!( + " [9] Gemini CLI — use the local `gemini` binary (Google sign-in or GEMINI_API_KEY)" + ); + print!("Enter 1-9 [1]: "); io::stdout().flush().ok(); let mut choice = String::new(); io::stdin().read_line(&mut choice).ok(); @@ -668,6 +739,7 @@ impl Config { cfg.default_profile = "claude_cli".to_string() } "8" | "gemini" => cfg.default_profile = "gemini".to_string(), + "9" | "gemini-cli" | "gemini_cli" => cfg.default_profile = "gemini_cli".to_string(), "1" | "openrouter" => cfg.default_profile = "openrouter".to_string(), _ => cfg.default_profile = "openrouter".to_string(), } @@ -746,6 +818,10 @@ impl Config { "Install Claude Code (`npm install -g @anthropic-ai/claude-code`) so '{}' is available on your PATH.", bin ), + "gemini_cli" => format!( + "Install Gemini CLI (`npm install -g @google/gemini-cli`) so '{}' is available on your PATH.", + bin + ), _ => format!( "Ensure the '{}' CLI binary is installed and on your PATH.", bin @@ -754,6 +830,7 @@ impl Config { 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.", + "gemini_cli" => "Sign in via `gemini` once, or set GEMINI_API_KEY for API-key auth.", _ => "Authentication is handled by the CLI runtime itself.", } .to_string(); diff --git a/src/formatting.rs b/src/formatting.rs index 74941be..0d18b2c 100644 --- a/src/formatting.rs +++ b/src/formatting.rs @@ -404,26 +404,28 @@ mod tests { #[test] fn streaming_handles_cumulative_chunks() { - let chunks = [ - "ffmpeg -i input.mov output.mp4", - "ffmpeg -i input.mov output.mp4 done", - "ffmpeg -i input.mov output.mp4 done", - ]; - let mut fmt = StreamingFormatter::new(); - let mut printed = String::new(); - for chunk in chunks { - if let Some(delta) = fmt.push(chunk) { - printed.push_str(&delta); + with_color_setting(true, || { + let chunks = [ + "ffmpeg -i input.mov output.mp4", + "ffmpeg -i input.mov output.mp4 done", + "ffmpeg -i input.mov output.mp4 done", + ]; + let mut fmt = StreamingFormatter::new(); + let mut printed = String::new(); + for chunk in chunks { + if let Some(delta) = fmt.push(chunk) { + printed.push_str(&delta); + } } - } - if let Some(tail) = fmt.flush() { - printed.push_str(&tail); - } - let final_render = fmt.rendered(); - let expected = - render_xmlish_to_ansi("ffmpeg -i input.mov output.mp4 done"); - assert_eq!(final_render, expected); - assert_eq!(printed, expected); + if let Some(tail) = fmt.flush() { + printed.push_str(&tail); + } + let final_render = fmt.rendered(); + let expected = + render_xmlish_to_ansi("ffmpeg -i input.mov output.mp4 done"); + assert_eq!(final_render, expected); + assert_eq!(printed, expected); + }); } #[test] diff --git a/tests/cli_backend_tests.rs b/tests/cli_backend_tests.rs index 2ea8c6d..abd49de 100644 --- a/tests/cli_backend_tests.rs +++ b/tests/cli_backend_tests.rs @@ -158,6 +158,61 @@ printf '%s\n' '{{"type":"item.completed","item":{{"type":"agent_message","text": assert_eq!(prompt_contents, expected_prompt); } +#[tokio::test] +async fn run_cli_completion_invokes_gemini_with_expected_args() { + let dir = tempdir().unwrap(); + let script_path = dir.path().join("fake_gemini"); + let args_dump = dir.path().join("gemini_args.txt"); + let prompt_dump = dir.path().join("gemini_prompt.txt"); + let script = format!( + r#"#!/bin/sh +set -eu +printf '%s\0' "$@" > "{args}" +cat > "{prompt}" +printf '%s\n' '{{"session_id":"test","response":"gemini ok"}}' +"#, + args = args_dump.display(), + prompt = prompt_dump.display() + ); + write_executable_script(&script_path, &script); + + let text = run_cli_completion_with_retry(|| CliCompletionRequest { + engine: CliEngine::Gemini, + binary: script_path.to_str().unwrap(), + base_args: &[], + system_prompt: "SYSTEM", + user_prompt: "USER", + model: "gemini-3-flash-preview", + reasoning_effort: None, + debug: false, + timeout: Duration::from_secs(5), + }) + .await + .expect("cli run succeeds"); + + assert_eq!(text.trim(), "gemini ok"); + + let args = read_args(&args_dump); + let expected: Vec = vec![ + "-o", + "json", + "--skip-trust", + "--approval-mode", + "plan", + "-m", + "gemini-3-flash-preview", + ] + .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"; + assert_eq!(prompt_contents, expected_prompt); +} + #[tokio::test] async fn run_cli_completion_invokes_claude_with_expected_args() { let dir = tempdir().unwrap(); diff --git a/tests/config_tests.rs b/tests/config_tests.rs index 65fd6d6..ef23019 100644 --- a/tests/config_tests.rs +++ b/tests/config_tests.rs @@ -1,6 +1,6 @@ use assert_cmd::cargo::cargo_bin_cmd; use fs_err as fs; -use qqqa::config::{Config, ProviderConnection}; +use qqqa::config::{config_dir_for_home, Config, ProviderConnection}; use serial_test::serial; use std::path::Path; use tempfile::tempdir; @@ -25,7 +25,7 @@ fn run_init_with_bin(bin: &str, input: &str) -> Config { .write_stdin(input); cmd.assert().success(); - let config_path = home_path.join(".qq").join("config.json"); + let config_path = config_dir_for_home(&home_path).join("config.json"); let bytes = fs::read(&config_path).expect("config json"); serde_json::from_slice(&bytes).expect("parse config") } @@ -39,7 +39,7 @@ fn run_qa_init(input: &str) -> Config { } fn read_config_from_home(home: &Path) -> Config { - let config_path = home.join(".qq").join("config.json"); + let config_path = config_dir_for_home(home).join("config.json"); let bytes = fs::read(&config_path).expect("config json"); serde_json::from_slice(&bytes).expect("parse config") } @@ -378,3 +378,71 @@ fn qq_disable_auto_copy_flag_persists_without_question() { let cfg = read_config_from_home(&home_path); assert!(!cfg.copy_first_command_enabled()); } + +#[test] +fn gemini_cli_profile_resolves_to_cli_backend() { + let cfg = Config::default(); + let eff = cfg + .resolve_profile(Some("gemini_cli"), None, None) + .expect("gemini_cli profile should resolve"); + match eff.connection { + ProviderConnection::Cli(ref conn) => assert_eq!(conn.binary, "gemini"), + _ => panic!("gemini_cli profile should resolve to CLI backend"), + } +} + +#[test] +fn profile_prompt_suffix_overrides_global_suffix() { + let mut cfg = Config::default(); + cfg.prompt_suffix = Some("global suffix".to_string()); + cfg.profiles + .get_mut("openrouter") + .expect("openrouter profile") + .prompt_suffix = Some("profile suffix".to_string()); + let eff = cfg + .resolve_profile(Some("openrouter"), None, None) + .expect("profile resolves"); + assert_eq!(eff.prompt_suffix.as_deref(), Some("profile suffix")); +} + +#[test] +fn global_prompt_suffix_applies_when_profile_has_none() { + let mut cfg = Config::default(); + cfg.prompt_suffix = Some("global suffix".to_string()); + let eff = cfg + .resolve_profile(Some("openrouter"), None, None) + .expect("profile resolves"); + assert_eq!(eff.prompt_suffix.as_deref(), Some("global suffix")); +} + +#[test] +#[serial] +fn config_dir_uses_xdg_when_legacy_missing() { + let home = tempdir().expect("temp home"); + let xdg = tempdir().expect("temp xdg"); + unsafe { + std::env::set_var("XDG_CONFIG_HOME", xdg.path()); + } + let dir = config_dir_for_home(home.path()); + assert_eq!(dir, xdg.path().join("qq")); + unsafe { + std::env::remove_var("XDG_CONFIG_HOME"); + } +} + +#[test] +fn config_dir_keeps_legacy_when_config_exists() { + let home = tempdir().expect("temp home"); + let xdg = tempdir().expect("temp xdg"); + let legacy = home.path().join(".qq"); + fs::create_dir_all(&legacy).expect("legacy dir"); + fs::write(legacy.join("config.json"), "{}").expect("legacy config"); + unsafe { + std::env::set_var("XDG_CONFIG_HOME", xdg.path()); + } + let dir = config_dir_for_home(home.path()); + assert_eq!(dir, legacy); + unsafe { + std::env::remove_var("XDG_CONFIG_HOME"); + } +}