use clap::Parser;
use colored::*;
use mirror::builder::{LLMBackend, LLMBuilder};
use mirror::chat::{ChatMessage, ImageMime};
use mirror::secret_store::SecretStore;
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use spinners::{Spinner, Spinners};
use std::io::{self, IsTerminal, Read, Write};
use std::str::FromStr;
#[cfg(feature = "logging")]
use env_logger;
#[derive(Parser)]
#[clap(
name = "mirror",
about = "Interactive CLI interface for chatting with LLM providers",
allow_hyphen_values = true
)]
struct CliArgs {
#[arg(index = 1)]
command: Option<String>,
#[arg(index = 2)]
provider_or_key: Option<String>,
#[arg(index = 3)]
prompt_or_value: Option<String>,
#[arg(long)]
provider: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
system: Option<String>,
#[arg(long)]
api_key: Option<String>,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
temperature: Option<f32>,
#[arg(long)]
max_tokens: Option<u32>,
}
fn detect_image_mime(data: &[u8]) -> Option<ImageMime> {
if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
Some(ImageMime::JPEG)
} else if data.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
Some(ImageMime::PNG)
} else if data.starts_with(&[0x47, 0x49, 0x46]) {
Some(ImageMime::GIF)
} else {
None
}
}
fn get_provider_info(args: &CliArgs) -> Option<(String, Option<String>)> {
if let Some(default_provider) = SecretStore::new()
.ok()
.and_then(|store| store.get_default_provider().cloned())
{
let parts: Vec<&str> = default_provider.split(':').collect();
println!("Default provider: {default_provider}");
return Some((parts[0].to_string(), parts.get(1).map(|s| s.to_string())));
}
if let Some(provider_string) = args.provider_or_key.clone() {
let parts: Vec<&str> = provider_string.split(':').collect();
return Some((parts[0].to_string(), parts.get(1).map(|s| s.to_string())));
}
args.provider
.clone()
.map(|provider| (provider, args.model.clone()))
}
fn get_api_key(backend: &LLMBackend, args: &CliArgs) -> Option<String> {
args.api_key.clone().or_else(|| {
let store = SecretStore::new().ok()?;
match backend {
LLMBackend::OpenAI => store
.get("OPENAI_API_KEY")
.cloned()
.or_else(|| std::env::var("OPENAI_API_KEY").ok()),
LLMBackend::Anthropic => store
.get("ANTHROPIC_API_KEY")
.cloned()
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok()),
LLMBackend::DeepSeek => store
.get("DEEPSEEK_API_KEY")
.cloned()
.or_else(|| std::env::var("DEEPSEEK_API_KEY").ok()),
LLMBackend::Google => store
.get("GOOGLE_API_KEY")
.cloned()
.or_else(|| std::env::var("GOOGLE_API_KEY").ok()),
LLMBackend::Groq => store
.get("GROQ_API_KEY")
.cloned()
.or_else(|| std::env::var("GROQ_API_KEY").ok()),
LLMBackend::AzureOpenAI => store
.get("AZURE_OPENAI_API_KEY")
.cloned()
.or_else(|| std::env::var("AZURE_OPENAI_API_KEY").ok()),
LLMBackend::Ollama => None,
LLMBackend::Phind => None,
LLMBackend::ElevenLabs => None,
LLMBackend::Cohere => store
.get("COHERE_API_KEY")
.cloned()
.or_else(|| std::env::var("COHERE_API_KEY").ok()),
LLMBackend::Mistral => store
.get("MISTRAL_API_KEY")
.cloned()
.or_else(|| std::env::var("MISTRAL_API_KEY").ok()),
}
})
}
fn process_input(input: &[u8], prompt: String) -> Vec<ChatMessage> {
let mut messages = Vec::new();
if !input.is_empty() && detect_image_mime(input).is_some() {
let mime = detect_image_mime(input).unwrap();
messages.push(ChatMessage::user().content(prompt).build());
messages.push(ChatMessage::user().image(mime, input.to_vec()).build());
} else if !input.is_empty() {
let input_str = String::from_utf8_lossy(input);
messages.push(
ChatMessage::user()
.content(format!("{prompt}\n\n{input_str}"))
.build(),
);
} else {
messages.push(ChatMessage::user().content(prompt).build());
}
messages
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
#[cfg(feature = "logging")]
{
let _ = env_logger::try_init();
}
let args = CliArgs::parse();
if let Some(cmd) = args.command.as_deref() {
match cmd {
"set" => {
if let (Some(key), Some(value)) = (
args.provider_or_key.as_deref(),
args.prompt_or_value.as_deref(),
) {
let mut store = SecretStore::new()?;
store.set(key, value)?;
println!("{} Secret '{}' has been set.", "✓".bright_green(), key);
return Ok(());
}
eprintln!("{} Usage: mirror set <key> <value>", "Error:".bright_red());
return Ok(());
}
"get" => {
if let Some(key) = args.provider_or_key.as_deref() {
let store = SecretStore::new()?;
match store.get(key) {
Some(value) => println!("{key}: {value}"),
None => println!("{} Secret '{}' not found", "!".bright_yellow(), key),
}
return Ok(());
}
eprintln!("{} Usage: mirror get <key>", "Error:".bright_red());
return Ok(());
}
"delete" => {
if let Some(key) = args.provider_or_key.as_deref() {
let mut store = SecretStore::new()?;
store.delete(key)?;
println!("{} Secret '{}' has been deleted.", "✓".bright_green(), key);
return Ok(());
}
eprintln!("{} Usage: mirror delete <key>", "Error:".bright_red());
return Ok(());
}
"chat" => {}
"default" => {
if let Some(provider) = args.provider_or_key.as_deref() {
let mut store = SecretStore::new()?;
store.set_default_provider(provider)?;
return Ok(());
} else if args.prompt_or_value.is_none() {
let store = SecretStore::new()?;
match store.get_default_provider() {
Some(provider) => println!("Default provider: {provider}"),
None => println!("{} No default provider set", "!".bright_yellow()),
}
return Ok(());
}
eprintln!(
"{} Usage: mirror default <provider:model>",
"Error:".bright_red()
);
return Ok(());
}
_ => {}
}
}
let (provider_name, model_name) = get_provider_info(&args)
.ok_or("No provider specified. Use --provider, provider:model argument, or set a default provider with 'mirror default <provider:model>'")?;
let backend =
LLMBackend::from_str(&provider_name).map_err(|e| format!("Invalid provider: {e}"))?;
let mut builder = LLMBuilder::new().backend(backend.clone());
if let Some(model) = model_name.or(args.model.clone()) {
builder = builder.model(model);
}
if let Some(system) = args.system.clone() {
builder = builder.system(system);
}
if let Some(key) = get_api_key(&backend, &args) {
builder = builder.api_key(key);
}
if let Some(url) = args.base_url.clone() {
builder = builder.base_url(url);
}
if let Some(temp) = args.temperature {
builder = builder.temperature(temp);
}
if let Some(mt) = args.max_tokens {
builder = builder.max_tokens(mt);
}
let provider = builder
.build()
.map_err(|e| format!("Failed to build provider: {e}"))?;
let is_pipe = !io::stdin().is_terminal();
if is_pipe || args.prompt_or_value.is_some() {
let mut input = Vec::new();
io::stdin().read_to_end(&mut input)?;
let prompt = if let Some(p) = args.prompt_or_value {
p
} else {
String::from_utf8_lossy(&input).to_string()
};
let messages = process_input(&input, prompt);
match provider.chat(&messages).await {
Ok(response) => {
if let Some(text) = response.text() {
println!("{text}");
}
}
Err(e) => {
eprintln!("Error: {e}");
}
}
return Ok(());
}
println!("{}", "mirror - Interactive Chat".bright_cyan());
println!("Provider: {}", provider_name.bright_green());
println!("{}", "Type 'exit' to quit".bright_black());
println!("{}", "─".repeat(50).bright_black());
let mut rl = DefaultEditor::new()?;
let mut messages: Vec<ChatMessage> = Vec::new();
loop {
io::stdout().flush()?;
let readline = rl.readline("> ");
match readline {
Ok(line) => {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.to_lowercase() == "exit" {
println!("{}", "👋 Goodbye!".bright_cyan());
break;
}
let _ = rl.add_history_entry(trimmed);
let user_message = ChatMessage::user().content(trimmed.to_string()).build();
messages.push(user_message);
let mut sp =
Spinner::new(Spinners::Dots12, "Thinking...".bright_magenta().to_string());
match provider.chat(&messages).await {
Ok(response) => {
sp.stop();
print!("\r\x1B[K");
if let Some(text) = response.text() {
println!("{} {}", "> Assistant:".bright_green(), text);
let assistant_message = ChatMessage::assistant().content(text).build();
messages.push(assistant_message);
} else {
println!("{}", "> Assistant: (no response)".bright_red());
}
println!("{}", "─".repeat(50).bright_black());
}
Err(e) => {
sp.stop();
eprintln!("{} {}", "Error:".bright_red(), e);
println!("{}", "─".repeat(50).bright_black());
}
}
}
Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => {
println!("\n{}", "👋 Goodbye!".bright_cyan());
break;
}
Err(err) => {
eprintln!("{} {:?}", "Error:".bright_red(), err);
break;
}
}
}
Ok(())
}