koda-cli 0.5.1

A high-performance AI coding agent for macOS and Linux
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
//! CLI argument parsing and application entry point.
//!
//! Consumed by [`crate::run()`] which is the single `pub` surface
//! the binary calls into.

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 server --stdio                 # ACP stdio server for editor plugins
  koda -s abc123 \"continue\"          # resume a saved session";

/// Koda 🐻 - A high-performance AI coding agent built in Rust
#[derive(Parser, Debug)]
#[command(
    name = "koda",
    version,
    about,
    long_about = LONG_ABOUT,
    after_help = AFTER_HELP,
)]
struct Cli {
    #[command(subcommand)]
    command: Option<Command>,

    /// Run a single prompt and exit (headless mode).
    /// Use "-" to read from stdin.
    #[arg(short, long, value_name = "PROMPT")]
    prompt: Option<String>,

    /// Positional prompt (alternative to -p).
    /// `koda "fix the bug"` is equivalent to `koda -p "fix the bug"`.
    #[arg(value_name = "PROMPT", conflicts_with = "prompt")]
    positional_prompt: Option<String>,

    /// Output format for headless mode
    #[arg(long, value_enum, default_value_t = headless::OutputFormat::Text)]
    output_format: headless::OutputFormat,

    /// Agent to use (matches a JSON file in agents/)
    #[arg(short, long, default_value = "default")]
    agent: String,

    /// Session ID to resume (omit to start a new session)
    #[arg(short, long = "resume", alias = "session")]
    session: Option<String>,

    /// Project root directory (defaults to current directory)
    #[arg(long)]
    project_root: Option<PathBuf>,

    /// LLM provider base URL override
    #[arg(long, env = "KODA_BASE_URL")]
    base_url: Option<String>,

    /// Model name override
    #[arg(long, env = "KODA_MODEL")]
    model: Option<String>,

    /// LLM provider (openai, anthropic, lmstudio, llama-cpp, gemini, groq, grok, ollama)
    #[arg(long, env = "KODA_PROVIDER")]
    provider: Option<String>,

    /// Maximum output tokens
    #[arg(long)]
    max_tokens: Option<u32>,

    /// Context window in tokens, for servers that don't report theirs.
    /// Beats the agent config, the server's answer and the model table.
    #[arg(
        long,
        env = "KODA_MAX_CONTEXT_TOKENS",
        value_name = "TOKENS",
        value_parser = koda_core::config::parse_max_context_tokens
    )]
    max_context_tokens: Option<usize>,

    /// Sampling temperature (0.0 - 2.0)
    #[arg(long)]
    temperature: Option<f64>,

    /// Anthropic extended thinking budget (tokens)
    #[arg(long)]
    thinking_budget: Option<u32>,

    /// OpenAI reasoning effort (low, medium, high)
    #[arg(long)]
    reasoning_effort: Option<String>,

    /// Trust mode: auto (default) or safe.
    /// "auto" auto-approves mutating actions; the kernel sandbox
    /// contains them. Destructive ops (rm -rf, git reset --hard,
    /// git push --force, Delete) and outside-project writes still
    /// confirm. Auto requires the kernel sandbox; on unsandboxed
    /// platforms koda refuses to start with an actionable error
    /// (use --mode safe or install the platform sandbox backend).
    /// "safe" confirms every mutation before executing — use this
    /// in CI, locked-down workstations, or any context where you
    /// want a human in every approval loop.
    /// Sandbox with credential protection is always active.
    #[arg(long, env = "KODA_MODE", default_value = "auto",
          value_parser = ["safe", "auto"])]
    mode: String,
}

#[derive(Subcommand, Debug)]
enum Command {
    /// Start an ACP (Agent Client Protocol) server for editor/client integrations
    Server {
        /// WebSocket port (not yet implemented — use --stdio instead)
        #[arg(long, default_value = "9999")]
        port: u16,
        /// Use stdin/stdout JSON-RPC transport (required for VS Code, Zed, etc.)
        ///
        /// Reads newline-delimited JSON-RPC 2.0 from stdin and writes to stdout.
        /// This is the standard ACP transport for local agent integrations.
        #[arg(long)]
        stdio: bool,
    },
    /// Connect to a running Koda server (not yet implemented)
    Connect { url: String },
}

