use crate::*;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use koda_core::persistence::Persistence;
use std::path::{Path, PathBuf};
const LONG_ABOUT: &str = "Koda runs in two modes:
INTERACTIVE Run `koda` (no arguments) to open the full TUI.
Type your question and press Enter.
Type /help inside for keybindings and all commands.
HEADLESS Pass a prompt to get a single answer and exit.
Great for scripts, pipes, and CI pipelines.
koda \"explain this codebase\"
git diff | koda
koda -p - < prompt.txt
Configuration precedence (highest wins):
1. CLI flags --model, --provider, --base-url
2. Env vars KODA_MODEL, KODA_PROVIDER, KODA_BASE_URL
3. Saved config set interactively with /model, /provider, /key
4. Built-in defaults
API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, …)
follow the same order. Keys saved with /key are loaded from the
local keystore at startup and injected as env vars — shell env
vars always win over stored keys.";
const AFTER_HELP: &str = "Examples:
koda # interactive TUI (type /help inside)
koda \"explain this codebase\" # one-shot question, then exit
koda -p \"fix the failing tests\" # same, explicit flag form
koda -p - # read prompt from stdin
git diff | koda # pipe diff as the prompt
koda \"refactor\" --model o3 # one-shot with a specific model
KODA_MODEL=gemini-flash koda \"...\" # env-var model override
koda server --stdio # ACP stdio server for editor plugins
koda -s abc123 \"continue\" # resume a saved session";
#[derive(Parser, Debug)]
#[command(
name = "koda",
version,
about,
long_about = LONG_ABOUT,
after_help = AFTER_HELP,
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(short, long, value_name = "PROMPT")]
prompt: Option<String>,
#[arg(value_name = "PROMPT", conflicts_with = "prompt")]
positional_prompt: Option<String>,
#[arg(long, default_value = "text", value_parser = ["text", "json"])]
output_format: String,
#[arg(short, long, default_value = "default")]
agent: String,
#[arg(short, long = "resume", alias = "session")]
session: Option<String>,
#[arg(long)]
project_root: Option<PathBuf>,
#[arg(long, env = "KODA_BASE_URL")]
base_url: Option<String>,
#[arg(long, env = "KODA_MODEL")]
model: Option<String>,
#[arg(long, env = "KODA_PROVIDER")]
provider: Option<String>,
#[arg(long)]
max_tokens: Option<u32>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
thinking_budget: Option<u32>,
#[arg(long)]
reasoning_effort: Option<String>,
#[arg(long, env = "KODA_MODE", default_value = "safe",
value_parser = ["safe", "auto"])]
mode: String,
}
#[derive(Subcommand, Debug)]
enum Command {
Server {
#[arg(long, default_value = "9999")]
port: u16,
#[arg(long)]
stdio: bool,
},
Connect { url: String },
}
pub(crate) async fn run() -> Result<()> {
let cli = Cli::parse();
if let Some(cmd) = &cli.command {
match cmd {
Command::Server { port, stdio } => {
if *stdio {
init_server_tracing();
let project_root = cli.project_root.clone().unwrap_or_else(|| {
std::env::current_dir().expect("Failed to get current directory")
});
let project_root = std::fs::canonicalize(&project_root)?;
let db = koda_core::db::Database::init(&koda_core::db::config_dir()?).await?;
if let Err(e) = koda_core::keystore::inject_into_env(&db).await {
tracing::warn!("Failed to load keystore: {e}");
}
let config = koda_core::config::KodaConfig::load(&project_root, &cli.agent)?;
let config = config
.with_overrides(
cli.base_url.clone(),
cli.model.clone(),
cli.provider.clone(),
)
.with_model_overrides(
cli.max_tokens,
cli.temperature,
cli.thinking_budget,
cli.reasoning_effort.clone(),
)
.with_trust(
koda_core::trust::TrustMode::parse(&cli.mode).unwrap_or_default(),
);
server::run_stdio_server(project_root, config).await?;
} else {
eprintln!("WebSocket server (--port {port}) not yet implemented. Use --stdio.");
std::process::exit(1);
}
return Ok(());
}
Command::Connect { url } => {
println!("Not implemented: Connect to {}", url);
std::process::exit(0);
}
}
}
let headless_prompt = resolve_headless_prompt(&cli)?;
let project_root = cli
.project_root
.unwrap_or_else(|| std::env::current_dir().expect("Failed to get current directory"));
let project_root = std::fs::canonicalize(&project_root)?;
let log_dir = koda_core::db::config_dir()?.join("logs");
std::fs::create_dir_all(&log_dir)?;
prune_old_logs(&log_dir, 50);
let log_filename = format!("koda-{}.log", std::process::id());
let file_appender = tracing_appender::rolling::never(&log_dir, &log_filename);
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
#[cfg(unix)]
{
let latest = log_dir.join("latest");
let _ = std::fs::remove_file(&latest);
let _ = std::os::unix::fs::symlink(log_dir.join(&log_filename), &latest);
}
tracing_subscriber::fmt()
.with_writer(non_blocking)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new("koda_core=info,koda_cli=info")
}),
)
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE)
.init();
tracing::info!("Koda starting. Project root: {:?}", project_root);
let db = koda_core::db::Database::init(&koda_core::db::config_dir()?).await?;
if let Err(e) = koda_core::keystore::inject_into_env(&db).await {
tracing::warn!("Failed to load keystore: {e}");
}
if let Some(prompt) = headless_prompt {
let config = koda_core::config::KodaConfig::load(&project_root, &cli.agent)?;
let config = config
.with_overrides(cli.base_url, cli.model, cli.provider)
.with_model_overrides(
cli.max_tokens,
cli.temperature,
cli.thinking_budget,
cli.reasoning_effort,
)
.with_trust(koda_core::trust::TrustMode::parse(&cli.mode).unwrap_or_default());
let session_id = match cli.session {
Some(id) => id,
None => db.create_session(&config.agent_name, &project_root).await?,
};
let exit_code = headless::run_headless(
project_root,
config,
db,
session_id,
prompt,
&cli.output_format,
)
.await?;
std::process::exit(exit_code);
}
let version_check = koda_core::version::spawn_version_check();
let first_run = onboarding::is_first_run();
let config = koda_core::config::KodaConfig::load(&project_root, &cli.agent)?;
let config = config
.with_overrides(cli.base_url, cli.model, cli.provider)
.with_model_overrides(
cli.max_tokens,
cli.temperature,
cli.thinking_budget,
cli.reasoning_effort,
)
.with_trust(koda_core::trust::TrustMode::parse(&cli.mode).unwrap_or_default());
let session_id = match cli.session {
Some(id) => id,
None => db.create_session(&config.agent_name, &project_root).await?,
};
tui_app::run(
project_root,
config,
db,
session_id,
version_check,
first_run,
)
.await
}
fn init_server_tracing() {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("koda_core=info,koda_cli=info"));
let _ = tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(filter)
.with_target(true)
.try_init();
tracing::info!("koda server --stdio: tracing initialized");
}
fn resolve_headless_prompt(cli: &Cli) -> Result<Option<String>> {
if let Some(ref p) = cli.prompt {
if p == "-" {
use std::io::Read;
let mut input = String::new();
std::io::stdin()
.read_to_string(&mut input)
.context("Failed to read from stdin")?;
return Ok(Some(input.trim().to_string()));
}
return Ok(Some(p.clone()));
}
if let Some(ref p) = cli.positional_prompt {
return Ok(Some(p.clone()));
}
if !atty_is_terminal() {
use std::io::Read;
let mut input = String::new();
std::io::stdin()
.read_to_string(&mut input)
.context("Failed to read from stdin")?;
let trimmed = input.trim().to_string();
if !trimmed.is_empty() {
return Ok(Some(trimmed));
}
}
Ok(None)
}
fn prune_old_logs(log_dir: &Path, keep: usize) {
let Ok(entries) = std::fs::read_dir(log_dir) else {
return;
};
let mut files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name();
let s = name.to_string_lossy();
s.starts_with("koda-") && s.ends_with(".log")
})
.filter_map(|e| {
e.metadata()
.ok()
.and_then(|m| m.modified().ok())
.map(|t| (t, e.path()))
})
.collect();
files.sort_by_key(|e| std::cmp::Reverse(e.0)); for (_, path) in files.into_iter().skip(keep) {
let _ = std::fs::remove_file(path);
}
}
fn atty_is_terminal() -> bool {
use std::io::IsTerminal;
std::io::stdin().is_terminal()
}