- 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>
377 lines
15 KiB
Rust
377 lines
15 KiB
Rust
//! `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
|
||
}
|