pub(crate) async fn run() -> Result<()> {
    // Intercept --version / -V before clap so we can append the
    // platform sandbox state to the version line. clap's derive
    // version handling only supports a static string, but we want
    // bug filers to see whether their kernel sandbox is available
    // (the most common variable affecting trust-mode behavior).
    // This replaces the dedicated `koda doctor` subcommand removed
    // in #1259-followup — same bug-report-aid value, no extra
    // command for users to remember.
    //
    // Only matches the bare flag: `koda --version` / `koda -V`. We
    // don't scan all positions to avoid colliding with prompts that
    // happen to contain the literal token (e.g. `koda "what is the
    // --version flag?"`).
    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 cli = Cli::parse();

    // Handle subcommands first
    if let Some(cmd) = &cli.command {
        match cmd {
            Command::Server { port, stdio } => {
                if *stdio {
                    // Init stderr tracing early so any startup hang in the
                    // stdio server is at least observable. Stdout is
                    // reserved for JSON-RPC; logs go to stderr.
                    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)?;

                    // Init DB and inject keys (#693)
                    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_context_override(cli.max_context_tokens)
                        .with_trust(parse_top_level_trust_mode(&cli.mode));
                    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);
            }
        }
    }

    // Resolve headless prompt: -p flag, positional arg, or stdin
    let headless_prompt = resolve_headless_prompt(&cli)?;

    // Resolve project root
    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)?;

    // Initialize logging to a per-process file (invisible to user).
    //
    // Each koda invocation gets its own log file named koda-<PID>.log.
    // A 'latest' symlink is updated to point to it so users can always
    // tail the current session without knowing the filename:
    //   tail -f ~/.config/koda/logs/latest
    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);
    // Best-effort symlink — silently skip on non-unix or permission errors.
    #[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);

    // Initialize database early — keys and last-provider recall live here (#693)
    let db = koda_core::db::Database::init(&koda_core::db::config_dir()?).await?;

    // Load and inject stored API keys (env vars take precedence)
    if let Err(e) = koda_core::keystore::inject_into_env(&db).await {
        tracing::warn!("Failed to load keystore: {e}");
    }

    // Headless mode: skip onboarding, banner, version check
    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_context_override(cli.max_context_tokens)
            .with_trust(parse_top_level_trust_mode(&cli.mode));
        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);
    }

    // Interactive mode: full REPL experience
    let version_check = koda_core::version::spawn_version_check();

    // Detect first run (onboarding happens inside the TUI now)
    let first_run = onboarding::is_first_run();

    // Load configuration. The TUI also restores the last-used provider from
    // the DB, but only for the fields these flags leave unset.
    let overrides = tui_context::ProviderOverrides {
        provider: cli.provider.is_some(),
        base_url: cli.base_url.is_some(),
        model: cli.model.is_some(),
    };
    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_context_override(cli.max_context_tokens)
        .with_trust(parse_top_level_trust_mode(&cli.mode));

    // Initialize database is already done above

    // Load or create session
    let session_id = match cli.session {
        Some(id) => id,
        None => db.create_session(&config.agent_name, &project_root).await?,
    };

    // Run the main event loop
    tui_app::run(
        project_root,
        config,
        overrides,
        db,
        session_id,
        version_check,
        first_run,
    )
    .await
}

/// Initialize tracing for the stdio server subcommand.
///
/// Logs go to **stderr** so stdout stays reserved for newline-delimited
/// JSON-RPC. The default filter is `info` so a freshly-spawned subprocess
/// always announces itself — invaluable when debugging a hung server from
/// an integration test (set `RUST_LOG=koda_cli=debug,koda_core=debug` for
/// more detail). `try_init` is used so we silently no-op if the parent
/// already initialized a subscriber (e.g. from `cargo test`).
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");
}

/// Parse the `--mode` CLI flag and coerce to a top-level-valid trust
/// mode, emitting a warning to stderr if Plan was requested.
///
/// **#1244**: this is the single CLI-side coercion point. Three
/// `with_trust(...)` call sites in this file used to call
/// `TrustMode::parse(&cli.mode).unwrap_or_default()` directly, which
/// silently let `--mode plan` through to the top-level session —
/// where Plan is sub-agent-only and just makes every write tool fail.
/// Funneling all three through here ensures the rule lives in one
/// place (DRY) and the warning is consistent across stdio-server,
/// headless, and TUI launch modes.
///
/// **#860 / Auto-requires-sandbox**: after the Plan coercion, also
/// validate that Auto isn't requested on a system without kernel
/// sandbox support. The check is a hard fail (process exits 1)
/// rather than a silent coerce because:
///   - Headless: `koda --mode auto -p "..."` silently becoming Safe
///     would `RejectAuto` every mutation and abort the task halfway.
///   - Interactive: explicit user intent should never be silently
///     reinterpreted (Zen of Python: errors should never pass
///     silently). The error message is enriched with a
///     platform-specific install hint via
///     [`koda_core::sandbox::setup_hint`] so users see the exact fix
///     (e.g. `apt install bubblewrap`) right at the failure site —
///     no extra subcommand to remember (#1259 follow-up).
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}");
            // Inline the platform-specific install hint so the user
            // doesn't need a second command (the old `koda doctor`)
            // to learn what to type next. Hint comes from
            // `koda-core::sandbox::setup_hint` so it stays DRY with
            // the docs and the bug-report template guidance.
            eprintln!(
                "\nSetup:\n{}",
                koda_core::sandbox::setup_hint(report.backend)
            );
            std::process::exit(1);
        }
    }
}

/// Resolve the headless prompt from -p flag, positional arg, or stdin pipe.
fn resolve_headless_prompt(cli: &Cli) -> Result<Option<String>> {
    // Explicit -p flag
    if let Some(ref p) = cli.prompt {
        if p == "-" {
            // Read from stdin
            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()));
    }

    // Positional prompt
    if let Some(ref p) = cli.positional_prompt {
        return Ok(Some(p.clone()));
    }

    // Check if stdin is piped (not a TTY) — auto-headless
    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)
}

/// Prune old `koda-<PID>.log` files, keeping only the `keep` most recent by mtime.
///
/// Called on startup before the new log file is created. Prevents unbounded
/// file-count accumulation (e.g. 18 000+ files after a year of heavy use).
/// All errors are silently discarded — a prune failure is never fatal.
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)); // newest first
    for (_, path) in files.into_iter().skip(keep) {
        let _ = std::fs::remove_file(path);
    }
}

/// Check if stdin is a terminal (not piped).
fn atty_is_terminal() -> bool {
    use std::io::IsTerminal;
    std::io::stdin().is_terminal()
}