Skip to main content

byob_plugins/
manifest.rs

1//! `byob-plugin.json`: the plugin manifest.
2//!
3//! Parsing mirrors hob's `parseManifest`: the same required fields, defaults and limits,
4//! and error messages written for the plugin's author (e.g.
5//! `byob-plugin.json: tools[0].run must be a path inside the plugin folder`). Unknown fields
6//! are ignored so newer manifests still load.
7
8use std::collections::BTreeMap;
9use std::path::{Component, Path};
10
11use serde::Serialize;
12use serde_json::{Map, Value};
13
14/// The manifest file name.
15pub const MANIFEST: &str = "byob-plugin.json";
16/// The manifest's old name (from hob, which byob started as a rewrite of). Not read: a
17/// folder with only this file gets a warning to rename it.
18pub const OLD_MANIFEST: &str = "hob-plugin.json";
19
20/// Prompt length caps (characters), as in hob: built-in plugins carry longer how-tos.
21const PROMPT_MAX: usize = 600;
22const PROMPT_MAX_BUILTIN: usize = 1500;
23
24/// A checked plugin manifest with defaults filled in.
25#[derive(Debug, Clone, PartialEq, Serialize)]
26pub struct Manifest {
27    /// `[a-z0-9][a-z0-9-]{0,47}`.
28    pub id: String,
29    pub name: String,
30    /// `0.0.0` when left out.
31    pub version: String,
32    pub description: String,
33    pub author: Option<String>,
34    /// A lucide icon name; `puzzle` when left out.
35    pub icon: String,
36    pub permissions: Permissions,
37    pub starters: Vec<Starter>,
38    pub tools: Vec<ManifestTool>,
39    pub mcp_servers: BTreeMap<String, McpServerConfig>,
40    pub settings: Vec<Setting>,
41    /// A line added to the system prompt while the plugin is on.
42    pub prompt: Option<String>,
43    /// Node `process.platform` names (`darwin`, `linux`, `win32`); everywhere when `None`.
44    pub platforms: Option<Vec<String>>,
45    /// A long-lived process the engine talks to in JSON lines (`backend`), see
46    /// `docs/plugins-backend.md`.
47    pub backend: Option<BackendDecl>,
48    /// Python packages its scripts and backend need (`python.requirements`, pip specs).
49    pub requirements: Vec<String>,
50    /// Live views its backend draws at render sites (`views`).
51    pub views: Vec<ManifestView>,
52    /// Can't be switched off (`locked`; honoured for bundled plugins and their forks).
53    pub locked: bool,
54    /// On until someone switches it (`"enabled": false` ships it switched off).
55    pub on_by_default: bool,
56    /// What it offers that another plugin may offer too (`provides`, e.g. `"spreadsheets"`):
57    /// of the switched-on plugins providing the same thing only one is used.
58    pub provides: Option<String>,
59    /// Artifact content types it adds (`artifact_types`, also `content_types`).
60    pub artifact_types: Vec<ManifestArtifactType>,
61    /// Which renderer draws a content type on clients (`renderers`).
62    pub renderers: Vec<ManifestRenderer>,
63    /// A section of the system prompt (`system_prompt`): a file of the plugin, or text an
64    /// in-tree builder makes.
65    pub system_prompt: Option<PromptSource>,
66    /// Places in the turn pipeline it sits in (`hooks`), see [`ManifestHook`].
67    pub hooks: Vec<ManifestHook>,
68    /// Workspace environment providers it adds (`environments`), see [`ManifestEnv`].
69    pub environments: Vec<ManifestEnv>,
70}
71
72/// The turn pipeline's hook points, in the order a turn reaches them.
73pub const HOOK_EVENTS: [&str; 5] = ["before_prompt", "before_request", "before_tool_call", "after_tool_call", "on_turn_end"];
74/// Most hooks one plugin declares.
75pub const MAX_HOOKS: usize = 16;
76/// A backend hook's default and longest timeout (milliseconds).
77pub const HOOK_TIMEOUT_MS: u64 = 2000;
78pub const MAX_HOOK_TIMEOUT_MS: u64 = 60_000;
79/// Hooks run by priority, lowest first; this when left out.
80pub const DEFAULT_HOOK_PRIORITY: i64 = 100;
81
82/// A place in the turn pipeline (`hooks[]`): `{"event": "before_tool_call", "priority": 50,
83/// "backend": true, "timeout_ms": 2000, "fail": "closed", "tools": ["delete_file"]}`, or
84/// `{"event": "before_request", "native": "compaction"}` (in-tree code by id).
85#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
86pub struct ManifestHook {
87    /// One of [`HOOK_EVENTS`].
88    pub event: String,
89    /// Lower runs first ([`DEFAULT_HOOK_PRIORITY`] when left out).
90    pub priority: i64,
91    /// In-tree code by id (`None`: the plugin's backend answers a `hook` request).
92    pub native: Option<String>,
93    /// How long a backend may take (ms).
94    pub timeout_ms: u64,
95    /// `"fail": "closed"`: a backend that faults or times out stops the stage (a tool call is
96    /// denied, a request isn't made); the default (open) carries on as if it said continue.
97    pub fail_closed: bool,
98    /// `before_tool_call` / `after_tool_call` only for these tools (empty: every tool).
99    pub tools: Vec<String>,
100}
101
102/// Most environment providers one plugin declares.
103pub const MAX_ENVIRONMENTS: usize = 8;
104
105/// A workspace environment provider (`environments[]`): in-tree (`{"native": "nix"}`), or a
106/// command that prints the environment as JSON when the workspace has one of `detect`:
107/// `{"id": "conda", "label": "conda environment", "detect": ["environment.yml"], "inputs":
108/// ["environment.yml"], "command": "python3", "args": ["conda_env.py"]}`. Loading it is asked
109/// once per workspace, like the built-in ones.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
111pub struct ManifestEnv {
112    /// `[a-z0-9_-]{1,32}` (the native id when native).
113    pub id: String,
114    pub native: Option<String>,
115    /// For messages: "conda environment".
116    pub label: String,
117    /// Files that make a workspace use it (the first one found is named).
118    pub detect: Vec<String>,
119    /// Files whose contents key its cache (`detect` too).
120    pub inputs: Vec<String>,
121    /// The program (on `PATH`, or a file of the plugin) and its arguments (a file of the
122    /// plugin is passed as its full path). It runs in the workspace, sandboxed, with network.
123    pub command: Option<String>,
124    pub args: Vec<String>,
125}
126
127/// Most artifact types or renderers one plugin declares.
128pub const MAX_TYPES: usize = 16;
129
130/// An artifact type a plugin adds: `{"type": "text/html", "native": "html_app", "guidance":
131/// "guidance.md"}`, or a declarative text type `{"type": "text/csv", "suffixes": [".csv"],
132/// "label": "table", "describe": ".csv (a table)", "pick": "a table → .csv"}`.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
134pub struct ManifestArtifactType {
135    /// The content type, e.g. `text/html`.
136    pub content_type: String,
137    /// An in-tree type (hooks, defaults) by id; the fields below override its defaults.
138    pub native: Option<String>,
139    pub suffixes: Option<Vec<String>>,
140    pub label: Option<String>,
141    pub describe: Option<String>,
142    pub pick: Option<String>,
143    /// A file of the plugin with the type's system-prompt guidance.
144    pub guidance: Option<String>,
145    /// `"binary": true`: its versions aren't text (`create_artifact` / `update_artifact` /
146    /// `read_artifact` don't take them; the plugin's own tools write them).
147    pub binary: bool,
148}
149
150/// `{"type": "text/html", "renderer": "web" | "text" | "native:<id>" | "view:<view id>"}`
151/// (`view:` names one of the plugin's views with `"site": "artifact"`).
152#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
153pub struct ManifestRenderer {
154    pub content_type: String,
155    pub renderer: String,
156}
157
158/// Where a plugin's system-prompt section comes from.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
160pub enum PromptSource {
161    /// A file of the plugin (`"system_prompt": "prompt.md"`).
162    File(String),
163    /// An in-tree builder by id (`"system_prompt": {"native": "artifacts"}`).
164    Native(String),
165}
166
167/// Render sites a view may declare.
168pub const VIEW_SITES: [&str; 5] = ["pane", "settings", "status", "card", "artifact"];
169/// Most views one plugin declares.
170pub const MAX_VIEWS: usize = 16;
171
172/// A view the plugin's backend draws (`views[]`): `{"site": "pane", "id": "todo",
173/// "title": "To do", "icon": "list"}`.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
175pub struct ManifestView {
176    /// `[a-z0-9_-]{1,48}`, unique in the plugin.
177    pub id: String,
178    /// One of [`VIEW_SITES`].
179    pub site: String,
180    /// The plugin's name when left out.
181    pub title: String,
182    pub icon: String,
183    /// A web view: its HTML file in the plugin folder (`"html": "ui/index.html"`), shown on
184    /// a web surface instead of a tree the backend draws. Empty for tree views.
185    pub html: String,
186}
187
188/// Most `python.requirements` entries.
189pub const MAX_REQUIREMENTS: usize = 64;
190/// Engine events a backend may subscribe to (`backend.events`, or `subscribe` at run time).
191pub const BACKEND_EVENTS: [&str; 5] = ["chat_opened", "turn_started", "turn_finished", "step_done", "artifact_updated"];
192
193/// How to start a plugin's backend.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
195pub struct BackendDecl {
196    pub run: BackendRun,
197    /// Extra arguments after the script (or the command).
198    pub args: Vec<String>,
199    /// Engine events it gets from the start (`subscribe` adds more at run time).
200    pub events: Vec<String>,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
204pub enum BackendRun {
205    /// A Python script in the plugin folder, run with the plugin's interpreter.
206    Python(String),
207    /// Any program (on `PATH`, or relative to the plugin folder), run in the plugin folder.
208    Command(String),
209    /// In-tree Rust registered by id (`{"native": "sheets"}`), run in byob's own process on
210    /// a thread of its own ([`crate::native`]).
211    Native(String),
212}
213
214#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
215pub struct Permissions {
216    /// Python tools (and skills' scripts) may use the internet.
217    pub network: bool,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
221pub struct Starter {
222    pub title: String,
223    pub prompt: String,
224    /// The starter asks the user for a file first.
225    pub file: bool,
226}
227
228/// A tool a plugin declares.
229#[derive(Debug, Clone, PartialEq, Serialize)]
230pub struct ManifestTool {
231    /// `[A-Za-z][A-Za-z0-9_]{0,63}`.
232    pub name: String,
233    pub description: String,
234    /// JSON Schema object for the arguments.
235    pub params: Value,
236    pub kind: ToolKind,
237    /// Step title with `{arg}` placeholders.
238    pub title: Option<String>,
239    /// A native tool this build may not have (e.g. `install_packages` without a package
240    /// manager): missing isn't a problem.
241    pub optional: bool,
242    /// What a script or backend tool does, for the permission gate (`"access"`: `read`,
243    /// `write`, `network`, `execute`); `None` is `execute`. Native tools bring their own.
244    #[serde(skip)]
245    pub access: Option<byob_tools::Access>,
246    /// Calls a person must confirm every time (`"confirm"`), whatever the approvals setting:
247    /// no policy, grant or hook's allow skips the card.
248    #[serde(skip)]
249    pub confirm: Option<Confirm>,
250}
251
252/// A script or backend tool's must-confirm rule (`"confirm": true`, `"<warning>"`, or `{"warning"?,
253/// "when"?: [arg names]}`).
254#[derive(Debug, Clone, PartialEq, Eq, Default)]
255pub struct Confirm {
256    /// Shown prominently on the card.
257    pub warning: String,
258    /// Only calls that give one of these arguments a value (not null, "", [], {} or false);
259    /// empty: every call.
260    pub when: Vec<String>,
261}
262
263impl Confirm {
264    /// The card's warning if this call must be confirmed.
265    pub fn applies(&self, args: &Value) -> Option<String> {
266        let given = |k: &String| match args.get(k) {
267            None | Some(Value::Null | Value::Bool(false)) => false,
268            Some(Value::String(s)) => !s.trim().is_empty(),
269            Some(Value::Array(a)) => !a.is_empty(),
270            Some(Value::Object(o)) => !o.is_empty(),
271            Some(_) => true,
272        };
273        (self.when.is_empty() || self.when.iter().any(given))
274            .then(|| if self.warning.is_empty() { "this call must be confirmed".to_owned() } else { self.warning.clone() })
275    }
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
279pub enum ToolKind {
280    /// A Python script, relative to the plugin folder.
281    Script(String),
282    /// One of the app's own (in-tree) tools, by name. A name this build doesn't have is a
283    /// problem of the plugin.
284    Native(String),
285    /// Served by the plugin's long-lived backend (`"run": "backend"`).
286    Backend,
287}
288
289/// How to reach one MCP server.
290#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
291pub enum McpServerConfig {
292    /// A program on this computer, spoken to over stdio.
293    Stdio { command: String, args: Vec<String>, env: BTreeMap<String, String> },
294    /// A server at a URL (Streamable HTTP, or legacy HTTP+SSE).
295    Url { url: String, sse: bool, headers: BTreeMap<String, String>, oauth: Option<OAuthClient> },
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
299pub struct OAuthClient {
300    pub client_id: String,
301    pub client_secret: Option<String>,
302}
303
304impl McpServerConfig {
305    /// Stdio servers run unsandboxed local code; URL servers only talk to the network.
306    pub fn is_local(&self) -> bool {
307        matches!(self, Self::Stdio { .. })
308    }
309
310    /// Fill `${KEY}` placeholders in args, env values, the URL and headers from the
311    /// plugin's settings. Unknown keys are left as they are.
312    pub fn with_settings(&self, values: &BTreeMap<String, String>) -> Self {
313        let fill = |s: &String| fill_placeholders(s, values);
314        let fill_map = |m: &BTreeMap<String, String>| m.iter().map(|(k, v)| (k.clone(), fill(v))).collect();
315        match self {
316            Self::Stdio { command, args, env } => Self::Stdio { command: fill(command), args: args.iter().map(fill).collect(), env: fill_map(env) },
317            Self::Url { url, sse, headers, oauth } => Self::Url { url: fill(url), sse: *sse, headers: fill_map(headers), oauth: oauth.clone() },
318        }
319    }
320}
321
322/// `"Bearer ${API_KEY}"` + `{API_KEY: "abc"}` → `"Bearer abc"`.
323pub fn fill_placeholders(s: &str, values: &BTreeMap<String, String>) -> String {
324    let mut out = String::with_capacity(s.len());
325    let mut rest = s;
326    while let Some(start) = rest.find("${") {
327        out.push_str(&rest[..start]);
328        let after = &rest[start + 2..];
329        match after.find('}') {
330            Some(end) => {
331                let key = &after[..end];
332                match values.get(key) {
333                    Some(v) => out.push_str(v),
334                    None => {
335                        out.push_str("${");
336                        out.push_str(key);
337                        out.push('}');
338                    }
339                }
340                rest = &after[end + 1..];
341            }
342            None => {
343                out.push_str(&rest[start..]);
344                rest = "";
345            }
346        }
347    }
348    out.push_str(rest);
349    out
350}
351
352#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
353pub enum SettingKind {
354    #[default]
355    Text,
356    Number,
357    Boolean,
358    Directory,
359    File,
360}
361
362impl SettingKind {
363    fn parse(s: &str) -> Option<Self> {
364        Some(match s {
365            "text" => Self::Text,
366            "number" => Self::Number,
367            "boolean" => Self::Boolean,
368            "directory" => Self::Directory,
369            "file" => Self::File,
370            _ => return None,
371        })
372    }
373}
374
375/// A value the user fills in on the plugin's card; passed to tools and servers as an
376/// environment variable named `key`.
377#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
378pub struct Setting {
379    /// `[A-Z][A-Z0-9_]{0,63}`.
380    pub key: String,
381    pub label: String,
382    pub secret: bool,
383    pub help: Option<String>,
384    pub kind: SettingKind,
385    pub required: bool,
386    pub multiple: bool,
387    /// Lists are joined with newlines, as hob does.
388    pub default: Option<String>,
389}
390
391/// Why a manifest was rejected; the message is meant for the plugin's author.
392#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
393#[error("{file}: {message}")]
394pub struct ManifestError {
395    pub file: String,
396    pub message: String,
397}
398
399/// Options for [`parse_manifest`].
400#[derive(Debug, Clone, Copy, Default)]
401pub struct ParseOptions<'a> {
402    /// Ships with the app: may use `native` tools and a longer prompt.
403    pub builtin: bool,
404    /// File name used in error messages; [`MANIFEST`] when `None`.
405    pub file: Option<&'a str>,
406}
407
408/// `[a-z0-9][a-z0-9-]{0,47}`
409pub fn plugin_id_ok(id: &str) -> bool {
410    let b = id.as_bytes();
411    !b.is_empty() && b.len() <= 48 && is_lower_alnum(b[0]) && b.iter().all(|&c| is_lower_alnum(c) || c == b'-')
412}
413
414/// `[A-Za-z][A-Za-z0-9_]{0,63}`
415pub fn tool_name_ok(name: &str) -> bool {
416    let b = name.as_bytes();
417    !b.is_empty() && b.len() <= 64 && b[0].is_ascii_alphabetic() && b.iter().all(|&c| c.is_ascii_alphanumeric() || c == b'_')
418}
419
420/// `[A-Z][A-Z0-9_]{0,63}`
421pub fn setting_key_ok(key: &str) -> bool {
422    let b = key.as_bytes();
423    !b.is_empty() && b.len() <= 64 && b[0].is_ascii_uppercase() && b.iter().all(|&c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == b'_')
424}
425
426fn is_lower_alnum(c: u8) -> bool {
427    c.is_ascii_lowercase() || c.is_ascii_digit()
428}
429
430/// A relative path that stays inside its folder (`tools/x.py`, not `/x` or `../x`).
431pub fn path_inside(p: &str) -> bool {
432    let mut depth: usize = 0;
433    for c in Path::new(p).components() {
434        match c {
435            Component::Normal(_) => depth += 1,
436            Component::CurDir => {}
437            Component::ParentDir => match depth.checked_sub(1) {
438                Some(d) => depth = d,
439                None => return false,
440            },
441            Component::RootDir | Component::Prefix(_) => return false,
442        }
443    }
444    // Windows-style absolute paths on other systems.
445    !(p.starts_with('\\') || p.as_bytes().get(1) == Some(&b':'))
446}
447
448/// Check a parsed `byob-plugin.json` and fill in defaults.
449pub fn parse_manifest(raw: &Value, opts: ParseOptions<'_>) -> Result<Manifest, ManifestError> {
450    let file = opts.file.unwrap_or(MANIFEST);
451    parse(raw, opts.builtin).map_err(|message| ManifestError { file: file.to_string(), message })
452}
453
454/// Parse manifest text (JSON).
455pub fn parse_manifest_str(text: &str, opts: ParseOptions<'_>) -> Result<Manifest, ManifestError> {
456    let file = opts.file.unwrap_or(MANIFEST);
457    let raw: Value = serde_json::from_str(text).map_err(|e| ManifestError { file: file.to_string(), message: format!("isn't valid JSON: {e}") })?;
458    parse_manifest(&raw, opts)
459}
460
461type R<T> = Result<T, String>;
462
463/// Required text: present, a string, not blank. Trimmed.
464fn req_str(v: Option<&Value>, field: &str) -> R<String> {
465    match v {
466        Some(Value::String(s)) if !s.trim().is_empty() => Ok(s.trim().to_string()),
467        _ => Err(format!("{field} must be text")),
468    }
469}
470
471/// Optional text: absent → `None`; present must be a string.
472fn opt_str(v: Option<&Value>, field: &str) -> R<Option<String>> {
473    match v {
474        None => Ok(None),
475        Some(Value::String(s)) => Ok(Some(s.trim().to_string())),
476        Some(_) => Err(format!("{field} must be text")),
477    }
478}
479
480fn list<'a>(v: Option<&'a Value>, field: &str) -> R<&'a [Value]> {
481    match v {
482        None => Ok(&[]),
483        Some(Value::Array(a)) => Ok(a),
484        Some(_) => Err(format!("{field} must be a list")),
485    }
486}
487
488fn obj<'a>(v: &'a Value, field: &str) -> R<&'a Map<String, Value>> {
489    v.as_object().ok_or_else(|| format!("{field} must be an object"))
490}
491
492fn str_map(v: Option<&Value>, field: &str) -> R<BTreeMap<String, String>> {
493    let Some(v) = v else { return Ok(BTreeMap::new()) };
494    let bad = || format!("{field} must map names to text");
495    let m = v.as_object().ok_or_else(bad)?;
496    m.iter().map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())).ok_or_else(bad)).collect()
497}
498
499fn truthy(v: Option<&Value>) -> bool {
500    match v {
501        None | Some(Value::Null) => false,
502        Some(Value::Bool(b)) => *b,
503        Some(Value::Number(n)) => n.as_f64().is_some_and(|f| f != 0.0),
504        Some(Value::String(s)) => !s.is_empty(),
505        Some(_) => true,
506    }
507}
508
509fn truncate_chars(s: &str, max: usize) -> String {
510    s.chars().take(max).collect()
511}
512
513fn parse(raw: &Value, builtin: bool) -> R<Manifest> {
514    let r = raw.as_object().ok_or("must be a JSON object")?;
515    let id = req_str(r.get("id"), "id")?;
516    if !plugin_id_ok(&id) {
517        return Err("id must be lowercase letters, digits and dashes, like \"trip-planner\"".into());
518    }
519    let tools = list(r.get("tools"), "tools")?.iter().enumerate().map(|(i, t)| parse_tool(t, i)).collect::<R<Vec<_>>>()?;
520    let mcp_servers = parse_servers(r.get("mcpServers"))?;
521    let settings = list(r.get("settings"), "settings")?.iter().enumerate().map(|(i, s)| parse_setting(s, i)).collect::<R<Vec<_>>>()?;
522    let starters = list(r.get("starters"), "starters")?
523        .iter()
524        .enumerate()
525        .map(|(i, s)| {
526            let f = format!("starters[{i}]");
527            let s = obj(s, &f)?;
528            Ok(Starter {
529                title: req_str(s.get("title"), &format!("{f}.title"))?,
530                prompt: req_str(s.get("prompt"), &format!("{f}.prompt"))?,
531                file: s.get("file") == Some(&Value::Bool(true)),
532            })
533        })
534        .collect::<R<Vec<_>>>()?;
535    let network = r.get("permissions").and_then(Value::as_object).and_then(|p| p.get("network")) == Some(&Value::Bool(true));
536    let prompt = match r.get("prompt") {
537        None => None,
538        p => Some(truncate_chars(&req_str(p, "prompt")?, if builtin { PROMPT_MAX_BUILTIN } else { PROMPT_MAX })),
539    };
540    let platforms = r.get("platforms").and_then(Value::as_array).map(|a| a.iter().map(|v| v.as_str().map_or_else(|| v.to_string(), str::to_string)).collect());
541    let author = match r.get("author") {
542        None => None,
543        a => Some(req_str(a, "author")?),
544    };
545    let backend = parse_backend(r.get("backend"))?;
546    if backend.is_none()
547        && let Some(i) = tools.iter().position(|t| t.kind == ToolKind::Backend)
548    {
549        return Err(format!("tools[{i}].run is \"backend\" but the plugin has no \"backend\""));
550    }
551    let requirements = parse_requirements(r.get("python"))?;
552    let hooks = parse_hooks(r.get("hooks"), backend.is_some())?;
553    let name = req_str(r.get("name"), "name")?;
554    let views = parse_views(r.get("views"), &name)?;
555    if backend.is_none() && views.iter().any(|v| v.html.is_empty()) {
556        return Err("views are drawn by the plugin's backend: add \"backend\": \"backend.py\" (or give the view an \"html\" file)".into());
557    }
558    let renderers = parse_renderers(r.get("renderers"))?;
559    for (i, d) in renderers.iter().enumerate() {
560        if let Some(v) = d.renderer.strip_prefix("view:")
561            && !views.iter().any(|x| x.id == v && x.site == "artifact")
562        {
563            return Err(format!("renderers[{i}].renderer: {:?} needs a view {{\"site\": \"artifact\", \"id\": \"{v}\"}} in \"views\"", d.renderer));
564        }
565    }
566    Ok(Manifest {
567        id,
568        name,
569        version: opt_str(r.get("version"), "version")?.filter(|v| !v.is_empty()).unwrap_or_else(|| "0.0.0".into()),
570        description: opt_str(r.get("description"), "description")?.unwrap_or_default(),
571        author,
572        icon: opt_str(r.get("icon"), "icon")?.filter(|v| !v.is_empty()).unwrap_or_else(|| "puzzle".into()),
573        permissions: Permissions { network },
574        starters,
575        tools,
576        mcp_servers,
577        settings,
578        prompt,
579        platforms,
580        backend,
581        requirements,
582        views,
583        locked: r.get("locked") == Some(&Value::Bool(true)),
584        on_by_default: r.get("enabled") != Some(&Value::Bool(false)),
585        provides: match opt_str(r.get("provides"), "provides")?.map(|p| p.trim().to_owned()).filter(|p| !p.is_empty()) {
586            Some(p) if !plugin_id_ok(&p) => return Err("provides must be lowercase letters, digits and dashes, like \"spreadsheets\"".into()),
587            p => p,
588        },
589        artifact_types: parse_artifact_types(r.get("artifact_types").or_else(|| r.get("content_types")))?,
590        renderers,
591        system_prompt: parse_prompt_source(r.get("system_prompt"))?,
592        hooks,
593        environments: parse_environments(r.get("environments"))?,
594    })
595}
596
597/// `"hooks": [{"event", "priority"?, "native"? | "backend": true, "timeout_ms"?, "fail"?, "tools"?}]`.
598fn parse_hooks(v: Option<&Value>, has_backend: bool) -> R<Vec<ManifestHook>> {
599    let items = list(v, "hooks")?;
600    if items.len() > MAX_HOOKS {
601        return Err(format!("hooks: at most {MAX_HOOKS}"));
602    }
603    let mut out = Vec::new();
604    for (i, item) in items.iter().enumerate() {
605        let f = format!("hooks[{i}]");
606        let o = obj(item, &f)?;
607        let event = req_str(o.get("event"), &format!("{f}.event"))?;
608        if !HOOK_EVENTS.contains(&event.as_str()) {
609            return Err(format!("{f}.event must be one of {}", HOOK_EVENTS.join(", ")));
610        }
611        let priority = match o.get("priority") {
612            None | Some(Value::Null) => DEFAULT_HOOK_PRIORITY,
613            Some(p) => p.as_i64().filter(|p| p.abs() <= 1_000_000).ok_or_else(|| format!("{f}.priority must be a whole number"))?,
614        };
615        let native = opt_str(o.get("native"), &format!("{f}.native"))?;
616        if let Some(n) = &native
617            && !native_id_ok(n)
618        {
619            return Err(format!("{f}.native must be an id like \"compaction\""));
620        }
621        if native.is_none() && !has_backend {
622            return Err(format!("{f} is answered by the plugin's backend: add \"backend\": \"backend.py\" (or name in-tree code with \"native\")"));
623        }
624        let timeout_ms = match o.get("timeout_ms") {
625            None | Some(Value::Null) => HOOK_TIMEOUT_MS,
626            Some(t) => {
627                t.as_u64().filter(|t| (1..=MAX_HOOK_TIMEOUT_MS).contains(t)).ok_or_else(|| format!("{f}.timeout_ms must be 1 to {MAX_HOOK_TIMEOUT_MS}"))?
628            }
629        };
630        let fail_closed = match opt_str(o.get("fail"), &format!("{f}.fail"))?.as_deref() {
631            None | Some("open") => false,
632            Some("closed") => true,
633            Some(_) => return Err(format!("{f}.fail must be \"open\" or \"closed\"")),
634        };
635        let tools = list(o.get("tools"), &format!("{f}.tools"))?
636            .iter()
637            .enumerate()
638            .map(|(j, t)| t.as_str().map(str::trim).filter(|t| tool_name_ok(t)).map(str::to_owned).ok_or_else(|| format!("{f}.tools[{j}] must be a tool name")))
639            .collect::<R<Vec<_>>>()?;
640        if !tools.is_empty() && !matches!(event.as_str(), "before_tool_call" | "after_tool_call") {
641            return Err(format!("{f}.tools only applies to before_tool_call and after_tool_call"));
642        }
643        out.push(ManifestHook { event, priority, native, timeout_ms, fail_closed, tools });
644    }
645    Ok(out)
646}
647
648/// `"environments": [{"native": "nix"}, {"id", "label", "detect", "inputs"?, "command", "args"?}]`.
649fn parse_environments(v: Option<&Value>) -> R<Vec<ManifestEnv>> {
650    let items = list(v, "environments")?;
651    if items.len() > MAX_ENVIRONMENTS {
652        return Err(format!("environments: at most {MAX_ENVIRONMENTS}"));
653    }
654    let mut out: Vec<ManifestEnv> = Vec::new();
655    for (i, item) in items.iter().enumerate() {
656        let f = format!("environments[{i}]");
657        let o = obj(item, &f)?;
658        let native = opt_str(o.get("native"), &format!("{f}.native"))?;
659        let id = match (&native, o.get("id")) {
660            (Some(n), None) => n.clone(),
661            (_, v) => req_str(v, &format!("{f}.id"))?,
662        };
663        let id_ok = (1..=32).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'));
664        if !id_ok || native.as_deref().is_some_and(|n| !native_id_ok(n)) {
665            return Err(format!("{f}.id must be lowercase letters, digits, _ or -, like \"conda\""));
666        }
667        if out.iter().any(|e| e.id == id) {
668            return Err(format!("{f}.id {id:?} is used twice"));
669        }
670        let files = |key: &str| -> R<Vec<String>> {
671            list(o.get(key), &format!("{f}.{key}"))?
672                .iter()
673                .enumerate()
674                .map(|(j, p)| match p.as_str().map(str::trim) {
675                    Some(p) if !p.is_empty() && path_inside(p) => Ok(p.to_owned()),
676                    _ => Err(format!("{f}.{key}[{j}] must be a file name inside the workspace, like \"environment.yml\"")),
677                })
678                .collect()
679        };
680        let detect = files("detect")?;
681        let inputs = files("inputs")?;
682        let command = opt_str(o.get("command"), &format!("{f}.command"))?.filter(|c| !c.is_empty());
683        let args = list(o.get("args"), &format!("{f}.args"))?
684            .iter()
685            .enumerate()
686            .map(|(j, a)| a.as_str().map(str::to_owned).ok_or_else(|| format!("{f}.args[{j}] must be text")))
687            .collect::<R<Vec<_>>>()?;
688        if native.is_none() && (detect.is_empty() || command.is_none()) {
689            return Err(format!("{f} needs \"native\" (built into byob) or \"detect\" (the workspace files) and \"command\" (prints the environment as JSON)"));
690        }
691        let label =
692            truncate_chars(&opt_str(o.get("label"), &format!("{f}.label"))?.filter(|l| !l.is_empty()).unwrap_or_else(|| format!("{id} environment")), 60);
693        out.push(ManifestEnv { id, native, label, detect, inputs, command, args });
694    }
695    Ok(out)
696}
697
698/// `type/subtype`, lowercase (what `byob_protocol::artifact::valid_content_type` takes).
699fn content_type_ok(t: &str) -> bool {
700    let ok = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'+' | b'_' | b'-'));
701    t.len() <= 128 && t.split_once('/').is_some_and(|(a, b)| ok(a) && (ok(b) || b == "*"))
702}
703
704fn native_id_ok(id: &str) -> bool {
705    (1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-' | b'.'))
706}
707
708fn parse_artifact_types(v: Option<&Value>) -> R<Vec<ManifestArtifactType>> {
709    let items = list(v, "artifact_types")?;
710    if items.len() > MAX_TYPES {
711        return Err(format!("artifact_types: at most {MAX_TYPES}"));
712    }
713    let mut out: Vec<ManifestArtifactType> = Vec::new();
714    for (i, item) in items.iter().enumerate() {
715        let f = format!("artifact_types[{i}]");
716        let o = obj(item, &f)?;
717        let content_type = req_str(o.get("type"), &format!("{f}.type"))?.to_ascii_lowercase();
718        if !content_type_ok(&content_type) || content_type.ends_with("/*") {
719            return Err(format!("{f}.type must be a content type like \"text/csv\""));
720        }
721        if out.iter().any(|t| t.content_type == content_type) {
722            return Err(format!("{f}.type {content_type:?} is declared twice"));
723        }
724        let native = opt_str(o.get("native"), &format!("{f}.native"))?;
725        if let Some(n) = &native
726            && !native_id_ok(n)
727        {
728            return Err(format!("{f}.native must be an id like \"html_app\""));
729        }
730        let suffixes = match o.get("suffixes") {
731            None => None,
732            v => Some(
733                list(v, &format!("{f}.suffixes"))?
734                    .iter()
735                    .enumerate()
736                    .map(|(j, s)| match s.as_str().map(str::trim) {
737                        Some(s) if s.starts_with('.') && s.len() > 1 && s.len() <= 32 && !s.contains(['/', '\\']) => Ok(s.to_ascii_lowercase()),
738                        _ => Err(format!("{f}.suffixes[{j}] must be a file name ending like \".csv\"")),
739                    })
740                    .collect::<R<Vec<_>>>()?,
741            ),
742        };
743        if native.is_none() && suffixes.as_ref().is_none_or(Vec::is_empty) {
744            return Err(format!("{f} needs \"native\" (a type built into byob) or \"suffixes\" (the file names it covers)"));
745        }
746        let guidance = opt_str(o.get("guidance"), &format!("{f}.guidance"))?;
747        if let Some(g) = &guidance
748            && (g.is_empty() || !path_inside(g))
749        {
750            return Err(format!("{f}.guidance must be a file inside the plugin folder, like \"guidance.md\""));
751        }
752        let short = |key: &str| -> R<Option<String>> { Ok(opt_str(o.get(key), &format!("{f}.{key}"))?.map(|s| truncate_chars(&s, 400))) };
753        let binary = match o.get("binary") {
754            None | Some(Value::Null) => false,
755            Some(Value::Bool(b)) => *b,
756            Some(_) => return Err(format!("{f}.binary must be true or false")),
757        };
758        out.push(ManifestArtifactType {
759            content_type,
760            native,
761            suffixes,
762            label: short("label")?,
763            describe: short("describe")?,
764            pick: short("pick")?,
765            guidance,
766            binary,
767        });
768    }
769    Ok(out)
770}
771
772fn parse_renderers(v: Option<&Value>) -> R<Vec<ManifestRenderer>> {
773    let items = list(v, "renderers")?;
774    if items.len() > MAX_TYPES {
775        return Err(format!("renderers: at most {MAX_TYPES}"));
776    }
777    items
778        .iter()
779        .enumerate()
780        .map(|(i, item)| {
781            let f = format!("renderers[{i}]");
782            let o = obj(item, &f)?;
783            let content_type = req_str(o.get("type"), &format!("{f}.type"))?.to_ascii_lowercase();
784            if !content_type_ok(&content_type) {
785                return Err(format!("{f}.type must be a content type like \"text/csv\" (or \"image/*\")"));
786            }
787            let renderer = req_str(o.get("renderer"), &format!("{f}.renderer"))?;
788            let web_file = renderer.strip_prefix("web:").map(str::trim);
789            let ok = matches!(renderer.as_str(), "web" | "text")
790                || renderer.strip_prefix("native:").is_some_and(native_id_ok)
791                || renderer.strip_prefix("view:").is_some_and(byob_view_id_ok)
792                || web_file.is_some_and(|p| !p.is_empty() && path_inside(p));
793            if !ok {
794                return Err(format!("{f}.renderer must be \"web\", \"web:<html file in the plugin>\", \"text\", \"native:<id>\" or \"view:<view id>\""));
795            }
796            Ok(ManifestRenderer { content_type, renderer })
797        })
798        .collect()
799}
800
801fn parse_prompt_source(v: Option<&Value>) -> R<Option<PromptSource>> {
802    let f = "system_prompt";
803    match v {
804        None | Some(Value::Null) => Ok(None),
805        Some(Value::String(s)) => {
806            let s = s.trim();
807            if s.is_empty() || !path_inside(s) {
808                return Err(format!("{f} must be a file inside the plugin folder, like \"prompt.md\""));
809            }
810            Ok(Some(PromptSource::File(s.to_owned())))
811        }
812        Some(v) => {
813            let o = obj(v, f)?;
814            let n = req_str(o.get("native"), &format!("{f}.native"))?;
815            if !native_id_ok(&n) {
816                return Err(format!("{f}.native must be an id like \"artifacts\""));
817            }
818            Ok(Some(PromptSource::Native(n)))
819        }
820    }
821}
822
823/// A view id: `[a-z0-9_-]{1,48}`.
824fn byob_view_id_ok(id: &str) -> bool {
825    (1..=48).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
826}
827
828/// `"views": [{"site": "pane", "id": "todo", "title": "To do", "icon": "list"}]`.
829fn parse_views(v: Option<&Value>, plugin_name: &str) -> R<Vec<ManifestView>> {
830    let items = list(v, "views")?;
831    if items.len() > MAX_VIEWS {
832        return Err(format!("views: at most {MAX_VIEWS}"));
833    }
834    let mut out: Vec<ManifestView> = Vec::new();
835    for (i, item) in items.iter().enumerate() {
836        let f = format!("views[{i}]");
837        let o = obj(item, &f)?;
838        let site = req_str(o.get("site"), &format!("{f}.site"))?;
839        if !VIEW_SITES.contains(&site.as_str()) {
840            return Err(format!("{f}.site must be one of {}", VIEW_SITES.join(", ")));
841        }
842        let id = match o.get("id") {
843            None => site.clone(),
844            v => req_str(v, &format!("{f}.id"))?,
845        };
846        if !byob_view_id_ok(&id) {
847            return Err(format!("{f}.id must be lowercase letters, digits, _ or -, like \"todo\""));
848        }
849        if out.iter().any(|x| x.id == id) {
850            return Err(format!("{f}.id {id:?} is used twice"));
851        }
852        let title = truncate_chars(&opt_str(o.get("title"), &format!("{f}.title"))?.filter(|t| !t.is_empty()).unwrap_or_else(|| plugin_name.to_owned()), 80);
853        let icon = opt_str(o.get("icon"), &format!("{f}.icon"))?.unwrap_or_default();
854        let html = opt_str(o.get("html"), &format!("{f}.html"))?.map(|h| h.trim().to_owned()).unwrap_or_default();
855        if !html.is_empty() && !path_inside(&html) {
856            return Err(format!("{f}.html must be a file inside the plugin folder, like \"ui/index.html\""));
857        }
858        if !html.is_empty() && !matches!(site.as_str(), "pane" | "card") {
859            return Err(format!("{f}: a web view (\"html\") goes in the pane or on a card"));
860        }
861        out.push(ManifestView { id, site, title, icon, html });
862    }
863    Ok(out)
864}
865
866/// `"backend": "backend.py"`, `{"run": "backend.py", "args": [], "events": []}` or
867/// `{"command": "node", "args": ["server.js"]}`.
868fn parse_backend(v: Option<&Value>) -> R<Option<BackendDecl>> {
869    let f = "backend";
870    let script = |p: String| -> R<BackendRun> {
871        if !path_inside(&p) || p.is_empty() {
872            return Err(format!("{f}.run must be a path inside the plugin folder, like \"backend.py\""));
873        }
874        Ok(BackendRun::Python(p))
875    };
876    let o = match v {
877        None | Some(Value::Null) => return Ok(None),
878        Some(Value::String(s)) => return Ok(Some(BackendDecl { run: script(s.trim().to_string())?, args: Vec::new(), events: Vec::new() })),
879        Some(v) => obj(v, f)?,
880    };
881    let run = match (o.get("run"), o.get("command"), o.get("native")) {
882        (Some(r), None, None) => script(req_str(Some(r), &format!("{f}.run"))?)?,
883        (None, Some(c), None) => BackendRun::Command(req_str(Some(c), &format!("{f}.command"))?),
884        (None, None, Some(n)) => BackendRun::Native(req_str(Some(n), &format!("{f}.native"))?),
885        _ => return Err(format!("{f} needs \"run\" (a Python script), \"command\" (a program) or \"native\" (a backend built into byob), one of them")),
886    };
887    let strings = |key: &str| -> R<Vec<String>> {
888        list(o.get(key), &format!("{f}.{key}"))?
889            .iter()
890            .enumerate()
891            .map(|(i, a)| a.as_str().map(str::to_string).ok_or_else(|| format!("{f}.{key}[{i}] must be text")))
892            .collect()
893    };
894    let args = strings("args")?;
895    let events = strings("events")?;
896    if let Some(e) = events.iter().find(|e| !BACKEND_EVENTS.contains(&e.as_str())) {
897        return Err(format!("{f}.events: {e:?} isn't an engine event; use {}", BACKEND_EVENTS.join(", ")));
898    }
899    Ok(Some(BackendDecl { run, args, events }))
900}
901
902/// `"python": {"requirements": ["requests>=2", "./wheels/x.whl"]}`.
903fn parse_requirements(v: Option<&Value>) -> R<Vec<String>> {
904    let Some(v) = v else { return Ok(Vec::new()) };
905    let o = obj(v, "python")?;
906    let reqs = list(o.get("requirements"), "python.requirements")?;
907    if reqs.len() > MAX_REQUIREMENTS {
908        return Err(format!("python.requirements: at most {MAX_REQUIREMENTS} packages"));
909    }
910    reqs.iter()
911        .enumerate()
912        .map(|(i, r)| {
913            let r = req_str(Some(r), &format!("python.requirements[{i}]"))?;
914            // one pip spec each, never options (--index-url …)
915            if r.starts_with('-') || r.contains(['\n', '\r']) || r.len() > 512 {
916                return Err(format!("python.requirements[{i}] must be one package spec like \"requests>=2.31\""));
917            }
918            Ok(r)
919        })
920        .collect()
921}
922
923fn parse_tool(t: &Value, i: usize) -> R<ManifestTool> {
924    let f = format!("tools[{i}]");
925    let t = obj(t, &f)?;
926    let native = match t.get("native") {
927        None => None,
928        n => Some(req_str(n, &format!("{f}.native"))?),
929    };
930    // native tools bring their own name, description and parameters
931    let name = match (&native, t.get("name")) {
932        (Some(n), None) => n.clone(),
933        (_, v) => req_str(v, &format!("{f}.name"))?,
934    };
935    if !tool_name_ok(&name) {
936        return Err(format!("{f}.name must be letters, digits or _, starting with a letter"));
937    }
938    let params = match t.get("params") {
939        None => serde_json::json!({ "type": "object", "properties": {} }),
940        Some(p @ Value::Object(_)) => p.clone(),
941        Some(_) => return Err(format!("{f}.params must be a JSON schema object")),
942    };
943    let run = match t.get("run") {
944        None => None,
945        Some(Value::String(s)) if s.trim() == "backend" => Some(String::new()),
946        v => {
947            let p = req_str(v, &format!("{f}.run"))?;
948            if !path_inside(&p) {
949                return Err(format!("{f}.run must be a path inside the plugin folder"));
950            }
951            Some(p)
952        }
953    };
954    let kind = match (run, native) {
955        (Some(r), _) if r.is_empty() => ToolKind::Backend,
956        (Some(r), _) => ToolKind::Script(r),
957        (None, Some(n)) => ToolKind::Native(n),
958        (None, None) => return Err(format!("{f}.run must name the Python script that runs this tool")),
959    };
960    let description = match (&kind, t.get("description")) {
961        (ToolKind::Native(_), None) => String::new(),
962        (_, d) => req_str(d, &format!("{f}.description"))?,
963    };
964    let title = match t.get("title") {
965        None => None,
966        v => Some(req_str(v, &format!("{f}.title"))?),
967    };
968    let optional = t.get("optional") == Some(&Value::Bool(true));
969    let access = match t.get("access") {
970        None | Some(Value::Null) => None,
971        a => Some(match req_str(a, &format!("{f}.access"))?.as_str() {
972            "read" => byob_tools::Access::Read,
973            "write" => byob_tools::Access::Write,
974            "network" => byob_tools::Access::Network,
975            "execute" => byob_tools::Access::Execute,
976            _ => return Err(format!("{f}.access must be one of read, write, network, execute")),
977        }),
978    };
979    let confirm = match t.get("confirm") {
980        None | Some(Value::Null | Value::Bool(false)) => None,
981        Some(Value::Bool(true)) => Some(Confirm::default()),
982        Some(Value::String(w)) => Some(Confirm { warning: w.trim().chars().take(300).collect(), when: Vec::new() }),
983        Some(Value::Object(c)) => {
984            let warning = match c.get("warning") {
985                None => String::new(),
986                w => req_str(w, &format!("{f}.confirm.warning"))?.chars().take(300).collect(),
987            };
988            let when = match c.get("when") {
989                None => Vec::new(),
990                Some(Value::Array(a)) => a
991                    .iter()
992                    .map(|v| v.as_str().map(str::to_owned))
993                    .collect::<Option<Vec<_>>>()
994                    .ok_or_else(|| format!("{f}.confirm.when must list argument names"))?,
995                Some(_) => return Err(format!("{f}.confirm.when must list argument names")),
996            };
997            Some(Confirm { warning, when })
998        }
999        Some(_) => return Err(format!("{f}.confirm must be true, a warning, or {{\"warning\", \"when\"}}")),
1000    };
1001    if confirm.is_some() && matches!(kind, ToolKind::Native(_)) {
1002        return Err(format!("{f}.confirm is for script and backend tools (native tools bring their own)"));
1003    }
1004    Ok(ManifestTool { name, description, params, kind, title, optional, access, confirm })
1005}
1006
1007fn parse_servers(v: Option<&Value>) -> R<BTreeMap<String, McpServerConfig>> {
1008    let Some(v) = v else { return Ok(BTreeMap::new()) };
1009    let m = v.as_object().ok_or("mcpServers must be an object of servers")?;
1010    m.iter().map(|(k, v)| Ok((k.clone(), parse_server(v, &format!("mcpServers.{k}"))?))).collect()
1011}
1012
1013/// One entry of an `mcpServers` object (manifest or pasted Claude/Cursor config).
1014pub(crate) fn parse_server(v: &Value, f: &str) -> R<McpServerConfig> {
1015    let v = obj(v, f)?;
1016    let ty = v.get("type").and_then(Value::as_str);
1017    if v.get("url").is_some() || matches!(ty, Some("http" | "sse" | "streamable-http")) {
1018        let url = req_str(v.get("url"), &format!("{f}.url"))?;
1019        let lower = url.to_ascii_lowercase();
1020        let rest = lower.strip_prefix("https://").or_else(|| lower.strip_prefix("http://"));
1021        if !rest.is_some_and(|r| !r.is_empty() && !r.starts_with('/')) {
1022            return Err(format!("{f}.url must start with https://"));
1023        }
1024        let headers = str_map(v.get("headers"), &format!("{f}.headers"))?;
1025        let oauth = match v.get("oauth") {
1026            None => None,
1027            Some(o) => {
1028                let o = obj(o, &format!("{f}.oauth"))?;
1029                Some(OAuthClient {
1030                    client_id: req_str(o.get("clientId"), &format!("{f}.oauth.clientId"))?,
1031                    client_secret: match o.get("clientSecret") {
1032                        None => None,
1033                        s => Some(req_str(s, &format!("{f}.oauth.clientSecret"))?),
1034                    },
1035                })
1036            }
1037        };
1038        return Ok(McpServerConfig::Url { url, sse: ty == Some("sse"), headers, oauth });
1039    }
1040    let args = list(v.get("args"), &format!("{f}.args"))?
1041        .iter()
1042        .enumerate()
1043        .map(|(i, a)| a.as_str().map(|s| s.trim().to_string()).ok_or_else(|| format!("{f}.args[{i}] must be text")))
1044        .collect::<R<Vec<_>>>()?;
1045    let env = str_map(v.get("env"), &format!("{f}.env"))?;
1046    Ok(McpServerConfig::Stdio { command: req_str(v.get("command"), &format!("{f}.command"))?, args, env })
1047}
1048
1049fn parse_setting(s: &Value, i: usize) -> R<Setting> {
1050    let f = format!("settings[{i}]");
1051    let s = obj(s, &f)?;
1052    let key = req_str(s.get("key"), &format!("{f}.key"))?;
1053    if !setting_key_ok(&key) {
1054        return Err(format!("{f}.key must be UPPER_CASE, like \"API_KEY\""));
1055    }
1056    let kind = match s.get("type") {
1057        None => SettingKind::Text,
1058        t => {
1059            let t = req_str(t, &format!("{f}.type"))?;
1060            SettingKind::parse(&t).ok_or_else(|| format!("{f}.type must be one of text, number, boolean, directory, file"))?
1061        }
1062    };
1063    let help = match s.get("help") {
1064        None => None,
1065        h => Some(req_str(h, &format!("{f}.help"))?),
1066    };
1067    let default = s.get("default").map(|d| match d {
1068        Value::Array(a) => a.iter().map(value_text).collect::<Vec<_>>().join("\n"),
1069        other => value_text(other),
1070    });
1071    Ok(Setting {
1072        key,
1073        label: req_str(s.get("label"), &format!("{f}.label"))?,
1074        secret: truthy(s.get("secret")),
1075        help,
1076        kind,
1077        required: truthy(s.get("required")),
1078        multiple: truthy(s.get("multiple")),
1079        default,
1080    })
1081}
1082
1083/// JS `String(v)` for the values settings defaults take.
1084fn value_text(v: &Value) -> String {
1085    match v {
1086        Value::String(s) => s.clone(),
1087        Value::Null => "null".into(),
1088        other => other.to_string(),
1089    }
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094    use super::*;
1095    use serde_json::json;
1096
1097    fn parse_user(v: Value) -> Result<Manifest, String> {
1098        parse_manifest(&v, ParseOptions::default()).map_err(|e| e.to_string())
1099    }
1100
1101    #[test]
1102    fn minimal_manifest_gets_defaults() {
1103        let m = parse_user(json!({"id": "recipe-helper", "name": "Recipe Helper", "extra": {"future": true}})).unwrap();
1104        assert_eq!(m.version, "0.0.0");
1105        assert_eq!(m.icon, "puzzle");
1106        assert!(!m.permissions.network);
1107        assert!(m.tools.is_empty() && m.mcp_servers.is_empty() && m.settings.is_empty());
1108    }
1109
1110    #[test]
1111    fn author_facing_errors() {
1112        let cases = [
1113            (json!([]), "byob-plugin.json: must be a JSON object"),
1114            (json!({"name": "x"}), "byob-plugin.json: id must be text"),
1115            (json!({"id": "Bad_Id", "name": "x"}), "byob-plugin.json: id must be lowercase letters, digits and dashes, like \"trip-planner\""),
1116            (json!({"id": "a", "name": "x", "tools": {}}), "byob-plugin.json: tools must be a list"),
1117            (
1118                json!({"id": "a", "name": "x", "tools": [{"name": "t", "description": "d", "run": "../escape.py"}]}),
1119                "byob-plugin.json: tools[0].run must be a path inside the plugin folder",
1120            ),
1121            (
1122                json!({"id": "a", "name": "x", "tools": [{"name": "t", "description": "d", "run": "/abs.py"}]}),
1123                "byob-plugin.json: tools[0].run must be a path inside the plugin folder",
1124            ),
1125            (
1126                json!({"id": "a", "name": "x", "tools": [{"name": "t", "description": "d"}]}),
1127                "byob-plugin.json: tools[0].run must name the Python script that runs this tool",
1128            ),
1129            (
1130                json!({"id": "a", "name": "x", "tools": [{"name": "1t", "description": "d", "run": "t.py"}]}),
1131                "byob-plugin.json: tools[0].name must be letters, digits or _, starting with a letter",
1132            ),
1133            (
1134                json!({"id": "a", "name": "x", "artifact_types": [{"type": "csv"}]}),
1135                "byob-plugin.json: artifact_types[0].type must be a content type like \"text/csv\"",
1136            ),
1137            (
1138                json!({"id": "a", "name": "x", "artifact_types": [{"type": "text/csv"}]}),
1139                "byob-plugin.json: artifact_types[0] needs \"native\" (a type built into byob) or \"suffixes\" (the file names it covers)",
1140            ),
1141            (
1142                json!({"id": "a", "name": "x", "renderers": [{"type": "text/csv", "renderer": "wasm"}]}),
1143                "byob-plugin.json: renderers[0].renderer must be \"web\", \"web:<html file in the plugin>\", \"text\", \"native:<id>\" or \"view:<view id>\"",
1144            ),
1145            (
1146                json!({"id": "a", "name": "x", "system_prompt": "../p.md"}),
1147                "byob-plugin.json: system_prompt must be a file inside the plugin folder, like \"prompt.md\"",
1148            ),
1149            (
1150                json!({"id": "a", "name": "x", "settings": [{"key": "api_key", "label": "k"}]}),
1151                "byob-plugin.json: settings[0].key must be UPPER_CASE, like \"API_KEY\"",
1152            ),
1153            (
1154                json!({"id": "a", "name": "x", "settings": [{"key": "K", "label": "k", "type": "color"}]}),
1155                "byob-plugin.json: settings[0].type must be one of text, number, boolean, directory, file",
1156            ),
1157            (json!({"id": "a", "name": "x", "mcpServers": {"s": {"url": "ftp://x"}}}), "byob-plugin.json: mcpServers.s.url must start with https://"),
1158            (
1159                json!({"id": "a", "name": "x", "mcpServers": {"s": {"command": "x", "env": {"A": 1}}}}),
1160                "byob-plugin.json: mcpServers.s.env must map names to text",
1161            ),
1162            (json!({"id": "a", "name": "x", "mcpServers": {"s": {}}}), "byob-plugin.json: mcpServers.s.command must be text"),
1163        ];
1164        for (raw, want) in cases {
1165            assert_eq!(parse_user(raw.clone()).unwrap_err(), want, "for {raw}");
1166        }
1167    }
1168
1169    #[test]
1170    fn python_tool_and_settings() {
1171        let m = parse_user(json!({
1172            "id": "text-stats", "name": "Text stats", "permissions": {"network": true},
1173            "tools": [{"name": "count_words", "description": "Count words.", "params": {"type": "object", "properties": {"file": {"type": "string"}}}, "run": "tools/count.py", "title": "Counted {file}"}],
1174            "settings": [{"key": "API_KEY", "label": "API key", "secret": true, "type": "directory", "multiple": true, "default": ["/a", "/b"]}],
1175            "starters": [{"title": "Go", "prompt": "/go", "file": true}],
1176            "prompt": "x".repeat(700),
1177        }))
1178        .unwrap();
1179        assert!(m.permissions.network);
1180        assert_eq!(m.tools[0].kind, ToolKind::Script("tools/count.py".into()));
1181        assert_eq!(m.tools[0].title.as_deref(), Some("Counted {file}"));
1182        assert_eq!(m.tools[0].access, None);
1183        let s = &m.settings[0];
1184        assert!(s.secret && s.multiple && !s.required);
1185        assert_eq!(s.kind, SettingKind::Directory);
1186        assert_eq!(s.default.as_deref(), Some("/a\n/b"));
1187        assert!(m.starters[0].file);
1188        assert_eq!(m.prompt.map(|p| p.chars().count()), Some(600));
1189    }
1190
1191    #[test]
1192    fn backends_and_requirements() {
1193        let m = parse_user(json!({"id": "a", "name": "x", "backend": "backend.py", "tools": [{"name": "t", "description": "d", "run": "backend"}],
1194            "python": {"requirements": ["requests>=2", "./wheels/w.whl"]}}))
1195        .unwrap();
1196        assert_eq!(m.backend.unwrap().run, BackendRun::Python("backend.py".into()));
1197        assert_eq!(m.tools[0].kind, ToolKind::Backend);
1198        assert_eq!(m.requirements, ["requests>=2", "./wheels/w.whl"]);
1199        let m =
1200            parse_user(json!({"id": "a", "name": "x", "backend": "b.py", "tools": [{"name": "t", "description": "d", "run": "backend", "access": "read"}]}))
1201                .unwrap();
1202        assert_eq!(m.tools[0].access, Some(byob_tools::Access::Read));
1203        let bad =
1204            parse_user(json!({"id": "a", "name": "x", "backend": "b.py", "tools": [{"name": "t", "description": "d", "run": "backend", "access": "root"}]}));
1205        assert_eq!(bad.unwrap_err(), "byob-plugin.json: tools[0].access must be one of read, write, network, execute");
1206        // Must-confirm calls: every call, or only when some arguments are given.
1207        let tool = |confirm: Value| json!({"id": "a", "name": "x", "backend": "b.py", "tools": [{"name": "t", "description": "d", "run": "backend", "confirm": confirm}]});
1208        let always = parse_user(tool(json!(true))).unwrap().tools[0].confirm.clone().unwrap();
1209        assert_eq!(always.applies(&json!({})).as_deref(), Some("this call must be confirmed"));
1210        let when = parse_user(tool(json!({"warning": "emails people", "when": ["to", "cc"]}))).unwrap().tools[0].confirm.clone().unwrap();
1211        assert_eq!(when.applies(&json!({"to": [], "cc": " ", "x": 1})), None);
1212        assert_eq!(when.applies(&json!({"cc": ["b@x"]})).as_deref(), Some("emails people"));
1213        assert_eq!(parse_user(tool(json!(false))).unwrap().tools[0].confirm, None);
1214        assert!(parse_user(tool(json!({"when": "to"}))).unwrap_err().contains("confirm.when must list argument names"));
1215        assert_eq!(parse_user(tool(json!(" sends email "))).unwrap().tools[0].confirm.clone().unwrap().applies(&json!({})).as_deref(), Some("sends email"));
1216        assert!(parse_user(tool(json!(3))).unwrap_err().contains("confirm must be true, a warning"));
1217        let m = parse_user(json!({"id": "a", "name": "x", "backend": {"command": "node", "args": ["s.js"], "events": ["turn_finished"]}})).unwrap();
1218        let b = m.backend.unwrap();
1219        assert_eq!((b.run, b.args, b.events), (BackendRun::Command("node".into()), vec!["s.js".to_string()], vec!["turn_finished".to_string()]));
1220        let cases = [
1221            (
1222                json!({"id": "a", "name": "x", "tools": [{"name": "t", "description": "d", "run": "backend"}]}),
1223                "byob-plugin.json: tools[0].run is \"backend\" but the plugin has no \"backend\"",
1224            ),
1225            (
1226                json!({"id": "a", "name": "x", "backend": "../b.py"}),
1227                "byob-plugin.json: backend.run must be a path inside the plugin folder, like \"backend.py\"",
1228            ),
1229            (
1230                json!({"id": "a", "name": "x", "backend": {"run": "b.py", "command": "node"}}),
1231                "byob-plugin.json: backend needs \"run\" (a Python script), \"command\" (a program) or \"native\" (a backend built into byob), one of them",
1232            ),
1233            (
1234                json!({"id": "a", "name": "x", "provides": "Spread Sheets"}),
1235                "byob-plugin.json: provides must be lowercase letters, digits and dashes, like \"spreadsheets\"",
1236            ),
1237            (
1238                json!({"id": "a", "name": "x", "backend": {"run": "b.py", "events": ["tick"]}}),
1239                "byob-plugin.json: backend.events: \"tick\" isn't an engine event; use chat_opened, turn_started, turn_finished, step_done, artifact_updated",
1240            ),
1241            (
1242                json!({"id": "a", "name": "x", "python": {"requirements": ["--index-url=http://x"]}}),
1243                "byob-plugin.json: python.requirements[0] must be one package spec like \"requests>=2.31\"",
1244            ),
1245        ];
1246        for (raw, want) in cases {
1247            assert_eq!(parse_user(raw.clone()).unwrap_err(), want, "for {raw}");
1248        }
1249    }
1250
1251    #[test]
1252    fn native_backends_default_switches_and_provides() {
1253        let m = parse_user(json!({"id": "a", "name": "x", "backend": {"native": "sheets"}, "enabled": false, "provides": "spreadsheets"})).unwrap();
1254        assert_eq!(m.backend.unwrap().run, BackendRun::Native("sheets".into()));
1255        assert!(!m.on_by_default);
1256        assert_eq!(m.provides.as_deref(), Some("spreadsheets"));
1257        let m = parse_user(json!({"id": "a", "name": "x"})).unwrap();
1258        assert!(m.on_by_default && m.provides.is_none());
1259    }
1260
1261    #[test]
1262    fn views_at_render_sites() {
1263        let m = parse_user(json!({"id": "a", "name": "Todo", "backend": "b.py",
1264            "views": [{"site": "pane", "id": "list", "title": "To do", "icon": "list"}, {"site": "status"}]}))
1265        .unwrap();
1266        assert_eq!(m.views[0], ManifestView { id: "list".into(), site: "pane".into(), title: "To do".into(), icon: "list".into(), html: String::new() });
1267        assert_eq!((m.views[1].id.as_str(), m.views[1].title.as_str()), ("status", "Todo"), "id defaults to the site, title to the name");
1268        let cases = [
1269            (
1270                json!({"id": "a", "name": "x", "views": [{"site": "pane"}]}),
1271                "byob-plugin.json: views are drawn by the plugin's backend: add \"backend\": \"backend.py\" (or give the view an \"html\" file)",
1272            ),
1273            (
1274                json!({"id": "a", "name": "x", "views": [{"site": "status", "html": "ui/x.html"}]}),
1275                "byob-plugin.json: views[0]: a web view (\"html\") goes in the pane or on a card",
1276            ),
1277            (
1278                json!({"id": "a", "name": "x", "views": [{"site": "pane", "html": "../x.html"}]}),
1279                "byob-plugin.json: views[0].html must be a file inside the plugin folder, like \"ui/index.html\"",
1280            ),
1281            (
1282                json!({"id": "a", "name": "x", "backend": "b.py", "views": [{"site": "sidebar"}]}),
1283                "byob-plugin.json: views[0].site must be one of pane, settings, status, card, artifact",
1284            ),
1285            (
1286                json!({"id": "a", "name": "x", "backend": "b.py", "views": [{"site": "pane", "id": "My View"}]}),
1287                "byob-plugin.json: views[0].id must be lowercase letters, digits, _ or -, like \"todo\"",
1288            ),
1289            (
1290                json!({"id": "a", "name": "x", "backend": "b.py", "views": [{"site": "pane"}, {"site": "pane"}]}),
1291                "byob-plugin.json: views[1].id \"pane\" is used twice",
1292            ),
1293        ];
1294        for (raw, want) in cases {
1295            assert_eq!(parse_user(raw.clone()).unwrap_err(), want, "for {raw}");
1296        }
1297        // A web view needs no backend; `web:<file>` renderers name a page of the plugin.
1298        let m = parse_user(json!({"id": "a", "name": "x", "views": [{"site": "pane", "id": "p", "html": "ui/index.html"}],
1299            "renderers": [{"type": "text/csv", "renderer": "web:ui/csv.html"}]}))
1300        .unwrap();
1301        assert_eq!((m.views[0].html.as_str(), m.renderers[0].renderer.as_str()), ("ui/index.html", "web:ui/csv.html"));
1302        assert!(parse_user(json!({"id": "a", "name": "x", "renderers": [{"type": "text/csv", "renderer": "web:/etc/x.html"}]})).is_err());
1303    }
1304
1305    #[test]
1306    fn native_bindings() {
1307        // Any plugin may name in-tree parts (a fork of a bundled plugin keeps them); the
1308        // engine resolves the ids and reports unknown ones as problems.
1309        let raw = json!({"id": "code", "name": "Code", "locked": true,
1310            "tools": [{"native": "run_python"}, {"native": "install_packages", "optional": true}],
1311            "artifact_types": [{"type": "text/html", "native": "html_app", "guidance": "guidance.md"},
1312                               {"type": "Text/CSV", "suffixes": [".CSV"], "label": "table", "pick": "a table → .csv"}],
1313            "renderers": [{"type": "text/html", "renderer": "web"}, {"type": "image/*", "renderer": "native:image"}],
1314            "system_prompt": {"native": "artifacts"}});
1315        let m = parse_user(raw).unwrap();
1316        assert_eq!(m.tools[0].name, "run_python");
1317        assert_eq!(m.tools[0].kind, ToolKind::Native("run_python".into()));
1318        assert!(!m.tools[0].optional && m.tools[1].optional && m.locked);
1319        assert_eq!(m.artifact_types[0].native.as_deref(), Some("html_app"));
1320        assert_eq!(m.artifact_types[0].guidance.as_deref(), Some("guidance.md"));
1321        assert_eq!((m.artifact_types[1].content_type.as_str(), m.artifact_types[1].suffixes.clone()), ("text/csv", Some(vec![".csv".to_string()])));
1322        assert_eq!(m.renderers[1], ManifestRenderer { content_type: "image/*".into(), renderer: "native:image".into() });
1323        assert_eq!(m.system_prompt, Some(PromptSource::Native("artifacts".into())));
1324        let m = parse_user(json!({"id": "a", "name": "x", "content_types": [{"type": "text/x", "native": "text"}], "system_prompt": "prompt.md"})).unwrap();
1325        assert_eq!(m.artifact_types[0].native.as_deref(), Some("text"));
1326        assert_eq!(m.system_prompt, Some(PromptSource::File("prompt.md".into())));
1327    }
1328
1329    #[test]
1330    fn server_configs() {
1331        let m = parse_user(json!({"id": "a", "name": "x", "mcpServers": {
1332            "mem": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-memory"], "env": {"K": "${API_KEY}"}},
1333            "lin": {"url": "https://mcp.linear.app/mcp", "headers": {"Authorization": "Bearer ${API_KEY}"}, "oauth": {"clientId": "c"}},
1334            "old": {"url": "https://example.com/sse", "type": "sse"},
1335        }}))
1336        .unwrap();
1337        assert!(m.mcp_servers["mem"].is_local());
1338        assert!(!m.mcp_servers["lin"].is_local());
1339        assert!(matches!(&m.mcp_servers["old"], McpServerConfig::Url { sse: true, .. }));
1340        let vals = BTreeMap::from([("API_KEY".to_string(), "abc".to_string())]);
1341        match m.mcp_servers["lin"].with_settings(&vals) {
1342            McpServerConfig::Url { headers, oauth, .. } => {
1343                assert_eq!(headers["Authorization"], "Bearer abc");
1344                assert_eq!(oauth.map(|o| o.client_id).as_deref(), Some("c"));
1345            }
1346            other => panic!("{other:?}"),
1347        }
1348    }
1349
1350    #[test]
1351    fn placeholders() {
1352        let vals = BTreeMap::from([("A".to_string(), "1".to_string())]);
1353        assert_eq!(fill_placeholders("${A}-${B}-${A", &vals), "1-${B}-${A");
1354        assert_eq!(fill_placeholders("ünï${A}cødé", &vals), "ünï1cødé");
1355    }
1356
1357    #[test]
1358    fn inside_paths() {
1359        assert!(path_inside("tools/x.py") && path_inside("./a/../b.py"));
1360        assert!(!path_inside("../x") && !path_inside("a/../../x") && !path_inside("/x") && !path_inside("C:\\x") && !path_inside("\\x"));
1361    }
1362
1363    #[test]
1364    fn hooks_and_environments() {
1365        let parse = |extra: serde_json::Value| {
1366            let mut m = serde_json::json!({"id": "p", "name": "P"});
1367            for (k, v) in extra.as_object().unwrap() {
1368                m[k] = v.clone();
1369            }
1370            parse_manifest(&m, ParseOptions::default())
1371        };
1372        let m = parse(serde_json::json!({"backend": "b.py", "hooks": [
1373            {"event": "before_tool_call", "priority": -5, "tools": ["delete_file"], "fail": "closed", "timeout_ms": 500},
1374            {"event": "before_request", "native": "compaction"}
1375        ]}))
1376        .unwrap();
1377        assert_eq!(
1378            m.hooks[0],
1379            ManifestHook {
1380                event: "before_tool_call".into(),
1381                priority: -5,
1382                native: None,
1383                timeout_ms: 500,
1384                fail_closed: true,
1385                tools: vec!["delete_file".into()]
1386            }
1387        );
1388        assert_eq!((m.hooks[1].priority, m.hooks[1].timeout_ms, m.hooks[1].fail_closed), (DEFAULT_HOOK_PRIORITY, HOOK_TIMEOUT_MS, false));
1389        let err = |v| parse(v).unwrap_err().message;
1390        assert!(err(serde_json::json!({"hooks": [{"event": "before_lunch", "native": "x"}]})).contains("event must be one of"));
1391        assert!(err(serde_json::json!({"hooks": [{"event": "on_turn_end"}]})).contains("add \"backend\""));
1392        assert!(err(serde_json::json!({"hooks": [{"event": "before_prompt", "native": "x", "tools": ["a"]}]})).contains("tools only applies"));
1393        assert!(err(serde_json::json!({"backend": "b.py", "hooks": [{"event": "on_turn_end", "timeout_ms": 0}]})).contains("timeout_ms"));
1394        assert!(err(serde_json::json!({"backend": "b.py", "hooks": [{"event": "on_turn_end", "fail": "maybe"}]})).contains("\"open\" or \"closed\""));
1395        let m = parse(serde_json::json!({"environments": [
1396            {"native": "nix"},
1397            {"id": "conda", "detect": ["environment.yml"], "command": "python3", "args": ["env.py"]}
1398        ]}))
1399        .unwrap();
1400        assert_eq!((m.environments[0].id.as_str(), m.environments[0].native.as_deref()), ("nix", Some("nix")));
1401        assert_eq!((m.environments[1].label.as_str(), m.environments[1].command.as_deref()), ("conda environment", Some("python3")));
1402        assert!(err(serde_json::json!({"environments": [{"id": "x", "detect": ["a"]}]})).contains("needs \"native\""));
1403        assert!(err(serde_json::json!({"environments": [{"id": "x", "detect": ["../a"], "command": "c"}]})).contains("inside the workspace"));
1404    }
1405}