Files
codecraft/cli/src/main.rs
T
Richard ZinkandClaude Opus 5.5 1f69d63b09 Status LEDs, image model selection, backup restore, provider test, monthly versions
- Sidebar: three status LEDs (server, models, MCP) with an overview popover (/api/status)
- Images: preview stage, purpose-based model choice and reference images
  (Nano Banana Pro edit refines the chosen preview)
- Settings: restore a stored or uploaded backup (staged, applied on restart)
- Provider form: test connection; saving or reloading re-checks the status at once
- opencode Zen/Go: send x-opencode-session with a stable id per conversation
- Releases are named vYYYYMM-NNNNN; scripts/release.sh numbers, tags, builds and publishes
- Fix: backups made in the same second no longer overwrite each other

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:31:24 +01:00

377 lines
15 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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<String>,
/// Continue the last session in this folder
#[arg(short, long)]
r#continue: bool,
/// Resume a session by id
#[arg(long)]
resume: Option<String>,
/// Model ("provider/model" or "server:provider/model")
#[arg(short, long)]
model: Option<String>,
/// Mode: build, plan, craft …
#[arg(long)]
mode: Option<String>,
#[command(subcommand)]
cmd: Option<Sub>,
}
#[derive(Subcommand)]
enum Sub {
/// Sign in to a CodeCraft server (creates an API token for this machine)
Login { url: Option<String>, username: Option<String> },
/// 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<String>,
#[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<Env> {
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<String> {
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<Env>, session: LocalSession, ui: mpsc::UnboundedSender<UiEvent>) -> mpsc::UnboundedSender<Cmd> {
let (tx, mut rx) = mpsc::unbounded_channel::<Cmd>();
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>) -> 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<String>, 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::<String>(), 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
}