Add terminal client, memory, model fallback, version and install improvements

- codecraft: full-screen terminal client (ratatui) – local tools and files,
  models via the CodeCraft server gateway or own providers (standalone with a
  guided setup), slash commands, @files, permission dialogs, resume, update
- Static Linux CLI builds (x86_64/aarch64, cargo-zigbuild) served by the
  server: /cli/install.sh and /cli/download/<target>
- Project memory (remember tool, .codecraft/memory.md) and personal memory
- Model fallback when a host is unreachable or a budget is used up
- Version/commit in UI and health, daily release check, "What's new" dialog
- PWA: install button, maskable icon, manifest id and screenshots
- Case-insensitive detection of connection errors for retries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 00:13:06 +01:00
1 parent aa1f8cf016
commit 6dffcb501a
52 files changed
+6477 -765

No files matched your search

+48 -2
View File
@@ -158,7 +158,53 @@ pub fn router(app: AppState) -> Router {
let index = web_dir.join("index.html");
let static_files = tower_http::services::ServeDir::new(&web_dir).fallback(tower_http::services::ServeFile::new(index));
Router::new().nest("/api", api).fallback_service(static_files).with_state(app)
Router::new()
.nest("/api", api)
.route("/cli/install.sh", get(cli_install_script))
.route("/cli/download/{target}", get(cli_download))
.fallback_service(static_files)
.with_state(app)
}
fn cli_dir() -> PathBuf {
std::env::var("CODECRAFT_CLI_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("/app/cli"))
}
/// The server's public base URL (config, else from the request).
fn public_base(app: &App, headers: &axum::http::HeaderMap) -> String {
let cfg = app.config();
if !cfg.auth.public_url.is_empty() {
return cfg.auth.public_url.trim_end_matches('/').to_string();
}
let host = headers.get("x-forwarded-host").or(headers.get(axum::http::header::HOST)).and_then(|v| v.to_str().ok()).unwrap_or("localhost:8787");
let proto = headers.get("x-forwarded-proto").and_then(|v| v.to_str().ok()).unwrap_or("http");
format!("{proto}://{host}")
}
/// `curl -fsSL <server>/cli/install.sh | sh` installs the terminal client.
async fn cli_install_script(State(app): State<AppState>, headers: axum::http::HeaderMap) -> Response {
let base = public_base(&app, &headers);
let script = include_str!("../resources-cli/install.sh").replace("__CODECRAFT_URL__", &base);
([(axum::http::header::CONTENT_TYPE, "text/x-shellscript; charset=utf-8")], script).into_response()
}
/// Static CLI binaries, e.g. /cli/download/linux-x86_64.
async fn cli_download(Path(target): Path<String>) -> Response {
if !target.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
return (StatusCode::BAD_REQUEST, "invalid target").into_response();
}
let path = cli_dir().join(format!("codecraft-{target}"));
match tokio::fs::read(&path).await {
Ok(bytes) => (
[
(axum::http::header::CONTENT_TYPE, "application/octet-stream".to_string()),
(axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"codecraft\"".to_string()),
],
bytes,
)
.into_response(),
Err(_) => (StatusCode::NOT_FOUND, format!("no codecraft build for {target} on this server")).into_response(),
}
}
/// Load a session the user may at least read.
@@ -1022,7 +1068,7 @@ async fn backup(Extension(user): Extension<User>, State(app): State<AppState>) -
/// Liveness for monitoring (no auth needed – see auth::is_public).
async fn health(State(app): State<AppState>) -> Json<Value> {
Json(json!({"ok": true, "version": env!("CARGO_PKG_VERSION"), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
Json(json!({"ok": true, "version": crate::update::VERSION, "commit": crate::update::commit(), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
}
// --- budget -------------------------------------------------------------------