Files
OneCAD-Simulation/apps/fieldcraft-cli/src/main.rs
T
jyvikoandClaude Opus 5.5 cb7e3a2711 CLI: fieldcraft-cli crashes [list|show|replay|dir|clear] (#107)
Lists, shows, replays and deletes the crash reports the desktop app
wrote. A replay opens the report's model, runs its commands with writes
confined to a scratch folder, and says whether the same internal error
happens again. Adapted from SolidCraft's solidcraft-cli crashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 00:36:18 -04:00

177 lines
7.7 KiB
Rust

//! fieldcraft-cli commands
//! fieldcraft-cli run <script.json>
//! fieldcraft-cli features
//! fieldcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
//! fieldcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
//! fieldcraft-cli --version
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod crashes;
use std::path::Path;
use std::process::ExitCode;
use fieldcraft_engine::Session;
use serde_json::json;
const USAGE: &str = "\
usage:
fieldcraft-cli commands list every command with its parameters
fieldcraft-cli commands --json the same as JSON, each command's parameters as JSON Schema
fieldcraft-cli run <script.json> run a script (a JSON array of {\"command\", \"params\"})
and print the model summary
fieldcraft-cli features print docs/features.md (every capability, ready or
coming soon) from the capability catalogue
fieldcraft-cli mcp [options] serve the Model Context Protocol on stdin/stdout
(docs/mcp.md):
--headless an in-process session (the default)
--connect ADDR drive the app at ADDR (`fieldcraft --control PORT`);
headless when it cannot be reached
--automation-read-root DIR files may be read only inside DIR
--automation-write-root DIR files may be written only inside DIR (with either root,
the access a root left out is not granted)
fieldcraft-cli crashes [list|show <id>|replay <id>|dir|clear [<id>]]
the crash reports the desktop app wrote on this
computer (docs/crash-reports.md): list them (--json),
show one, replay one (does the crash happen again?),
print their folder, delete them
fieldcraft-cli --version print the version
";
/// Largest script accepted (bytes).
const MAX_SCRIPT_BYTES: u64 = 256 * 1024 * 1024;
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let result = match args.first().map(String::as_str) {
Some("commands") if args.get(1).map(String::as_str) == Some("--json") => {
let all: Vec<_> = fieldcraft_engine::commands().iter().map(fieldcraft_engine::CommandSpec::listing).collect();
serde_json::to_string_pretty(&all).map(|text| println!("{text}")).map_err(|e| e.to_string())
}
Some("commands") => {
for c in fieldcraft_engine::commands() {
println!("{:<24} {:<18} {}", c.id, c.label, c.params);
}
Ok(())
}
Some("features") => {
print!("{}", fieldcraft_engine::catalogue::features_page());
Ok(())
}
Some("mcp") => mcp(args.get(1..).unwrap_or(&[])),
Some("crashes") => crashes::run(args.get(1..).unwrap_or(&[])),
Some("run") => args.get(1).ok_or_else(|| format!("run needs a script\n\n{USAGE}")).and_then(|p| run(p)),
Some("-V" | "--version") => {
println!("fieldcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
Some("-h" | "--help" | "help") | None => {
print!("{USAGE}");
Ok(())
}
Some(other) => Err(format!("unknown command `{other}`\n\n{USAGE}")),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
/// The `mcp` options: each given once, as `--flag value` or `--flag=value`.
fn mcp_options(args: &[String]) -> Result<fieldcraft_mcp::Options, String> {
let mut connect = None;
let mut headless = false;
let mut read = None;
let mut write = None;
let mut it = args.iter();
while let Some(arg) = it.next() {
let (flag, inline) = match arg.split_once('=') {
Some((f, v)) if f.starts_with("--") => (f, Some(v.to_string())),
_ => (arg.as_str(), None),
};
let mut value = || inline.clone().or_else(|| it.next().cloned()).filter(|v| !v.is_empty()).ok_or_else(|| format!("{flag} needs a value"));
let slot = match flag {
"--headless" if inline.is_none() => {
if std::mem::replace(&mut headless, true) {
return Err("--headless is given twice".into());
}
continue;
}
"--connect" => &mut connect,
"--automation-read-root" => &mut read,
"--automation-write-root" => &mut write,
other => return Err(format!("mcp: unknown option `{other}`\n\n{USAGE}")),
};
if slot.replace(value()?).is_some() {
return Err(format!("{flag} is given twice"));
}
}
if headless && connect.is_some() {
return Err("--connect and --headless exclude each other".into());
}
let roots = match (&read, &write) {
(None, None) => fieldcraft_io::roots::Roots::default(),
_ => fieldcraft_io::roots::Roots::new(read.as_deref().map(Path::new), write.as_deref().map(Path::new)).map_err(|e| e.to_string())?,
};
Ok(fieldcraft_mcp::Options { connect, roots })
}
fn mcp(args: &[String]) -> Result<(), String> {
let (backend, note) = fieldcraft_mcp::backend(mcp_options(args)?)?;
if let Some(note) = note {
eprintln!("fieldcraft-cli mcp: {note}");
}
let mut server = fieldcraft_mcp::Server::new(backend);
server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| format!("mcp: {e}"))
}
fn run(path: &str) -> Result<(), String> {
let size = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
if size > MAX_SCRIPT_BYTES {
return Err(format!("{path}: larger than {} MB", MAX_SCRIPT_BYTES / (1024 * 1024)));
}
let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
let script: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("{path}: {e}"))?;
let mut session = Session::default();
session.run_script(&script).map_err(|e| e.to_string())?;
let info = session.execute("model.info", &json!({})).map_err(|e| e.to_string())?;
println!("{}", serde_json::to_string_pretty(&info).map_err(|e| e.to_string())?);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(args: &[&str]) -> Result<fieldcraft_mcp::Options, String> {
mcp_options(&args.iter().map(|s| s.to_string()).collect::<Vec<_>>())
}
#[test]
fn mcp_options_are_checked() {
let dir = std::env::temp_dir();
let d = dir.to_string_lossy();
assert!(opts(&[]).unwrap().connect.is_none());
assert_eq!(opts(&["--connect", "127.0.0.1:7070"]).unwrap().connect.as_deref(), Some("127.0.0.1:7070"));
assert_eq!(opts(&["--connect=127.0.0.1:7070"]).unwrap().connect.as_deref(), Some("127.0.0.1:7070"));
assert!(opts(&["--automation-read-root", &d]).unwrap().roots.is_confined());
assert!(opts(&[&format!("--automation-write-root={d}")]).unwrap().roots.is_confined());
for bad in [
vec!["--connect"],
vec!["--connect", "a", "--connect", "b"],
vec!["--headless", "--headless"],
vec!["--headless", "--connect", "a"],
vec!["--automation-read-root", "/no/such/folder"],
vec!["--automation-read-root="],
vec!["--nope"],
vec!["--headless=yes"],
] {
assert!(opts(&bad).is_err(), "{bad:?}");
}
}
}