Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.



Expand Down Expand Up @@ -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)
Expand All @@ -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:

Expand All @@ -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`:

Expand All @@ -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
Expand All @@ -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 <value>` 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).
Expand Down
136 changes: 133 additions & 3 deletions src/ai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand All @@ -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
)),
Expand Down Expand Up @@ -717,6 +718,83 @@ mod cli_backend {
parse_codex_response(&stdout)
}

async fn run_gemini(req: CliCompletionRequest<'_>) -> Result<String> {
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<String> {
let mut cmd = build_claude_command(&req, false);

Expand Down Expand Up @@ -1046,6 +1124,32 @@ mod cli_backend {
Ok(messages.join("\n\n"))
}

fn parse_gemini_response(stdout: &str) -> Result<String> {
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<String> {
let value = parse_single_json_value(stdout)
.with_context(|| "CLI provider returned non-JSON output")?;
Expand Down Expand Up @@ -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);
Expand All @@ -1194,6 +1298,11 @@ mod cli_backend {
parse_codex_response(input)
}

#[cfg(test)]
pub(super) fn parse_gemini_response_for_test(input: &str) -> Result<String> {
parse_gemini_response(input)
}

#[cfg(test)]
pub(super) fn parse_claude_response_for_test(input: &str) -> Result<String> {
parse_claude_response(input)
Expand All @@ -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};
Expand Down Expand Up @@ -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"}}
Expand Down
4 changes: 4 additions & 0 deletions src/bin/qa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions src/bin/qq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading