mod agent;
mod api;
mod permissions;
mod prompt;
mod tools;
mod util;
use agent::Agent;
use anyhow::{Context, Result};
use api::GrokClient;
use clap::Parser;
use colored::Colorize;
use permissions::{PermissionMode, SessionPermissions};
use rustyline::DefaultEditor;
use serde_json::json;
use std::io::{Write, stdout};
#[derive(Parser)]
#[command(name = "grox", about = "Agentic coding with Grok")]
struct Cli {
#[arg(long)]
model: Option<String>,
#[arg(long)]
verbose: bool,
#[arg(long, conflicts_with_all = ["read_only", "yolo"])]
auto_approve_writes: bool,
#[arg(long, conflicts_with_all = ["auto_approve_writes", "yolo"])]
read_only: bool,
#[arg(long, conflicts_with_all = ["auto_approve_writes", "read_only"])]
yolo: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
let _ = dotenvy::dotenv();
let cli = Cli::parse();
let api_key = match std::env::var("XAI_API_KEY") {
Ok(key) if !key.is_empty() => key,
_ => {
eprintln!("{}", "XAI_API_KEY is not set.".red().bold());
eprintln!();
eprintln!("Get your API key at: {}", "https://console.x.ai/".cyan());
eprintln!("Then export it in your shell:");
eprintln!();
eprintln!(" {}", "export XAI_API_KEY=your-key-here".dimmed());
std::process::exit(1);
}
};
let model = cli.model
.unwrap_or_else(|| std::env::var("GROX_MODEL").unwrap_or_else(|_| "grok-3-fast".to_string()));
if cli.verbose {
unsafe { std::env::set_var("GROX_VERBOSE", "1") };
}
let permission_mode = if cli.yolo {
PermissionMode::Yolo
} else if cli.read_only {
PermissionMode::ReadOnly
} else if cli.auto_approve_writes {
PermissionMode::Trust
} else {
PermissionMode::Default
};
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let project_root = util::detect_project_root(&cwd);
println!("{}", "grox — agentic coding with Grok".bold());
println!(
"model: {} | project: {} | mode: {} | type {} to exit",
model.cyan(),
project_root.display().to_string().cyan(),
format!("{permission_mode}").cyan(),
"/quit".dimmed()
);
println!(
"{}",
"note: grox can read any file on your system. File contents are sent to xAI and stored for 30 days."
.dimmed()
);
println!();
let mut session_perms = SessionPermissions::new(permission_mode, project_root.clone());
let mut client = GrokClient::new(api_key, model);
let grox_md = util::load_grox_md(&project_root);
if let Some(ref content) = grox_md {
if content.contains("truncated") {
eprintln!(
"{}",
" warning: GROX.md exceeds 10K characters and was truncated"
.yellow()
);
}
}
let system_content = prompt::build_system_prompt(
&project_root,
grox_md.as_deref(),
);
let system_prompt = json!({
"role": "system",
"content": system_content
});
let mut previous_response_id: Option<String> = None;
let mut rl = DefaultEditor::new()?;
loop {
let input = match rl.readline(&format!("{} ", ">>".green().bold())) {
Ok(line) => line,
Err(
rustyline::error::ReadlineError::Interrupted
| rustyline::error::ReadlineError::Eof,
) => break,
Err(e) => return Err(e.into()),
};
let input = input.trim().to_string();
if input.is_empty() {
continue;
}
if input == "/quit" || input == "/exit" {
break;
}
if input.starts_with("/model ") {
let new_model = input.strip_prefix("/model ").unwrap().trim().to_string();
if new_model.is_empty() {
println!("{}", "usage: /model <name>".dimmed());
} else {
client.set_model(new_model.clone());
println!(" model switched to {}", new_model.cyan());
}
continue;
}
if input == "/status" {
println!(" model: {}", client.model().cyan());
println!(" project: {}", project_root.display().to_string().cyan());
println!(" mode: {}", format!("{permission_mode}").cyan());
println!(
" tools: {}",
tools::Tool::all()
.iter()
.map(|t| format!("{t:?}"))
.collect::<Vec<_>>()
.join(", ")
.dimmed()
);
continue;
}
rl.add_history_entry(&input)?;
let api_input = vec![
system_prompt.clone(),
json!({
"role": "user",
"content": input,
}),
];
println!();
let agent = Agent::new(&client, &project_root);
match agent
.run(
api_input,
previous_response_id.as_deref(),
&mut {
let mut first_token = true;
move |token: String| {
if first_token && !token.trim().is_empty() {
first_token = false;
}
print!("{token}");
let _ = stdout().flush();
}
},
&mut |name: &str, args: &str| {
let summary = summarize_tool_call(name, args);
println!(" {} {}", format!("▸ {name}").cyan(), summary.dimmed());
let _ = stdout().flush();
},
&mut |name: &str, output: &str| {
const MAX_DISPLAY_LINES: usize = 20;
let is_error = output.starts_with("Error:")
|| output.starts_with("File '")
|| output.starts_with("Permission denied");
if is_error {
let msg = output.lines().next().unwrap_or(output);
println!("{}", format!(" {} {}", "✗".red(), msg).dimmed());
} else if name == "shell_exec" {
if output.is_empty() || output == "(no output)" {
println!("{}", format!(" {} (no output)", "✓".green()).dimmed());
} else {
let lines: Vec<&str> = output.lines().collect();
let total = lines.len();
let show = total.min(MAX_DISPLAY_LINES);
println!("{}", format!(" {}", "✓".green()).dimmed());
for line in &lines[..show] {
println!(" {}", line.dimmed());
}
if total > MAX_DISPLAY_LINES {
println!(
" {}",
format!("... ({} more lines)", total - MAX_DISPLAY_LINES).dimmed()
);
}
}
} else if output.is_empty() {
println!("{}", format!(" {} (empty)", "✓".green()).dimmed());
} else {
let lines: Vec<&str> = output.lines().collect();
let summary = if lines.len() > 5 {
format!(" {} ({} lines)", "✓".green(), lines.len())
} else {
format!(" {} ({} bytes)", "✓".green(), output.len())
};
println!("{}", summary.dimmed());
}
},
&mut |name: &str, args: &str| -> bool {
session_perms.authorize(name, args)
},
)
.await
{
Ok(result) => {
if result.text.is_empty() {
println!(
"{}",
"(no response from model)".dimmed()
);
}
println!();
if let Some(usage) = &result.usage {
let cost_str = estimate_cost(client.model(), usage);
println!(
"{}",
format!(
" tokens: {} in / {} out{}",
usage.input_tokens, usage.output_tokens, cost_str
)
.dimmed()
);
}
previous_response_id = result.response_id;
}
Err(e) => {
eprintln!("\n{} {e}\n", "error:".red().bold());
}
}
}
println!("{}", "\ngoodbye.".dimmed());
Ok(())
}
fn estimate_cost(model: &str, usage: &api::Usage) -> String {
let pricing: Option<(f64, f64)> = match model {
m if m.starts_with("grok-3-mini") => Some((0.30, 0.50)),
m if m.starts_with("grok-3") => Some((3.00, 15.00)),
m if m.starts_with("grok-2") => Some((2.00, 10.00)),
_ => None,
};
match pricing {
Some((input_rate, output_rate)) => {
let cost = (usage.input_tokens as f64 * input_rate
+ usage.output_tokens as f64 * output_rate)
/ 1_000_000.0;
format!(" (~${cost:.4})")
}
None => String::new(),
}
}
fn summarize_tool_call(name: &str, args: &str) -> String {
let parsed: serde_json::Value = serde_json::from_str(args).unwrap_or_default();
match name {
"file_read" | "list_files" => {
parsed["path"].as_str().unwrap_or("?").to_string()
}
"grep" => {
let pattern = parsed["pattern"].as_str().unwrap_or("?");
let path = parsed["path"].as_str().unwrap_or(".");
format!("{pattern} in {path}")
}
"file_write" => {
let path = parsed["path"].as_str().unwrap_or("?");
let len = parsed["content"].as_str().map(|c| c.len()).unwrap_or(0);
format!("{path} ({len} bytes)")
}
"shell_exec" => {
let cmd = parsed["command"].as_str().unwrap_or("?");
let truncated: String = cmd.chars().take(80).collect();
if truncated.len() < cmd.len() {
format!("{truncated}…")
} else {
truncated
}
}
_ => args.to_string(),
}
}