//! `codecraft` – CodeCraft in the terminal. //! //! A local coding agent: tools and files stay on this machine; models come //! from a CodeCraft server (sign in) and/or your own providers (standalone). mod agent; mod app; mod conf; mod editor; mod i18n; mod models; mod server; mod session; mod stats; mod theme; mod tools; use agent::{Agent, Env, Reply, UiEvent, builtin_modes, modes_from_server}; use anyhow::{Result, bail}; use app::Cmd; use clap::{Parser, Subcommand}; use codecraft_core::config::PermissionConfig; use conf::Config; use serde_json::Value; use session::LocalSession; use std::io::{IsTerminal, Write}; use std::sync::Arc; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; #[derive(Parser)] #[command(name = "codecraft", version = codecraft_core::version::VERSION, about = "CodeCraft in the terminal – a local coding agent")] struct Cli { /// Start with this prompt prompt: Option, /// Continue the last session in this folder #[arg(short, long)] r#continue: bool, /// Resume a session by id #[arg(long)] resume: Option, /// Model ("provider/model" or "server:provider/model") #[arg(short, long)] model: Option, /// Mode: build, plan, craft … #[arg(long)] mode: Option, #[command(subcommand)] cmd: Option, } #[derive(Subcommand)] enum Sub { /// Sign in to a CodeCraft server (creates an API token for this machine) Login { url: Option, username: Option }, /// Forget the server login Logout, /// Guided setup: server login and own model providers Setup, /// Run one prompt without the full-screen UI (for scripts) Run { prompt: String, #[arg(short, long)] model: Option, #[arg(long, default_value = "build")] mode: String, /// Permission mode: default, auto, accept_edits, bypass #[arg(long, default_value = "auto")] permissions: String, }, /// Update codecraft from your server Update, /// List local sessions Sessions, } /// Configuration, models and modes (server and/or local). pub async fn build_env() -> Result { let cfg = Config::load()?; let server = cfg.server.as_ref().map(server::Server::new); let mut server_cfg = None; let mut offline = None; if let Some(s) = &server { match tokio::time::timeout(std::time::Duration::from_secs(10), s.get("/cli/config")).await { Ok(Ok(v)) => server_cfg = Some(v), Ok(Err(e)) => offline = Some(format!("{e:#}")), Err(_) => offline = Some("server timeout".into()), } } let catalog = models::Catalog::load(&cfg, server_cfg.as_ref()).await; let sc = server_cfg.clone().unwrap_or(Value::Null); let modes = if server_cfg.is_some() { modes_from_server(&sc) } else { builtin_modes() }; let permissions: PermissionConfig = serde_json::from_value(sc["permissions"].clone()).unwrap_or_default(); let remote_tools = sc["mcp_tools"] .as_array() .into_iter() .flatten() .map(|t| tools::ToolSpec { name: t["name"].as_str().unwrap_or("").into(), description: t["description"].as_str().unwrap_or("").into(), schema: t["input_schema"].clone(), read_only: false, is_edit: false, remote: true, }) .collect(); let server_skills = sc["skills"].as_array().into_iter().flatten().map(|s| (s["name"].as_str().unwrap_or("").to_string(), s["description"].as_str().unwrap_or("").to_string())).collect(); // Language and theme: local override, else the server's, else the system's. let prefs = &sc["prefs"]; let lang = cfg.lang.clone().or_else(|| prefs["lang"].as_str().map(String::from)).unwrap_or_else(i18n::system_lang); i18n::set_lang(&lang); let base = cfg.theme.clone().or_else(|| prefs["theme"].as_str().map(String::from)).unwrap_or_else(|| "gruvbox".into()); let mode = cfg.theme_mode.clone().or_else(|| prefs["theme_mode"].as_str().map(String::from)).unwrap_or_else(|| "auto".into()); theme::set(theme::Theme::load(&base, &mode)); let env = Env { base_prompt: sc["base_prompt"].as_str().unwrap_or(codecraft_core::prompts::BASE_PROMPT).to_string(), auto_prompt: sc["auto_mode_prompt"].as_str().unwrap_or(codecraft_core::prompts::AUTO_MODE_PROMPT).to_string(), memory: sc["memory"].as_str().unwrap_or("").to_string(), cfg, server, server_cfg, catalog, modes, permissions, remote_tools, server_skills, }; if let Some(e) = offline { eprintln!("warning: CodeCraft server not reachable ({e}) – using local providers only"); } Ok(env) } fn prompt_line(label: &str, secret: bool) -> Result { eprint!("{label}: "); std::io::stderr().flush()?; if !secret || !std::io::stdin().is_terminal() { let mut s = String::new(); std::io::stdin().read_line(&mut s)?; return Ok(s.trim().to_string()); } use crossterm::event::{Event, KeyCode, KeyEventKind, read}; crossterm::terminal::enable_raw_mode()?; let mut s = String::new(); let res = loop { match read() { Ok(Event::Key(k)) if k.kind != KeyEventKind::Release => match k.code { KeyCode::Enter => break Ok(()), KeyCode::Backspace => { s.pop(); } KeyCode::Esc => break Err(anyhow::anyhow!("cancelled")), KeyCode::Char('c') if k.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => break Err(anyhow::anyhow!("cancelled")), KeyCode::Char(c) => s.push(c), _ => {} }, Ok(_) => {} Err(e) => break Err(e.into()), } }; crossterm::terminal::disable_raw_mode()?; eprintln!(); res.map(|_| s) } fn version_newer(server: &str, ours: &str) -> bool { codecraft_core::version::newer(server, ours) } fn target() -> String { format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH) } async fn self_update() -> Result<()> { let cfg = Config::load()?; let Some(login) = &cfg.server else { bail!("not signed in – updates come from your CodeCraft server (codecraft login)") }; let http = server::http(); let info: Value = http.get(format!("{}/api/info", login.url)).send().await?.json().await?; let latest = info["version"].as_str().unwrap_or("0"); if !version_newer(latest, codecraft_core::version::VERSION) { println!("codecraft {} is up to date.", codecraft_core::version::VERSION); return Ok(()); } let url = format!("{}/cli/download/{}", login.url, target()); println!("Downloading codecraft {latest} for {} …", target()); let resp = http.get(&url).send().await?; if !resp.status().is_success() { bail!("no build for {} on the server ({})", target(), resp.status()); } let bytes = resp.bytes().await?; let exe = std::env::current_exe()?; let tmp = exe.with_extension("new"); std::fs::write(&tmp, &bytes)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?; } std::fs::rename(&tmp, &exe)?; println!("Updated to {latest}."); Ok(()) } /// Process commands for the agent (one turn at a time). fn spawn_agent(env: Arc, session: LocalSession, ui: mpsc::UnboundedSender) -> mpsc::UnboundedSender { let (tx, mut rx) = mpsc::unbounded_channel::(); tokio::spawn(async move { let mut agent = Agent::new(env, session, ui.clone()); while let Some(cmd) = rx.recv().await { match cmd { Cmd::Prompt { text, images, effort, cancel } => agent.prompt(text, images, effort, cancel).await, Cmd::SetModel(m) => { agent.session.model = m; let _ = agent.session.save(); } Cmd::SetMode(m) => { agent.session.mode = m; let _ = agent.session.save(); } Cmd::SetPermission(p) => { agent.session.permission_mode = p; let _ = agent.session.save(); } Cmd::Load(s) => agent = Agent::new(agent.env.clone(), *s, ui.clone()), Cmd::New => { let s = LocalSession::new(&agent.session.cwd, &agent.session.model, &agent.session.mode, &agent.session.permission_mode); agent = Agent::new(agent.env.clone(), s, ui.clone()); } Cmd::Compact(cancel) => { let _ = ui.send(UiEvent::Busy(true)); let model = agent.env.catalog.find(&agent.session.model).cloned(); match model { Some(m) => { if let Err(e) = agent.compact(&m, &cancel).await { let _ = ui.send(UiEvent::Error(format!("{e:#}"))); } } None => { let _ = ui.send(UiEvent::Error(i18n::t("no_model").into())); } } let _ = ui.send(UiEvent::Busy(false)); } Cmd::SetEnv(e) => agent.env = e, } } }); tx } fn default_model(env: &Env, cfg: &Config, wanted: Option) -> String { wanted .or_else(|| cfg.default_model.clone()) .filter(|m| env.catalog.find(m).is_some()) .or_else(|| env.server_cfg.as_ref().and_then(|s| s["default_model"].as_str()).map(|m| format!("{}{m}", models::SERVER_PREFIX)).filter(|m| env.catalog.find(m).is_some())) .or_else(|| env.catalog.models.first().map(|m| m.spec.clone())) .unwrap_or_default() } /// One prompt without the UI: answer on stdout, tools on stderr. async fn run_once(prompt: String, model: Option, mode: String, permissions: String) -> Result<()> { let env = Arc::new(build_env().await?); let cwd = std::env::current_dir()?.display().to_string(); let model = default_model(&env, &env.cfg.clone(), model); if model.is_empty() { bail!("no model configured – run `codecraft setup`"); } let session = LocalSession::new(&cwd, &model, &mode, &permissions); let (ui_tx, mut ui_rx) = mpsc::unbounded_channel(); let mut agent = Agent::new(env, session, ui_tx); let cancel = CancellationToken::new(); let task = tokio::spawn(async move { agent.prompt(prompt, vec![], None, cancel).await }); let interactive = std::io::stdin().is_terminal(); while let Some(ev) = ui_rx.recv().await { match ev { UiEvent::Text(t) => { print!("{t}"); std::io::stdout().flush()?; } UiEvent::ToolStart { name, input, .. } => eprintln!("\n> {name} {}", input["description"].as_str().or(input["command"].as_str()).or(input["path"].as_str()).unwrap_or("")), UiEvent::Error(e) => eprintln!("error: {e}"), UiEvent::Permission { tool, title, reply, .. } => { let answer = if interactive { prompt_line(&format!("allow {tool} {title}? [y/a/N]"), false)? } else { String::new() }; let _ = reply.send(match answer.as_str() { "y" | "j" => Reply::Once, "a" => Reply::Always, _ => Reply::Reject(Some(if interactive { "rejected".into() } else { "not allowed in non-interactive mode – use --permissions bypass or accept_edits".into() })), }); } UiEvent::Question { reply, .. } => { let _ = reply.send(None); } UiEvent::Busy(false) => break, _ => {} } } let _ = task.await; println!(); Ok(()) } #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); match cli.cmd { Some(Sub::Login { url, username }) => { let url = match url { Some(u) => u, None => prompt_line("CodeCraft server URL", false)?, }; let user = match username { Some(u) => u, None => prompt_line("Username", false)?, }; let pass = prompt_line("Password", true)?; let login = server::login(&url, &user, &pass).await?; let mut cfg = Config::load()?; println!("Signed in to {} as {}.", login.url, login.username); cfg.server = Some(login); cfg.save()?; return Ok(()); } Some(Sub::Logout) => { let mut cfg = Config::load()?; cfg.server = None; cfg.save()?; println!("Signed out (revoke the token under Settings → Account → API tokens if needed)."); return Ok(()); } Some(Sub::Update) => return self_update().await, Some(Sub::Run { prompt, model, mode, permissions }) => return run_once(prompt, model, mode, permissions).await, Some(Sub::Sessions) => { for s in session::list(None).into_iter().take(50) { let when = chrono::DateTime::from_timestamp_millis(s.updated_at).map(|d| d.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M").to_string()).unwrap_or_default(); println!("{} {when} {:<40} {}", s.id, s.title.chars().take(40).collect::(), s.cwd); } return Ok(()); } Some(Sub::Setup) | None => {} } let setup = matches!(cli.cmd, Some(Sub::Setup)) || Config::load()?.is_empty(); let env = Arc::new(build_env().await?); let cwd = std::env::current_dir()?.display().to_string(); let cfg = env.cfg.clone(); let session = if let Some(id) = cli.resume { LocalSession::load(&id)? } else if cli.r#continue { session::list(Some(&cwd)).into_iter().next().unwrap_or_else(|| LocalSession::new(&cwd, "", "build", "auto")) } else { LocalSession::new(&cwd, "", "build", "auto") }; let mut session = session; if session.model.is_empty() || env.catalog.find(&session.model).is_none() { session.model = default_model(&env, &cfg, cli.model.clone()); } else if let Some(m) = cli.model { session.model = m; } if let Some(m) = cli.mode { session.mode = m; } if session.messages.is_empty() { session.permission_mode = cfg.permission_mode.clone().unwrap_or_else(|| "auto".into()); } // Notice when the server has a newer CLI. let mut status = String::new(); if let Some(v) = env.server_cfg.as_ref().and_then(|s| s["version"].as_str()) { if version_newer(v, codecraft_core::version::VERSION) { status = format!("update available: {v} – run `codecraft update`"); } } let (ui_tx, ui_rx) = mpsc::unbounded_channel(); let cmd = spawn_agent(env.clone(), session.clone(), ui_tx); let mut app = app::App::new(env, session, cmd); app.set_status(status); app::run(app, ui_rx, cli.prompt, setup).await }