Add web terminal, git credentials, OpenSCAD, review loops, automations

- Web terminal (PTY over WebSocket, xterm.js, sandboxed, reattach with replay)
- Per-user git credentials and commit identity (credential store in the user's home)
- OpenSCAD tool (STL/3MF + preview renders, bounding box) and cad skill; 3D model card
- Craft phase inferred from tool activity when the agent does not report it
- Configurable review loop per mode (rounds, reviewer model, criteria)
- Delete config and Markdown agents from the agent editor
- Automations: cron-scheduled agents from Markdown job files, schedule tool, page
- change_directory tool (only on the user's request)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 22:00:38 +01:00
1 parent a0d0e707f2
commit df1924b0a1
41 files changed
+2809 -34

No files matched your search

+270
View File
@@ -0,0 +1,270 @@
//! OpenSCAD: render .scad files to STL/3MF and preview images (for 3D printing).
use super::{Tool, ToolCtx, ToolOutput, arg_str};
use crate::types::ImageData;
use anyhow::{Result, bail};
use async_trait::async_trait;
use base64::Engine;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::time::Duration;
const VIEWS: &[(&str, &str)] = &[
// rot_x, rot_y, rot_z for --camera (translate 0, distance from --viewall)
("iso", "55,0,25"),
("front", "90,0,0"),
("right", "90,0,90"),
("top", "0,0,0"),
("back", "90,0,180"),
("bottom", "180,0,0"),
];
pub struct OpenScad;
/// Bounding box and triangle count of an ASCII or binary STL.
fn stl_stats(data: &[u8]) -> Option<([f32; 3], [f32; 3], usize)> {
let mut min = [f32::MAX; 3];
let mut max = [f32::MIN; 3];
let mut tris = 0;
let mut add = |v: [f32; 3]| {
for i in 0..3 {
min[i] = min[i].min(v[i]);
max[i] = max[i].max(v[i]);
}
};
if data.starts_with(b"solid") && data.windows(6).take(2000).any(|w| w == b"vertex") {
let text = String::from_utf8_lossy(data);
for line in text.lines() {
let l = line.trim();
if let Some(rest) = l.strip_prefix("vertex") {
let v: Vec<f32> = rest.split_whitespace().filter_map(|x| x.parse().ok()).collect();
if v.len() == 3 {
add([v[0], v[1], v[2]]);
}
} else if l.starts_with("facet") {
tris += 1;
}
}
} else if data.len() >= 84 {
let n = u32::from_le_bytes(data[80..84].try_into().ok()?) as usize;
for t in 0..n {
let base = 84 + t * 50 + 12;
if base + 36 > data.len() {
break;
}
for k in 0..3 {
let o = base + k * 12;
let f = |i: usize| f32::from_le_bytes(data[o + i * 4..o + i * 4 + 4].try_into().unwrap());
add([f(0), f(1), f(2)]);
}
tris += 1;
}
}
(tris > 0).then_some((min, max, tris))
}
fn param_value(v: &Value) -> Option<String> {
Some(match v {
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
Value::Array(a) => format!("[{}]", a.iter().filter_map(param_value).collect::<Vec<_>>().join(",")),
_ => return None,
})
}
#[async_trait]
impl Tool for OpenScad {
fn name(&self) -> &str {
"openscad"
}
fn description(&self) -> String {
"Render an OpenSCAD file (.scad) for 3D printing: exports STL and/or 3MF next to the file and \
returns preview images from several views (iso, front, right, top, back, bottom) plus the \
bounding box in mm, the triangle count and OpenSCAD warnings (e.g. non-manifold geometry). \
Use `params` to override top-level variables (-D) without editing the file. Look at the \
previews and compare them with the requirements or the user's sketch, then refine."
.into()
}
fn schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": ".scad file"},
"formats": {"type": "array", "items": {"type": "string", "enum": ["stl", "3mf", "png"]}, "description": "Default [\"stl\", \"png\"]"},
"views": {"type": "array", "items": {"type": "string", "enum": VIEWS.iter().map(|v| v.0).collect::<Vec<_>>()}, "description": "Preview views, default [\"iso\", \"front\", \"top\"]"},
"params": {"type": "object", "description": "Variable overrides, e.g. {\"wall\": 2, \"label\": \"A\"}"},
"output": {"type": "string", "description": "Base name for exports (default: same as the .scad file)"}
},
"required": ["path"]
})
}
fn is_edit(&self) -> bool {
false
}
fn title(&self, input: &Value) -> String {
format!("OpenSCAD {}", input["path"].as_str().unwrap_or(""))
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let scad = ctx.resolve(arg_str(&input, "path")?)?;
if !scad.is_file() {
bail!("{} does not exist – write the .scad file first", scad.display());
}
let formats: Vec<String> = input["formats"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_else(|| vec!["stl".into(), "png".into()]);
let views: Vec<String> = input["views"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_else(|| vec!["iso".into(), "front".into(), "top".into()]);
let dir = scad.parent().unwrap_or(Path::new(".")).to_path_buf();
let stem = input["output"].as_str().map(String::from).unwrap_or_else(|| scad.file_stem().and_then(|s| s.to_str()).unwrap_or("model").to_string());
let stem = stem.replace(['/', '\\'], "_");
let mut defines: Vec<String> = Vec::new();
if let Some(p) = input["params"].as_object() {
for (k, v) in p {
if !k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
bail!("invalid parameter name '{k}'");
}
if let Some(val) = param_value(v) {
defines.push("-D".into());
defines.push(format!("{k}={val}"));
}
}
}
let mut report = Vec::new();
let mut warnings = String::new();
let mut images = Vec::new();
let mut files = Vec::new();
let preview_dir = dir.join(".codecraft").join("cad");
// Mesh exports.
for fmt in formats.iter().filter(|f| *f == "stl" || *f == "3mf") {
let out = dir.join(format!("{stem}.{fmt}"));
let mut args: Vec<String> = vec!["-o".into(), out.display().to_string()];
args.extend(defines.iter().cloned());
args.push(scad.display().to_string());
let (ok, log) = run_scad(ctx, &dir, &args, false).await?;
warnings.push_str(&log);
if !ok {
return Ok(ToolOutput::error(format!("OpenSCAD failed:\n{}", tail(&log, 4000))));
}
files.push(out.clone());
if fmt == "stl" {
if let Some((min, max, tris)) = std::fs::read(&out).ok().and_then(|d| stl_stats(&d)) {
report.push(format!(
"Bounding box: {:.2} × {:.2} × {:.2} mm (x {:.2}..{:.2}, y {:.2}..{:.2}, z {:.2}..{:.2}), {tris} triangles",
max[0] - min[0], max[1] - min[1], max[2] - min[2], min[0], max[0], min[1], max[1], min[2], max[2]
));
if min[2].abs() > 0.01 {
report.push(format!("Note: the model does not sit on z=0 (lowest point z={:.2}) – slicers drop it onto the bed.", min[2]));
}
} else {
report.push("The exported STL is empty – check the geometry (e.g. difference() removed everything).".into());
}
}
}
// Preview images (rendered, so they show the final geometry).
if formats.iter().any(|f| f == "png") {
std::fs::create_dir_all(&preview_dir)?;
for view in &views {
let Some((_, rot)) = VIEWS.iter().find(|v| v.0 == view) else { continue };
let out = preview_dir.join(format!("{stem}-{view}.png"));
let mut args: Vec<String> = vec![
"-o".into(),
out.display().to_string(),
"--render".into(),
"--imgsize=900,700".into(),
"--viewall".into(),
"--autocenter".into(),
"--projection=p".into(),
"--colorscheme=Tomorrow".into(),
format!("--camera=0,0,0,{rot},0"),
];
args.extend(defines.iter().cloned());
args.push(scad.display().to_string());
let (ok, log) = run_scad(ctx, &dir, &args, true).await?;
if !ok {
warnings.push_str(&log);
continue;
}
if let Ok(bytes) = std::fs::read(&out) {
images.push(ImageData { media_type: "image/png".into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) });
files.push(out);
}
}
}
let warn: Vec<&str> = warnings
.lines()
.filter(|l| l.contains("WARNING") || l.contains("ERROR") || l.contains("not a valid 2-manifold") || l.contains("Simple: no"))
.collect();
let rel = |p: &PathBuf| p.strip_prefix(&ctx.cwd).map(|r| r.display().to_string()).unwrap_or_else(|_| p.display().to_string());
let mut text = format!("Rendered {}.\nFiles: {}\n", rel(&scad), files.iter().map(rel).collect::<Vec<_>>().join(", "));
for r in &report {
text.push_str(r);
text.push('\n');
}
if !warn.is_empty() {
text.push_str("OpenSCAD messages:\n");
for w in warn.iter().take(20) {
text.push_str(w.trim());
text.push('\n');
}
}
if !images.is_empty() {
text.push_str(&format!("Preview images ({}): {}", images.len(), views.join(", ")));
}
let meta = json!({"files": files.iter().map(rel).collect::<Vec<_>>()});
Ok(ToolOutput { content: text, images, meta: Some(meta), is_error: false })
}
}
fn tail(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut start = s.len() - max;
while !s.is_char_boundary(start) {
start += 1;
}
s[start..].to_string()
}
/// Run openscad (PNG output through a virtual X server), sandboxed for non-admins.
async fn run_scad(ctx: &ToolCtx, dir: &Path, args: &[String], display: bool) -> Result<(bool, String)> {
let mut full: Vec<&str> = Vec::new();
if display {
full.extend(["xvfb-run", "-a", "-s", "-screen 0 1280x1024x24", "openscad"]);
} else {
full.push("openscad");
}
full.extend(args.iter().map(String::as_str));
let mut cmd = match &ctx.sandbox {
Some(spec) => {
if !crate::sandbox::available() {
bail!("OpenSCAD is disabled for this account: the sandbox is not available");
}
crate::sandbox::command(spec, dir, full[0], &full[1..])
}
None => {
let mut c = tokio::process::Command::new(full[0]);
c.args(&full[1..]).current_dir(dir);
c
}
};
cmd.kill_on_drop(true);
let out = tokio::time::timeout(Duration::from_secs(300), cmd.output())
.await
.map_err(|_| anyhow::anyhow!("OpenSCAD timed out after 5 minutes (simplify the model or lower $fn)"))?
.map_err(|e| anyhow::anyhow!("cannot run OpenSCAD ({e}) – is it installed?"))?;
let log = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
Ok((out.status.success(), log))
}
#[cfg(test)]
mod tests {
#[test]
fn ascii_stl_bbox() {
let stl = b"solid x\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 10 0 0\nvertex 0 5 2\nendloop\nendfacet\nendsolid x\n";
let (min, max, n) = super::stl_stats(stl).unwrap();
assert_eq!((min, max, n), ([0.0, 0.0, 0.0], [10.0, 5.0, 2.0], 1));
}
}