isokit: project layer, Studio GUI, MCP server, viewer, CLI, demo project and docs

This commit is contained in:
grabowski committed 2026-10-22 15:29:07 +07:00
1 parent 95c80ec6b1
commit 564aeb75f6
47 files changed
+5978 -366

No files matched your search

+209 -1
View File
@@ -1 +1,209 @@
fn main() {}
//! `isokit`: the command line.
//!
//! isokit new <dir> start a project
//! isokit studio [dir] the GUI editor in the browser (also serves MCP at /mcp)
//! isokit mcp [dir] MCP server over stdio for AI agents
//! isokit build [dir] self-contained HTML page with every view
//! isokit render [dir] -o x.svg one view as SVG
//! isokit check [dir] lint the project
//! isokit prefabs [dir] list the object types
mod serve;
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use isokit_project::{check, page, Project};
use std::io::{BufRead, Write};
use std::path::PathBuf;
#[derive(Parser)]
#[command(
name = "isokit",
version,
about = "Isometric scene engine: Studio GUI, MCP server, HTML/SVG export"
)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Create a project folder with a starter scene
New {
dir: PathBuf,
#[arg(long)]
title: Option<String>,
},
/// Open the Studio (GUI editor) in the browser
Studio {
#[arg(default_value = ".")]
dir: PathBuf,
#[arg(long, default_value_t = 7878)]
port: u16,
/// Do not open a browser
#[arg(long)]
no_open: bool,
},
/// Serve the MCP protocol on stdio (for AI agents)
Mcp {
#[arg(default_value = ".")]
dir: PathBuf,
},
/// Build the self-contained HTML page
Build {
#[arg(default_value = ".")]
dir: PathBuf,
#[arg(short, long)]
out: Option<PathBuf>,
},
/// Render one view as a standalone SVG
Render {
#[arg(default_value = ".")]
dir: PathBuf,
#[arg(short, long)]
out: PathBuf,
#[arg(long)]
level: Option<String>,
#[arg(long)]
rot: Option<u8>,
/// Light (day) colours instead of dark
#[arg(long)]
light: bool,
},
/// Lint the project (exit code 1 on errors)
Check {
#[arg(default_value = ".")]
dir: PathBuf,
},
/// List the object types (prefabs) and their properties
Prefabs {
#[arg(default_value = ".")]
dir: PathBuf,
},
}
fn load(dir: &PathBuf) -> Result<Project> {
let p = Project::load(dir).map_err(|e| anyhow::anyhow!("{e}"))?;
if let Err(e) = &p.runtime {
bail!("{e}");
}
Ok(p)
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.cmd {
Cmd::New { dir, title } => {
let title = title.unwrap_or_else(|| {
dir.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or("Scene".into())
});
isokit_project::scaffold(&dir, &title).map_err(|e| anyhow::anyhow!("{e}"))?;
println!(
"Created {} - open it with: isokit studio {}",
dir.display(),
dir.display()
);
}
Cmd::Studio { dir, port, no_open } => serve::studio(&dir, port, !no_open)?,
Cmd::Mcp { dir } => mcp_stdio(&dir)?,
Cmd::Build { dir, out } => {
let p = load(&dir)?;
let out = out.unwrap_or_else(|| {
dir.join(
p.manifest
.build
.out
.clone()
.unwrap_or_else(|| "dist/index.html".into()),
)
});
let html = page::static_page(&p).map_err(|e| anyhow::anyhow!("{e}"))?;
if let Some(d) = out.parent() {
std::fs::create_dir_all(d).ok();
}
std::fs::write(&out, &html).with_context(|| out.display().to_string())?;
let views = p.manifest.levels().len() * 4;
println!(
"{} ({} views, {} kB)",
out.display(),
views,
html.len() / 1024
);
}
Cmd::Render {
dir,
out,
level,
rot,
light,
} => {
let p = load(&dir)?;
let level = level.unwrap_or_else(|| p.manifest.default_level());
let rot = rot.unwrap_or(p.manifest.default_rotation) % 4;
let svg = page::svg(&p, &level, rot, light).map_err(|e| anyhow::anyhow!("{e}"))?;
std::fs::write(&out, svg).with_context(|| out.display().to_string())?;
println!("{} ({level}, rotation {rot})", out.display());
}
Cmd::Check { dir } => {
let p = Project::load(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
let f = check::check(&p);
for x in &f {
println!(
"{:7} {}{}",
x.level,
x.object
.as_ref()
.map(|o| format!("[{o}] "))
.unwrap_or_default(),
x.message
);
}
let errors = f.iter().filter(|x| x.level == "error").count();
println!("{} finding(s), {errors} error(s)", f.len());
if errors > 0 {
std::process::exit(1);
}
}
Cmd::Prefabs { dir } => {
let p = load(&dir)?;
for m in p.catalog() {
println!("{:12} {:24} {} [{}]", m.kind, m.label, m.category, m.source);
for d in &m.props {
println!(
" {:10} {:8} default {} {}",
d.key, d.kind, d.default, d.label
);
}
}
}
}
Ok(())
}
/// MCP over stdio: one JSON-RPC message per line in, one per line out.
fn mcp_stdio(dir: &PathBuf) -> Result<()> {
let mut ws =
isokit_project::workspace::Workspace::open(dir).map_err(|e| anyhow::anyhow!("{e}"))?;
let stdin = std::io::stdin();
let mut out = std::io::stdout().lock();
for line in stdin.lock().lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let resp = match serde_json::from_str::<serde_json::Value>(&line) {
Ok(msg) => isokit_project::mcp::handle(&mut ws, &msg),
Err(e) => Some(
serde_json::json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }),
),
};
if let Some(r) = resp {
writeln!(out, "{r}")?;
out.flush()?;
}
}
Ok(())
}
+356
View File
@@ -0,0 +1,356 @@
//! `isokit studio`: the GUI editor and its JSON API on localhost, plus the
//! MCP endpoint (`POST /mcp`, JSON responses) so agents edit the same scene
//! live. Single-threaded: one request at a time against one Workspace.
use anyhow::Result;
use isokit_project::ops::Op;
use isokit_project::workspace::Workspace;
use isokit_project::{assets, check, mcp, page};
use serde_json::{json, Map, Value};
use std::io::Read;
use std::path::Path;
use tiny_http::{Header, Method, Request, Response, Server};
const VIEW_PAGE: &str = r#"<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>isokit preview</title>
<script>(function(){var t=null;try{t=localStorage.getItem("isokit-theme")}catch(e){}if(t!=="light"&&t!=="dark")t=matchMedia("(prefers-color-scheme: light)").matches?"light":"dark";document.documentElement.setAttribute("data-theme",t);})();</script>
<link rel="stylesheet" href="/assets/viewer/isokit-view.css"><link rel="stylesheet" href="/assets/theme.css">
<style>:root{color-scheme:dark}html[data-theme=light]{color-scheme:light}body{margin:0;background:#0a0f1a;font:14px system-ui,sans-serif;color:#d6deeb}
html[data-theme=light] body{background:#f4f6fa;color:#1b2533}main{max-width:1320px;margin:0 auto;padding:16px}</style></head>
<body><main><div id="view"></div></main><script src="/assets/viewer/isokit-view.js"></script><script>
fetch("/api/meta").then(function(r){return r.json()}).then(function(m){
var meta={title:m.title,note:m.note,levels:m.levels,defaultLevel:m.default_level,defaultRotation:m.default_rotation};
document.title=m.title+" · preview";
var view=IsoKit.mount(document.getElementById("view"),IsoKit.httpSource("",meta),{});
window.__isokit={view:view};
var v=null;(function poll(){fetch("/api/version").then(function(r){return r.json()}).then(function(r){if(v!==null&&r.version!==v)view.refresh();v=r.version;}).catch(function(){}).then(function(){setTimeout(poll,1000)});})();
});
</script></body></html>"#;
fn header(k: &str, v: &str) -> Header {
Header::from_bytes(k.as_bytes(), v.as_bytes()).expect("valid header")
}
fn send(req: Request, status: u16, mime: &str, body: String) {
let r = Response::from_string(body)
.with_status_code(status)
.with_header(header("Content-Type", mime))
.with_header(header("Cache-Control", "no-store"));
let _ = req.respond(r);
}
fn send_json(req: Request, v: Value) {
let status = if v.get("error").is_some() && v.get("jsonrpc").is_none() {
400
} else {
200
};
send(
req,
status,
"application/json; charset=utf-8",
v.to_string(),
);
}
fn query(url: &str) -> Map<String, Value> {
let mut m = Map::new();
if let Some((_, q)) = url.split_once('?') {
for kv in q.split('&') {
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
m.insert(decode(k), Value::String(decode(v)));
}
}
m
}
fn decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
match b[i] {
b'+' => out.push(b' '),
b'%' if i + 2 < b.len() => {
if let Ok(v) =
u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16)
{
out.push(v);
i += 2;
} else {
out.push(b'%');
}
}
c => out.push(c),
}
i += 1;
}
String::from_utf8_lossy(&out).to_string()
}
/// Requests from a web page on another origin (DNS rebinding, a malicious
/// site) must not edit the scene.
fn origin_ok(req: &Request) -> bool {
match req.headers().iter().find(|h| h.field.equiv("Origin")) {
None => true,
Some(h) => {
let o = h.value.as_str();
o == "null" || o.starts_with("http://127.0.0.1") || o.starts_with("http://localhost")
}
}
}
fn state(ws: &Workspace) -> Value {
json!({ "doc": ws.project.doc, "version": ws.version.to_string(), "can_undo": ws.can_undo(), "can_redo": ws.can_redo() })
}
fn meta(ws: &Workspace) -> Value {
let mut v = serde_json::to_value(ws.project.meta()).unwrap_or_default();
v["dir"] = json!(ws.dir.display().to_string());
v["load_error"] = json!(ws.load_error);
v["script_error"] = json!(ws.project.runtime.as_ref().err());
v
}
pub fn studio(dir: &Path, port: u16, open: bool) -> Result<()> {
let dir = std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
// Windows canonical paths carry a \?\ prefix that other tools do not accept
let dir = std::path::PathBuf::from(
dir.to_string_lossy()
.trim_start_matches(r"\\?\")
.to_string(),
);
let mut ws = Workspace::open(&dir).map_err(|e| anyhow::anyhow!("{e}"))?;
let (server, port) = (port..port.saturating_add(20))
.find_map(|p| Server::http(("127.0.0.1", p)).ok().map(|s| (s, p)))
.ok_or_else(|| anyhow::anyhow!("no free port from {port}"))?;
let url = format!("http://127.0.0.1:{port}/");
println!("isokit Studio: {url}");
println!(" project {}", dir.display());
println!(" preview {url}view");
println!(" MCP {url}mcp (or: isokit mcp {})", dir.display());
if let Err(e) = &ws.project.runtime {
println!(" script error: {e}");
}
if open {
open_browser(&url);
}
for mut req in server.incoming_requests() {
let url = req.url().to_string();
let path = url.split('?').next().unwrap_or("/").to_string();
let method = req.method().clone();
let mut body = String::new();
if method == Method::Post {
if !origin_ok(&req) {
send(
req,
403,
"text/plain",
"cross-origin request refused".into(),
);
continue;
}
let _ = req.as_reader().take(16 << 20).read_to_string(&mut body);
}
let bodyv: Value = if body.trim().is_empty() {
json!({})
} else {
serde_json::from_str(&body).unwrap_or(Value::Null)
};
match (&method, path.as_str()) {
(Method::Get, "/") => send(
req,
200,
"text/html; charset=utf-8",
assets::get("studio/index.html")
.unwrap_or_default()
.into_owned(),
),
(Method::Get, "/view") => send(req, 200, "text/html; charset=utf-8", VIEW_PAGE.into()),
(Method::Get, "/assets/theme.css") => {
ws.refresh();
send(req, 200, "text/css; charset=utf-8", ws.project.theme.css())
}
(Method::Get, p) if p.starts_with("/assets/") => {
let name = &p["/assets/".len()..];
let mime = if name.ends_with(".js") {
"text/javascript; charset=utf-8"
} else if name.ends_with(".css") {
"text/css; charset=utf-8"
} else {
"text/html; charset=utf-8"
};
match assets::get(name) {
Some(s) => send(req, 200, mime, s.into_owned()),
None => send(req, 404, "text/plain", "not found".into()),
}
}
(Method::Get, "/api/version") => {
ws.refresh();
send_json(req, json!({ "version": ws.version.to_string() }))
}
(Method::Get, "/api/meta") => {
ws.refresh();
send_json(req, meta(&ws))
}
(Method::Get, "/api/doc") => {
ws.refresh();
send_json(req, state(&ws))
}
(Method::Get, "/api/render") => {
ws.refresh();
let q = query(&url);
let p = &ws.project;
let level = q
.get("level")
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_else(|| p.manifest.default_level());
let rot = q
.get("rot")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<u8>().ok())
.unwrap_or(0);
let cfg = q
.get("cfg")
.and_then(|v| v.as_str())
.and_then(|s| serde_json::from_str::<Map<String, Value>>(s).ok());
let dev = q.get("dev").is_some();
match p.render(&level, rot, cfg.as_ref(), dev) {
Ok(v) => send_json(req, serde_json::to_value(v).unwrap_or_default()),
Err(e) => send(
req,
200,
"application/json",
json!({ "error": e }).to_string(),
),
}
}
(Method::Get, "/api/check") => {
ws.refresh();
send_json(req, json!({ "findings": check::check(&ws.project) }))
}
(Method::Post, "/api/op") => {
let r = if let Some(list) = bodyv.get("ops").and_then(|v| v.as_array()) {
let ops: Result<Vec<Op>, String> = list
.iter()
.map(|v| serde_json::from_value::<Op>(v.clone()).map_err(|e| e.to_string()))
.collect();
ops.and_then(|l| ws.apply_all(l))
.map(|o| o.last().and_then(|x| x.id.clone()))
} else {
serde_json::from_value::<Op>(bodyv.clone())
.map_err(|e| format!("bad operation: {e}"))
.and_then(|op| ws.apply(op))
.map(|o| o.id)
};
match r {
Ok(id) => {
let mut s = state(&ws);
s["ok"] = json!(true);
s["id"] = json!(id);
send_json(req, s)
}
Err(e) => send(
req,
200,
"application/json",
json!({ "error": e }).to_string(),
),
}
}
(Method::Post, "/api/undo") | (Method::Post, "/api/redo") => {
let r = if path.ends_with("undo") {
ws.undo()
} else {
ws.redo()
};
match r {
Ok(_) => send_json(req, state(&ws)),
Err(e) => send(
req,
200,
"application/json",
json!({ "error": e }).to_string(),
),
}
}
(Method::Post, "/api/build") => {
ws.refresh();
let p = &ws.project;
let out = p.dir.join(
p.manifest
.build
.out
.clone()
.unwrap_or_else(|| "dist/index.html".into()),
);
let r = page::static_page(p)
.map_err(|e| e.to_string())
.and_then(|html| {
if let Some(d) = out.parent() {
std::fs::create_dir_all(d).map_err(|e| e.to_string())?;
}
std::fs::write(&out, html).map_err(|e| e.to_string())
});
match r {
Ok(_) => send_json(req, json!({ "path": out.display().to_string() })),
Err(e) => send(
req,
200,
"application/json",
json!({ "error": e }).to_string(),
),
}
}
(Method::Post, "/mcp") => {
// a batch (array) or a single message; notifications get 202
let resp = match &bodyv {
Value::Array(list) => {
let out: Vec<Value> = list
.iter()
.filter_map(|m| mcp::handle(&mut ws, m))
.collect();
if out.is_empty() {
None
} else {
Some(Value::Array(out))
}
}
Value::Null => Some(
json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": "parse error" } }),
),
m => mcp::handle(&mut ws, m),
};
match resp {
Some(v) => send(req, 200, "application/json", v.to_string()),
None => {
let _ = req.respond(Response::empty(202));
}
}
}
(Method::Get, "/mcp") => send(
req,
405,
"text/plain",
"MCP: POST JSON-RPC messages here".into(),
),
_ => send(req, 404, "text/plain", "not found".into()),
}
}
Ok(())
}
fn open_browser(url: &str) {
#[cfg(windows)]
let r = std::process::Command::new("cmd")
.args(["/C", "start", "", url])
.spawn();
#[cfg(target_os = "macos")]
let r = std::process::Command::new("open").arg(url).spawn();
#[cfg(all(unix, not(target_os = "macos")))]
let r = std::process::Command::new("xdg-open").arg(url).spawn();
if r.is_err() {
println!(" open {url} in a browser");
}
}