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
9 changes: 4 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,10 @@ Working notes for agents and contributors on QuickRunner (`qr`).
`OPENAI_BASE_URL` mapping — map those yourself). `qr do` prints the AI-suggested command and
only runs it after you type `y` (default is No), so it is safe to pipe `n` for a
non-executing smoke test. None of this is required for build, lint, tests, or scan/go/stats.
- **Gotcha — AI fallback provider:** the default config defines an `[ai.fallback]` pointing at
`https://api.openai.com/v1`. If you set `QR_AI_BASE_URL` to a proxy/gateway (whose key is not a
real OpenAI key) and the primary call has a transient failure/timeout, `qr` falls back to
api.openai.com and fails with a confusing `401 Incorrect API key`. When using a custom endpoint,
also set `QR_AI_FALLBACK_BASE_URL` (and `QR_AI_FALLBACK_MODEL`) to the same endpoint.
- **Gotcha — AI fallback provider:** the default config does not define an `[ai.fallback]`. If you
configure one manually, `qr` refuses to retry prompt context against a different protocol/base URL
unless `QR_AI_ALLOW_CROSS_ENDPOINT_FALLBACK=true` is set. When using a proxy/gateway and you want
automatic fallback, keep the fallback endpoint on the same base URL or opt in explicitly.
- **Gotcha — secrets in the Desktop terminal:** injected secret env vars are present in the agent
shell but a freshly opened Desktop GUI terminal may not inherit them. To drive an AI demo from the
Desktop, write the needed vars to a temp file the agent shell can produce and `source` it in the
Expand Down
31 changes: 31 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dirs = "6.0.0"
fuzzy-matcher = "0.3.7"
indicatif = "0.18.3"
reqwest = { version = "0.12.24", default-features = false, features = ["blocking", "json", "rustls-tls"] }
rpassword = "7.4.0"
rusqlite = { version = "0.37.0", features = ["bundled"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
Expand Down
7 changes: 0 additions & 7 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,6 @@ model = ""
api_key = ""
api_key_env = "OPENAI_API_KEY"

[ai.fallback]
protocol = "openai"
base_url = "https://api.openai.com/v1"
model = ""
api_key = ""
api_key_env = "OPENAI_API_KEY"

[stats]
enabled = false
db_path = "__default__"
Expand Down
82 changes: 82 additions & 0 deletions src/ai/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::ai::providers::{AiProtocol, ProviderConfig};
/// (`max_tokens` is 1024), so 8 MiB is generous while still bounding memory
/// against a malicious or buggy provider streaming an unbounded body.
const MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024;
const ALLOW_CROSS_ENDPOINT_FALLBACK_ENV: &str = "QR_AI_ALLOW_CROSS_ENDPOINT_FALLBACK";

#[derive(Debug, Clone)]
pub struct AiClient {
Expand Down Expand Up @@ -61,6 +62,11 @@ impl AiClient {
Ok(response) => Ok(response),
Err(primary_error) => {
if let Some(fallback) = &self.fallback {
if !fallback_can_receive_prompt(&self.primary, fallback) {
return Err(anyhow!(
"Primary provider failed: {primary_error}. Refusing to send prompt to fallback provider at a different endpoint; set {ALLOW_CROSS_ENDPOINT_FALLBACK_ENV}=true to allow cross-endpoint fallback."
));
}
self.send_prompt(fallback, "fallback", system_prompt, user_message)
.map_err(|fallback_error| {
anyhow!(
Expand Down Expand Up @@ -127,6 +133,25 @@ impl AiClient {
}
}

fn fallback_can_receive_prompt(primary: &ProviderConfig, fallback: &ProviderConfig) -> bool {
same_ai_endpoint(primary, fallback) || env_flag_enabled(ALLOW_CROSS_ENDPOINT_FALLBACK_ENV)
}

fn same_ai_endpoint(primary: &ProviderConfig, fallback: &ProviderConfig) -> bool {
primary.protocol == fallback.protocol && endpoint_url(primary) == endpoint_url(fallback)
}

fn env_flag_enabled(key: &str) -> bool {
env::var(key)
.map(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes"
)
})
.unwrap_or(false)
}

fn resolve_api_key(provider: &ProviderConfig, keychain_role: &str) -> Result<String> {
// 1. explicit per-provider env var (highest-precedence override)
if !provider.api_key_env.trim().is_empty() {
Expand Down Expand Up @@ -323,6 +348,7 @@ mod tests {
"CUSTOM_QR_TEST_ANTHROPIC_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
ALLOW_CROSS_ENDPOINT_FALLBACK_ENV,
] {
unsafe {
std::env::remove_var(key);
Expand Down Expand Up @@ -430,6 +456,62 @@ mod tests {
}
}

#[test]
fn clear_test_env_removes_cross_endpoint_fallback_opt_in() {
unsafe {
std::env::set_var(ALLOW_CROSS_ENDPOINT_FALLBACK_ENV, "true");
}

clear_test_env();

assert!(std::env::var(ALLOW_CROSS_ENDPOINT_FALLBACK_ENV).is_err());
}

#[test]
fn cross_endpoint_fallback_requires_explicit_opt_in() {
let _guard = test_env_lock().lock().unwrap();
clear_test_env();
let primary = spawn_server(500, r#"{"error":{"message":"primary down"}}"#);
let fallback = spawn_server(
200,
r#"{"choices":[{"message":{"content":"cargo test"}}],"usage":{"prompt_tokens":1,"completion_tokens":1}}"#,
);
unsafe {
std::env::set_var("QR_TEST_AI_KEY", "token");
}

let client = AiClient::new(
ProviderConfig {
protocol: AiProtocol::OpenAi,
base_url: primary,
model: "demo".into(),
api_key: String::new(),
api_key_env: "QR_TEST_AI_KEY".into(),
},
Some(ProviderConfig {
protocol: AiProtocol::OpenAi,
base_url: fallback,
model: "demo".into(),
api_key: String::new(),
api_key_env: "QR_TEST_AI_KEY".into(),
}),
);

let error = client
.execute_prompt("You classify tasks", "run tests")
.unwrap_err();
assert!(
error
.to_string()
.contains("Refusing to send prompt to fallback provider at a different endpoint"),
"{error:#}"
);

unsafe {
std::env::remove_var("QR_TEST_AI_KEY");
}
}

#[test]
fn custom_env_var_overrides_well_known_and_config_key() {
let _guard = test_env_lock().lock().unwrap();
Expand Down
50 changes: 41 additions & 9 deletions src/commands/do_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use crate::{
ai::client::{AiClient, AiResponse},
config::{AgentConfig, AppConfig},
project_profile::{ProjectProfile, discover_project_root, load_profile_from},
terminal,
};

const CLASSIFICATION_PROMPT: &str = r#"You are QuickRunner's router.
Expand Down Expand Up @@ -114,13 +115,20 @@ fn load_current_profile() -> Result<ProjectProfile> {
}

fn print_inline_preview(command: &str, reason: Option<&str>) -> Result<()> {
println!("→ {command}");
if let Some(reason) = reason.filter(|value| !value.trim().is_empty()) {
println!(" {reason}");
for line in inline_preview_lines(command, reason) {
println!("{line}");
}
Ok(())
}

fn inline_preview_lines(command: &str, reason: Option<&str>) -> Vec<String> {
let mut lines = vec![format!("→ {}", terminal::escape_untrusted(command))];
if let Some(reason) = reason.filter(|value| !value.trim().is_empty()) {
lines.push(format!(" {}", terminal::escape_untrusted(reason)));
}
lines
}

/// Confirm and run an inline command. Every AI-generated command gets the same
/// single confirmation: `confirm` defaults to No and the full command is shown in
/// the preview first, so a command like `git status; rm -rf ~` cannot run unless
Expand Down Expand Up @@ -177,16 +185,25 @@ fn build_delegate_suggestions(
}

fn print_delegate_suggestions(suggestions: &[String], reason: Option<&str>) -> Result<()> {
println!("🧠 This looks like a multi-step coding task.");
if let Some(reason) = reason.filter(|value| !value.trim().is_empty()) {
println!(" {reason}");
}
for suggestion in suggestions {
println!("→ Suggested: {suggestion}");
for line in delegate_suggestion_lines(suggestions, reason) {
println!("{line}");
}
Ok(())
}

fn delegate_suggestion_lines(suggestions: &[String], reason: Option<&str>) -> Vec<String> {
let mut lines = vec!["🧠 This looks like a multi-step coding task.".to_string()];
if let Some(reason) = reason.filter(|value| !value.trim().is_empty()) {
lines.push(format!(" {}", terminal::escape_untrusted(reason)));
}
lines.extend(
suggestions
.iter()
.map(|suggestion| format!("→ Suggested: {}", terminal::escape_untrusted(suggestion))),
);
lines
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -224,4 +241,19 @@ mod tests {
build_delegate_suggestions(&AgentConfig::default(), "refactor auth", Some(&profile));
assert!(suggestions[0].starts_with("codex "));
}

#[test]
fn inline_preview_lines_escape_terminal_controls() {
let lines = inline_preview_lines("cargo test\u{1b}[2J", Some("\u{1b}[31mred"));
assert_eq!(lines[0], "→ cargo test\\u{1b}[2J");
assert_eq!(lines[1], " \\u{1b}[31mred");
}

#[test]
fn delegate_suggestion_lines_escape_terminal_controls() {
let suggestions = vec!["codex exec \u{1b}[2J".to_string()];
let lines = delegate_suggestion_lines(&suggestions, Some("\u{1b}[31magent"));
assert_eq!(lines[1], " \\u{1b}[31magent");
assert_eq!(lines[2], "→ Suggested: codex exec \\u{1b}[2J");
}
}
61 changes: 51 additions & 10 deletions src/commands/go.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ use std::io::IsTerminal;
use anyhow::{Result, anyhow};
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};

use crate::{config::AppConfig, picker, scanner::ProjectEntry, scanner::load_or_scan_projects};
use crate::{
config::AppConfig, picker, scanner::ProjectEntry, scanner::load_or_scan_projects, terminal,
};

pub struct GoResult {
pub path: String,
Expand All @@ -25,10 +27,7 @@ pub fn execute(config: &AppConfig, query: &str) -> Result<GoResult> {
let selected = if matches.len() == 1 {
matches[0].clone()
} else if std::io::stdin().is_terminal() && std::io::stderr().is_terminal() {
let labels = matches
.iter()
.map(|entry| format!("{} ({})", entry.name, entry.path))
.collect::<Vec<_>>();
let labels = matches.iter().map(project_choice_label).collect::<Vec<_>>();
let picker_start = std::time::Instant::now();
let Some(index) = picker::pick_index(&labels)? else {
return Err(anyhow!("Selection cancelled"));
Expand All @@ -38,11 +37,7 @@ pub fn execute(config: &AppConfig, query: &str) -> Result<GoResult> {
} else {
return Err(anyhow!(
"Multiple matches for '{query}': {}",
matches
.iter()
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>()
.join(", ")
multiple_match_names(&matches)
));
};

Expand All @@ -52,6 +47,22 @@ pub fn execute(config: &AppConfig, query: &str) -> Result<GoResult> {
})
}

fn project_choice_label(entry: &ProjectEntry) -> String {
format!(
"{} ({})",
terminal::escape_untrusted(&entry.name),
terminal::escape_untrusted(&entry.path)
)
}

fn multiple_match_names(entries: &[ProjectEntry]) -> String {
entries
.iter()
.map(|entry| terminal::escape_untrusted(&entry.name))
.collect::<Vec<_>>()
.join(", ")
}

pub fn rank_matches(entries: &[ProjectEntry], query: &str) -> Vec<ProjectEntry> {
let lower_query = query.to_ascii_lowercase();
let matcher = SkimMatcherV2::default();
Expand Down Expand Up @@ -187,4 +198,34 @@ mod tests {
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].name, "orion-app");
}

#[test]
fn project_choice_label_escapes_terminal_controls() {
let entry = ProjectEntry {
name: "bad\u{1b}[2J".into(),
path: "/tmp/bad\u{7}".into(),
source: "git".into(),
};
assert_eq!(
project_choice_label(&entry),
"bad\\u{1b}[2J (/tmp/bad\\u{7})"
);
}

#[test]
fn multiple_match_names_escape_terminal_controls() {
let entries = vec![
ProjectEntry {
name: "one\u{1b}[2J".into(),
path: "/tmp/one".into(),
source: "git".into(),
},
ProjectEntry {
name: "two\u{7}".into(),
path: "/tmp/two".into(),
source: "git".into(),
},
];
assert_eq!(multiple_match_names(&entries), "one\\u{1b}[2J, two\\u{7}");
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod scanner;
pub mod secret;
pub mod shell;
pub mod stats_db;
pub mod terminal;

/// A single process-wide lock for unit tests that mutate environment variables.
/// One shared lock (rather than a per-module copy) is required because env vars
Expand Down
Loading
Loading