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
(a --base-url used in the TUI is remembered too)
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-latest koda \"...\" # env-var model override
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, value_enum, default_value_t = headless::OutputFormat::Text)]
output_format: headless::OutputFormat,
#[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,
env = "KODA_MAX_CONTEXT_TOKENS",
value_name = "TOKENS",
value_parser = koda_core::config::parse_max_context_tokens
)]
max_context_tokens: Option<usize>,
#[arg(long)]
temperature: Option<f64>,
#[arg(long)]
thinking_budget: Option<u32>,
#[arg(
long,
alias = "effort",
env = "KODA_REASONING_EFFORT",
value_name = "LEVEL"
)]
reasoning_effort: Option<String>,
#[arg(long, env = "KODA_MODE", default_value = "auto",
value_parser = ["safe", "auto"])]
mode: String,
}
#[derive(Subcommand, Debug)]
enum Command {
#[command(hide = true)]
Server {
#[arg(hide = true, trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
}
const SERVER_REMOVED: &str = "`koda server` has been removed. Koda no longer speaks ACP, \
so editors can't connect to it.
Run `koda` in a terminal, or `koda -p <prompt> --output-format stream-json` from a script.
See https://lijunzh.github.io/koda/acp.html";
pub(crate) async fn run() -> Result<()> {
let raw: Vec<String> = std::env::args().collect();
if raw.len() == 2 && (raw[1] == "--version" || raw[1] == "-V") {
let report = koda_core::sandbox::dependency_report();
let sandbox = if report.available {
format!("sandbox: available [{}]", report.backend)
} else {
format!("sandbox: UNAVAILABLE [{}]", report.backend)
};
println!("koda {} ({sandbox})", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let mut cli = Cli::parse();
if matches!(cli.command, Some(Command::Server { .. })) {
eprintln!("{SERVER_REMOVED}");
std::process::exit(1);
}
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 first_run = onboarding::is_first_run();
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, log_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_ansi(false)
.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}");
}
let alias = match cli.model.as_deref() {
Some(name) => {
koda_core::model_alias::resolve_for_flag(
name,
cli.provider.as_deref(),
cli.base_url.as_deref(),
)
.await
}
None => None,
};
if alias.is_some() {
cli.model = None;
}
let with_alias = |config: koda_core::config::KodaConfig| match &alias {
Some(a) => config.with_alias(a),
None => config,
};
if let Some(prompt) = headless_prompt {
let config = koda_core::config::KodaConfig::load(&project_root, &cli.agent)?;
let typed = (cli.reasoning_effort.clone(), cli.thinking_budget);
let config = with_alias(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_context_override(cli.max_context_tokens)
.with_trust(parse_top_level_trust_mode(&cli.mode));
check_typed_reasoning(typed.0.as_deref(), typed.1, &config)?;
let session_id = match cli.session {
Some(id) => db.resolve_session_id(&id).await?,
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?;
drop(log_guard);
std::process::exit(exit_code);
}
let version_check = koda_core::version::spawn_version_check();
let overrides = tui_context::ProviderOverrides {
provider: cli.provider.is_some() || alias.is_some(),
base_url: cli.base_url.is_some(),
model: cli.model.is_some() || alias.is_some(),
};
let config = koda_core::config::KodaConfig::load(&project_root, &cli.agent)?;
let typed = (cli.reasoning_effort.clone(), cli.thinking_budget);
let config = with_alias(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_context_override(cli.max_context_tokens)
.with_trust(parse_top_level_trust_mode(&cli.mode));
check_typed_reasoning(typed.0.as_deref(), typed.1, &config)?;
let session_id = match cli.session {
Some(id) => db.resolve_session_id(&id).await?,
None => db.create_session(&config.agent_name, &project_root).await?,
};
tui_app::run(
project_root,
config,
overrides,
db,
session_id,
version_check,
first_run,
)
.await
}
fn check_typed_reasoning(
effort: Option<&str>,
thinking_budget: Option<u32>,
config: &koda_core::config::KodaConfig,
) -> Result<()> {
if config.provider_type != koda_core::config::ProviderType::Anthropic {
return Ok(());
}
let model = &config.model_settings.model;
if let Some(level) = effort {
koda_core::model_effort::check_typed(model, level).map_err(anyhow::Error::msg)?;
}
if thinking_budget.is_some() && koda_core::model_effort::rejects_thinking_budget(model) {
anyhow::bail!(
"{model} takes no --thinking-budget. Use --effort with one of: {}.",
koda_core::model_effort::LEVELS.join(", ")
);
}
Ok(())
}
fn parse_top_level_trust_mode(cli_mode: &str) -> koda_core::trust::TrustMode {
let parsed = koda_core::trust::TrustMode::parse(cli_mode).unwrap_or_default();
let (mode, warning) = koda_core::trust::coerce_for_top_level(parsed);
if let Some(w) = warning {
eprintln!("warning: {w}");
}
let report = koda_core::sandbox::dependency_report();
match koda_core::trust::require_sandbox_for_auto(mode, report.available) {
Ok(m) => m,
Err(msg) => {
eprintln!("error: {msg}");
eprintln!(
"\nSetup:\n{}",
koda_core::sandbox::setup_hint(report.backend)
);
std::process::exit(1);
}
}
}
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()
}