mod agent;
mod budget;
mod cache;
mod client;
mod config;
mod git;
mod history;
mod hooks;
mod index;
mod init;
mod mcp;
mod plan;
mod sessions;
mod setup;
mod telemetry;
mod tools;
mod tui;
mod ui;
use anyhow::Result;
use clap::{CommandFactory, Parser};
use config::{ConfigFile, ResolvedConfig};
#[derive(Parser, Debug)]
#[command(
name = "parecode",
about = "A hyper-efficient coding agent for local and cloud LLMs",
long_about = None,
)]
struct Args {
task: Option<String>,
#[arg(short, long, env = "PARECODE_PROFILE")]
profile: Option<String>,
#[arg(long, env = "PARECODE_ENDPOINT")]
endpoint: Option<String>,
#[arg(short, long, env = "PARECODE_MODEL")]
model: Option<String>,
#[arg(long, env = "PARECODE_API_KEY")]
api_key: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
quick: bool,
#[arg(short, long)]
verbose: bool,
#[arg(long)]
timestamps: bool,
#[arg(long)]
init: bool,
#[arg(long)]
profiles: bool,
#[arg(long, value_name = "SHELL")]
completions: Option<String>,
#[arg(long)]
update: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
if args.init {
let path = ConfigFile::write_default_if_missing()?;
println!("Config written to: {}", path.display());
println!("Edit it, then run: parecode");
return Ok(());
}
if let Some(shell_name) = &args.completions {
return generate_completions(shell_name);
}
if args.update {
return self_update().await;
}
if !config::config_path().exists()
&& args.endpoint.is_none()
&& args.model.is_none()
{
match setup::run_setup_wizard().await {
Ok(true) => {
if let Some(hint) = setup::shell_completion_hint() {
println!();
println!("{hint}");
}
println!();
}
Ok(false) => {
}
Err(e) => {
eprintln!(" Setup wizard error: {e}");
eprintln!(" Falling back to defaults. Run `parecode --init` to configure.");
}
}
}
let file = ConfigFile::load()?;
if args.profiles {
print_profiles(&file);
return Ok(());
}
let resolved = ResolvedConfig::resolve(
&file,
args.profile.as_deref(),
args.endpoint.as_deref(),
args.model.as_deref(),
args.api_key.as_deref(),
);
if let Some(task) = args.task {
if args.quick {
run_single_shot_quick(task, resolved, args.verbose).await?;
} else {
run_single_shot(task, file, resolved, args.verbose, args.dry_run).await?;
}
return Ok(());
}
let update_notice = tokio::spawn(async { setup::check_for_update().await });
tui::run(file, resolved, args.verbose, args.dry_run, args.timestamps, update_notice).await
}
async fn run_single_shot(
task: String,
_file: ConfigFile,
resolved: ResolvedConfig,
verbose: bool,
dry_run: bool,
) -> Result<()> {
use tokio::sync::mpsc;
println!();
println!(" ▲ parecode {} · {}", resolved.profile_name, resolved.model);
println!();
println!(" task: {task}");
println!();
let mut client = client::Client::new(resolved.endpoint.clone(), resolved.model.clone());
if let Some(key) = &resolved.api_key {
client.set_api_key(key.clone());
}
let mcp = mcp::McpClient::new(&resolved.mcp_servers).await;
let hook_config = if !resolved.hooks_disabled && resolved.hooks.is_empty() {
hooks::write_hooks_to_config(&resolved.profile_name)
} else {
resolved.hooks.clone()
};
let config = agent::AgentConfig {
verbose,
dry_run,
context_tokens: resolved.context_tokens,
_profile_name: resolved.profile_name.clone(),
_model: resolved.model.clone(),
_show_timestamps: false,
mcp,
hooks: std::sync::Arc::new(hook_config),
hooks_enabled: !resolved.hooks_disabled,
auto_commit: false,
auto_commit_prefix: String::new(),
git_context: false,
};
let (tx, mut rx) = mpsc::unbounded_channel::<tui::UiEvent>();
let agent_handle = tokio::spawn(async move {
agent::run_tui(&task, &client, &config, vec![], None, tx).await
});
while let Some(ev) = rx.recv().await {
print_event_plain(&ev);
}
agent_handle.await??;
Ok(())
}
fn print_event_plain(ev: &tui::UiEvent) {
use tui::UiEvent;
match ev {
UiEvent::Chunk(c) => {
print!("{c}");
let _ = std::io::Write::flush(&mut std::io::stdout());
}
UiEvent::ThinkingChunk(_) => {} UiEvent::ToolCall { name, args_summary } => {
println!("\n {} {name} {args_summary}", ui::tool_glyph(name));
}
UiEvent::ToolResult { summary } => {
let first = summary.lines().next().unwrap_or(summary);
println!(" → {first}");
}
UiEvent::CacheHit { path } => {
println!(" ↩ cache {path}");
}
UiEvent::LoopWarning { tool_name } => {
println!(" ⚠ loop detected on {tool_name}");
}
UiEvent::BudgetWarning => {
println!(" ⟳ context compressed");
}
UiEvent::ToolBudgetHit { limit } => {
println!("\n ■ tool call limit ({limit}) reached");
}
UiEvent::AgentDone { input_tokens, output_tokens, tool_calls, compressed_count, .. } => {
let compressed = if *compressed_count > 0 {
format!(" {compressed_count} outputs truncated")
} else {
String::new()
};
println!("\n\n ✓ in {input_tokens} out {output_tokens} tools {tool_calls}{compressed}");
}
UiEvent::AgentError(e) => {
println!("\n ✗ {e}");
}
UiEvent::TokenStats { input, output, total_input, total_output } => {
println!(" · i:{input} o:{output} ∑i:{total_input} ∑o:{total_output}");
}
UiEvent::ContextUpdate { .. } => {} UiEvent::HookOutput { event, output, exit_code } => {
if !output.trim().is_empty() {
let mark = if *exit_code == 0 { "✓" } else { "✗" };
println!(" ⚙ {event} {mark}: {}", output.lines().next().unwrap_or(""));
}
}
UiEvent::PlanReady(_)
| UiEvent::PlanGenerateFailed(_)
| UiEvent::PlanStepStart { .. }
| UiEvent::PlanStepDone { .. }
| UiEvent::PlanComplete { .. }
| UiEvent::PlanFailed { .. }
| UiEvent::GitChanges { .. }
| UiEvent::GitAutoCommit { .. }
| UiEvent::GitError(_) => {}
UiEvent::SystemMsg(msg) => {
println!(" {msg}");
}
}
}
async fn run_single_shot_quick(
task: String,
resolved: ResolvedConfig,
verbose: bool,
) -> Result<()> {
use tokio::sync::mpsc;
println!();
println!(" ⚡ parecode quick {} · {}", resolved.profile_name, resolved.model);
println!();
let mut client = client::Client::new(resolved.endpoint.clone(), resolved.model.clone());
if let Some(key) = &resolved.api_key {
client.set_api_key(key.clone());
}
let mcp = mcp::McpClient::new(&resolved.mcp_servers).await;
let config = agent::AgentConfig {
verbose,
dry_run: false,
context_tokens: resolved.context_tokens,
_profile_name: resolved.profile_name.clone(),
_model: resolved.model.clone(),
_show_timestamps: false,
mcp,
hooks: std::sync::Arc::new(hooks::HookConfig::default()),
hooks_enabled: false,
auto_commit: false,
auto_commit_prefix: String::new(),
git_context: false,
};
let (tx, mut rx) = mpsc::unbounded_channel::<tui::UiEvent>();
let agent_handle = tokio::spawn(async move {
agent::run_quick(&task, &client, &config, tx).await
});
while let Some(ev) = rx.recv().await {
print_event_plain(&ev);
}
agent_handle.await??;
Ok(())
}
fn print_profiles(file: &ConfigFile) {
let mut entries: Vec<(String, String, String, u32)> = file
.profiles
.iter()
.map(|(name, p)| (name.clone(), p.endpoint.clone(), p.model.clone(), p.context_tokens))
.collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
println!();
println!(" Profiles");
for (name, endpoint, model, ctx) in &entries {
let marker = if *name == file.default_profile { " ←" } else { "" };
println!(" {name}{marker}");
println!(" endpoint {endpoint}");
println!(" model {model}");
println!(" context {}k", ctx / 1000);
println!();
}
}
fn generate_completions(shell_name: &str) -> Result<()> {
use clap_complete::{Shell, generate};
let shell: Shell = match shell_name.to_lowercase().as_str() {
"bash" => Shell::Bash,
"zsh" => Shell::Zsh,
"fish" => Shell::Fish,
"elvish" => Shell::Elvish,
_ => {
eprintln!("Unknown shell: {shell_name}");
eprintln!("Supported: bash, zsh, fish, elvish");
std::process::exit(1);
}
};
let mut cmd = Args::command();
generate(shell, &mut cmd, "parecode", &mut std::io::stdout());
Ok(())
}
async fn self_update() -> Result<()> {
use std::io::Write;
let current = env!("CARGO_PKG_VERSION");
print!(" Checking for updates... ");
std::io::stdout().flush()?;
let update = setup::check_for_update().await;
match update {
None => {
println!("parecode {current} is already the latest version.");
return Ok(());
}
Some((_, latest)) => {
println!("parecode {current} → {latest} available");
println!();
let target = detect_target();
let Some(target) = target else {
eprintln!(" ✗ Could not detect platform. Update manually from:");
eprintln!(" https://github.com/PartTimer1996/parecode/releases/latest");
std::process::exit(1);
};
print!(" Downloading parecode {latest} for {target}... ");
std::io::stdout().flush()?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()?;
let release_url = "https://api.github.com/repos/PartTimer1996/parecode/releases/latest";
let resp = client
.get(release_url)
.header("User-Agent", "parecode")
.send()
.await?;
if !resp.status().is_success() {
println!("✗");
eprintln!(" Failed to fetch release info (HTTP {})", resp.status());
std::process::exit(1);
}
let body: serde_json::Value = resp.json().await?;
let assets = body["assets"].as_array().ok_or_else(|| {
anyhow::anyhow!("No assets in release")
})?;
let is_windows = target.contains("windows");
let unix_exts = [".tar.xz", ".tar.gz"];
let asset_url = if is_windows {
assets.iter().find_map(|a| {
let name = a["name"].as_str()?;
if name.contains(&target) && name.ends_with(".zip") {
a["browser_download_url"].as_str().map(|s| s.to_string())
} else {
None
}
})
} else {
unix_exts.iter().find_map(|ext| {
assets.iter().find_map(|a| {
let name = a["name"].as_str()?;
if name.contains(&target) && name.ends_with(ext) {
a["browser_download_url"].as_str().map(|s| s.to_string())
} else {
None
}
})
})
};
let Some(download_url) = asset_url else {
println!("✗");
eprintln!(" No matching asset for target {target}");
eprintln!(" Check: https://github.com/PartTimer1996/parecode/releases/latest");
std::process::exit(1);
};
let resp = client
.get(&download_url)
.header("User-Agent", "parecode")
.send()
.await?;
if !resp.status().is_success() {
println!("✗");
eprintln!(" Download failed (HTTP {})", resp.status());
std::process::exit(1);
}
let bytes = resp.bytes().await?;
println!("✓ ({:.1} MB)", bytes.len() as f64 / 1_048_576.0);
print!(" Extracting... ");
std::io::stdout().flush()?;
let binary_name = if is_windows { "parecode.exe" } else { "parecode" };
let extracted = if is_windows {
extract_from_zip(&bytes, binary_name)?
} else if download_url.ends_with(".tar.xz") {
extract_from_tar_xz(&bytes, binary_name)?
} else {
extract_from_tar_gz(&bytes, binary_name)?
};
println!("✓");
let current_exe = std::env::current_exe()?;
print!(" Replacing {}... ", current_exe.display());
std::io::stdout().flush()?;
replace_exe(¤t_exe, &extracted)?;
println!("✓");
println!();
println!(" parecode {latest} installed.");
}
}
Ok(())
}
fn detect_target() -> Option<String> {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
match (arch, os) {
("x86_64", "linux") => Some("x86_64-unknown-linux-musl".to_string()),
("aarch64", "linux") => Some("aarch64-unknown-linux-musl".to_string()),
("x86_64", "macos") => Some("x86_64-apple-darwin".to_string()),
("aarch64", "macos") => Some("aarch64-apple-darwin".to_string()),
("x86_64", "windows") => Some("x86_64-pc-windows-msvc".to_string()),
_ => None,
}
}
fn extract_from_tar_xz(data: &[u8], target_name: &str) -> Result<Vec<u8>> {
use std::io::Read;
let decoder = xz2::read::XzDecoder::new(data);
let mut archive = tar::Archive::new(decoder);
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?;
if let Some(fname) = path.file_name() {
if fname == target_name {
let mut buf = Vec::new();
entry.read_to_end(&mut buf)?;
return Ok(buf);
}
}
}
anyhow::bail!("Binary '{target_name}' not found in archive")
}
fn extract_from_tar_gz(data: &[u8], target_name: &str) -> Result<Vec<u8>> {
use std::io::Read;
let decoder = flate2::read::GzDecoder::new(data);
let mut archive = tar::Archive::new(decoder);
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?;
if let Some(fname) = path.file_name() {
if fname == target_name {
let mut buf = Vec::new();
entry.read_to_end(&mut buf)?;
return Ok(buf);
}
}
}
anyhow::bail!("Binary '{target_name}' not found in archive")
}
fn extract_from_zip(data: &[u8], target_name: &str) -> Result<Vec<u8>> {
use std::io::Read;
let reader = std::io::Cursor::new(data);
let mut zip = zip::ZipArchive::new(reader)?;
for i in 0..zip.len() {
let mut file = zip.by_index(i)?;
let path = std::path::Path::new(file.name());
if let Some(fname) = path.file_name() {
if fname == target_name {
let mut buf = Vec::new();
file.read_to_end(&mut buf)?;
return Ok(buf);
}
}
}
anyhow::bail!("Binary '{target_name}' not found in zip")
}
fn replace_exe(current_path: &std::path::Path, new_data: &[u8]) -> Result<()> {
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
let dir = current_path.parent().unwrap_or(std::path::Path::new("."));
let backup = dir.join("parecode.old");
let staging = dir.join("parecode.new");
fs::write(&staging, new_data)?;
#[cfg(unix)]
fs::set_permissions(&staging, fs::Permissions::from_mode(0o755))?;
if backup.exists() {
let _ = fs::remove_file(&backup);
}
fs::rename(current_path, &backup)?;
if let Err(e) = fs::rename(&staging, current_path) {
let _ = fs::rename(&backup, current_path);
return Err(e.into());
}
let _ = fs::remove_file(&backup);
Ok(())
}