Skip to main content

brep_app/automation/
command.rs

1//! The command registry and the wire envelope (spec §4, Appendix A).
2//!
3//! A command is a [`CommandSpec`]: name, group, doc, the JSON Schemas of its
4//! argument and result types (derived with `schemars`), the frame phase it
5//! runs in, MCP-style annotations, and a handler. Each `cmd_*` module owns a
6//! `pub static COMMANDS: &[CommandSpec]`; [`registry`] gathers them. A host
7//! generates its tool list from [`describe`]; nothing lists commands by hand.
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11/// Where in the frame a command runs (§4.4).
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
13#[serde(rename_all = "snake_case")]
14pub enum Phase {
15    /// `raw_input_hook`: the handler emits `egui::Event`s.
16    Input,
17    /// Top of `ui`, before any panel draws.
18    Mutate,
19    /// Bottom of `ui`, after the state registry is rebuilt.
20    Read,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, schemars::JsonSchema)]
24pub struct Annotations {
25    pub read_only: bool,
26    pub destructive: bool,
27    pub idempotent: bool,
28    /// The host applies the idle contract (§6) before returning.
29    pub waits: bool,
30}
31
32impl Annotations {
33    pub const READ: Annotations = Annotations { read_only: true, destructive: false, idempotent: true, waits: false };
34    pub const INPUT: Annotations = Annotations { read_only: false, destructive: false, idempotent: false, waits: false };
35    pub const MUTATE: Annotations = Annotations { read_only: false, destructive: false, idempotent: false, waits: true };
36    pub const MUTATE_NOWAIT: Annotations = Annotations { read_only: false, destructive: false, idempotent: true, waits: false };
37    pub const DESTRUCTIVE: Annotations = Annotations { read_only: false, destructive: true, idempotent: false, waits: true };
38}
39
40/// What an app-phase handler sees.
41pub struct Ctx<'a> {
42    pub app: &'a mut crate::app::BrepApp,
43    pub egui: &'a egui::Context,
44}
45
46/// What a handler returns.
47pub enum Outcome {
48    Done(Value),
49    /// A JSON result plus a binary payload (a PNG) carried beside it.
50    Blob { json: Value, bytes: Vec<u8> },
51    /// The reply completes when `Event::Screenshot` with this token arrives.
52    AwaitScreenshot { token: u64, region: crate::automation::cmd_capture::Region },
53}
54
55pub type InputHandler = fn(&mut crate::automation::pointer::Pointer, Value, &mut Vec<egui::Event>) -> Result<Value, String>;
56pub type AppHandler = fn(&mut Ctx<'_>, Value) -> Result<Outcome, String>;
57
58pub enum Handler {
59    Input(InputHandler),
60    App(AppHandler),
61}
62
63pub struct CommandSpec {
64    pub name: &'static str,
65    pub group: &'static str,
66    pub doc: &'static str,
67    pub phase: Phase,
68    pub annotations: Annotations,
69    pub args_schema: fn() -> Value,
70    pub result_schema: fn() -> Value,
71    pub handler: Handler,
72}
73
74/// The JSON Schema of a type, as a `serde_json::Value`.
75///
76/// `schemars` renders an unconstrained value (e.g. a `serde_json::Value` field)
77/// as the boolean schema `true`. Strict MCP tool-schema validators (such as
78/// Claude Code's) reject a bare boolean where a schema object is expected, and
79/// that rejects the *whole* `tools/list` — one such field zeroes out every tool.
80/// So we normalize boolean subschemas to their object form before publishing.
81pub fn schema_of<T: schemars::JsonSchema>() -> Value {
82    let mut schema = serde_json::to_value(schemars::schema_for!(T)).unwrap_or(Value::Null);
83    normalize_bool_subschemas(&mut schema);
84    schema
85}
86
87/// Replace boolean subschemas (`true`/`false` used where a schema *object* is
88/// expected — a `properties` member or `items`) with their object equivalents
89/// (`true` → `{}`, `false` → `{"not": {}}`). Legitimate booleans such as
90/// `additionalProperties` and `default` are left untouched.
91fn normalize_bool_subschemas(node: &mut Value) {
92    match node {
93        Value::Object(map) => {
94            if let Some(Value::Object(props)) = map.get_mut("properties") {
95                for value in props.values_mut() {
96                    if let Some(b) = value.as_bool() {
97                        *value = if b { serde_json::json!({}) } else { serde_json::json!({"not": {}}) };
98                    }
99                }
100            }
101            if let Some(items) = map.get_mut("items") {
102                if let Some(b) = items.as_bool() {
103                    *items = if b { serde_json::json!({}) } else { serde_json::json!({"not": {}}) };
104                }
105            }
106            for value in map.values_mut() {
107                normalize_bool_subschemas(value);
108            }
109        }
110        Value::Array(arr) => {
111            for value in arr.iter_mut() {
112                normalize_bool_subschemas(value);
113            }
114        }
115        _ => {}
116    }
117}
118
119pub fn parse_args<T: serde::de::DeserializeOwned>(args: Value) -> Result<T, String> {
120    // A call with no arguments arrives as `null` or `{}`; both mean "none".
121    let args = if args.is_null() { Value::Object(Default::default()) } else { args };
122    serde_json::from_value(args).map_err(|e| format!("arguments: {e}"))
123}
124
125/// A command that takes nothing.
126#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
127#[serde(deny_unknown_fields)]
128pub struct NoArgs {}
129
130/// A command that returns nothing beyond the envelope.
131#[derive(Debug, Default, Serialize, schemars::JsonSchema)]
132pub struct Empty {}
133
134/// Every registered command, in module order. Each `cmd_*` module contributes
135/// its `COMMANDS` static; `tests/automation_registry.rs` walks the source tree
136/// for `static COMMANDS` declarations and fails if one is missing here, so this
137/// function cannot silently become a hand-kept subset.
138pub fn registry() -> Vec<&'static CommandSpec> {
139    use crate::automation::*;
140    let sets: &[&[CommandSpec]] = &[
141        cmd_frame::COMMANDS,
142        cmd_input::COMMANDS,
143        cmd_capture::COMMANDS,
144        cmd_state::COMMANDS,
145        cmd_document::COMMANDS,
146        cmd_history::COMMANDS,
147        cmd_scene::COMMANDS,
148        cmd_camera::COMMANDS,
149        cmd_settings::COMMANDS,
150        cmd_shell::COMMANDS,
151        cmd_metadata::COMMANDS,
152        cmd_assembly::COMMANDS,
153        cmd_pmi::COMMANDS,
154        cmd_wire_harness::COMMANDS,
155    ];
156    sets.iter().flat_map(|s| s.iter()).collect()
157}
158
159pub fn lookup(name: &str) -> Option<&'static CommandSpec> {
160    registry().into_iter().find(|c| c.name == name)
161}
162
163/// The registry as JSON — what a host turns into `tools/list` and what the
164/// generated docs render: `[{name, group, doc, phase, annotations, argsSchema, resultSchema}]`.
165pub fn describe() -> Value {
166    Value::Array(
167        registry()
168            .into_iter()
169            .map(|c| {
170                serde_json::json!({
171                    "name": c.name,
172                    "group": c.group,
173                    "doc": c.doc,
174                    "phase": c.phase,
175                    "annotations": c.annotations,
176                    "argsSchema": (c.args_schema)(),
177                    "resultSchema": (c.result_schema)(),
178                })
179            })
180            .collect(),
181    )
182}
183
184// --- wire -------------------------------------------------------------------
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct Envelope {
188    pub id: u64,
189    pub cmd: String,
190    #[serde(default)]
191    pub args: Value,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
195#[serde(rename_all = "snake_case")]
196pub enum NoticeKind {
197    Panic,
198    LogError,
199    RunnerRefusal,
200    Toast,
201    Console,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
205pub struct Notice {
206    pub kind: NoticeKind,
207    pub frame: u64,
208    pub text: String,
209}
210
211#[derive(Debug, Clone, Default, Serialize, Deserialize)]
212pub struct Reply {
213    pub id: u64,
214    pub frame: u64,
215    pub ok: bool,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub result: Option<Value>,
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub error: Option<String>,
220    #[serde(default, skip_serializing_if = "Vec::is_empty")]
221    pub notices: Vec<Notice>,
222    /// A binary payload beside the JSON (a PNG). In-process hosts pass it
223    /// through; the dial-in adapter sends it as a binary frame.
224    #[serde(skip)]
225    pub blob: Option<Vec<u8>>,
226}
227
228impl Reply {
229    pub fn ok(id: u64, frame: u64, result: Value, notices: Vec<Notice>) -> Self {
230        Self { id, frame, ok: true, result: Some(result), error: None, notices, blob: None }
231    }
232    pub fn err(id: u64, frame: u64, error: impl Into<String>, notices: Vec<Notice>) -> Self {
233        Self { id, frame, ok: false, result: None, error: Some(error.into()), notices, blob: None }
234    }
235}
236
237// BREP private tests: 4c5028b0c3e4ba8b