objectiveai_cli/filesystem/plugins/manifest.rs
1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use crate::filesystem::tools::Exec;
5
6/// Declarative metadata a plugin ships with itself. The wire shape is
7/// JSON; the on-disk convention (sibling file, embedded resource,
8/// `--manifest` flag, …) is deliberately out of scope of this struct
9/// and will be settled in a follow-up.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
11#[schemars(rename = "filesystem.plugins.Manifest")]
12pub struct Manifest {
13 /// One-line description of what the plugin does. Surfaced in
14 /// listings and the plugin's `--help`-equivalent UI.
15 pub description: String,
16
17 /// Version string. Semver convention is recommended but not
18 /// enforced — the host just displays whatever's here.
19 pub version: String,
20
21 /// GitHub `<owner>` segment of the source repo. Authors write
22 /// their canonical owner here; the installer overwrites this
23 /// field with whatever owner it was actually installed from (so
24 /// forks land on disk with the fork's owner, not the upstream's).
25 pub owner: String,
26
27 /// Author or authors of the plugin. Free-form string.
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 #[schemars(extend("omitempty" = true))]
30 pub author: Option<String>,
31
32 /// Homepage or repository URL.
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 #[schemars(extend("omitempty" = true))]
35 pub homepage: Option<String>,
36
37 /// SPDX license identifier (or any string).
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 #[schemars(extend("omitempty" = true))]
40 pub license: Option<String>,
41
42 /// Per-OS exec command for the plugin's cli side — the same shape
43 /// tools use. The current platform's vector is the program plus
44 /// its leading arguments; the run's args are appended, and the
45 /// whole thing runs with CWD = `<plugin dir>/cli/`. Relative
46 /// program paths resolve against that folder; bare names keep
47 /// PATH-lookup semantics. Empty when the plugin is viewer-only.
48 #[serde(default, skip_serializing_if = "Exec::is_empty")]
49 #[schemars(extend("omitempty" = true))]
50 pub exec: Exec,
51
52 /// GitHub-release asset filename for the plugin's cli bundle — a
53 /// `.zip` whose contents are extracted into `<plugin dir>/cli/`
54 /// at install time (the [`Self::exec`] working directory), the
55 /// same way [`Self::viewer_zip`] extracts to `viewer/`. Absent
56 /// when the exec needs nothing on disk (e.g. it invokes a
57 /// PATH-resolved program).
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 #[schemars(extend("omitempty" = true))]
60 pub cli_zip: Option<String>,
61
62 /// GitHub-release asset filename for the plugin's viewer UI
63 /// bundle (a `.zip` whose root contains `index.html` plus
64 /// assets). When absent, the plugin has no viewer tab from this
65 /// source. Mutually exclusive with [`Self::viewer_url`] —
66 /// validated by [`Self::validate`].
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 #[schemars(extend("omitempty" = true))]
69 pub viewer_zip: Option<String>,
70
71 /// Remote URL the viewer's iframe loads directly, instead of an
72 /// on-disk bundle from [`Self::viewer_zip`]. The full URL is used
73 /// as the iframe `src=` verbatim — query string, path, port,
74 /// fragment all pass through. Must use `https://`, or `http://`
75 /// targeting `localhost` / `127.0.0.1` (development only).
76 ///
77 /// Mutually exclusive with [`Self::viewer_zip`]. [`Self::viewer_routes`]
78 /// and [`Self::mobile_ready`] apply to remote-URL viewers the same
79 /// way they apply to zip-bundled viewers — the embedded axum
80 /// server still hosts the declared routes; the iframe still
81 /// receives the same postMessage protocol regardless of where
82 /// its HTML/JS loaded from.
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 #[schemars(extend("omitempty" = true))]
85 pub viewer_url: Option<String>,
86
87 /// HTTP routes the viewer exposes on behalf of this plugin.
88 /// Each entry registers a handler at
89 /// `/plugin/<repository>/<path>` on the viewer's embedded axum
90 /// server; a hit emits a `PluginRequest { type, value }` event
91 /// to the React frontend, which dispatches to the plugin's
92 /// iframe via the postMessage bridge.
93 #[serde(default, skip_serializing_if = "Vec::is_empty")]
94 pub viewer_routes: Vec<ViewerRoute>,
95
96 /// Plugin author opts in to mobile viewer support by setting
97 /// this. Mobile viewer builds only surface plugins with this
98 /// flag true — mobile has no local backend binary, so plugin
99 /// UIs that require a backend will misbehave unless their
100 /// authors specifically design for "no-backend" mode. Defaults
101 /// to false (desktop-only).
102 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
103 pub mobile_ready: bool,
104
105 /// MCP servers the plugin wants the host to expose. Each entry
106 /// has a `name` (the identifier agents reference via
107 /// [`objectiveai_sdk::agent::ClientObjectiveaiMcpPluginEntry::mcp_servers`])
108 /// plus the same `url` + `authorization` shape
109 /// [`objectiveai_sdk::agent::McpServer`] uses. Auth-requiring entries flag
110 /// `authorization = true`; credentials are resolved by the host
111 /// (env vars / config), not the manifest.
112 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 #[schemars(extend("omitempty" = true))]
114 pub mcp_servers: Vec<McpServer>,
115}
116
117impl Manifest {
118 /// Whether this plugin presents a viewer tab in the host. True
119 /// iff either viewer source field — [`Self::viewer_zip`] or
120 /// [`Self::viewer_url`] — is set.
121 pub fn has_viewer(&self) -> bool {
122 self.viewer_zip.is_some() || self.viewer_url.is_some()
123 }
124
125 /// LLM-visible tool name. See
126 /// [`objectiveai_sdk::agent::materialize_tool_name`].
127 pub fn tool_name(&self, name: &str) -> String {
128 objectiveai_sdk::agent::materialize_tool_name(&self.owner, name, &self.version)
129 }
130
131 /// Validate fields that can't be enforced by serde alone:
132 /// `viewer_zip` and `viewer_url` are mutually exclusive, and
133 /// `viewer_url` (when present) must be `https://` or `http://`
134 /// targeting `localhost` / `127.0.0.1`. Called at every parse
135 /// boundary (remote-fetched install, on-disk read) so a broken
136 /// manifest can't sneak through.
137 pub fn validate(&self) -> Result<(), &'static str> {
138 if self.viewer_zip.is_some() && self.viewer_url.is_some() {
139 return Err("viewer_zip and viewer_url are mutually exclusive");
140 }
141 if let Some(url) = self.viewer_url.as_deref() {
142 validate_viewer_url(url)?;
143 }
144 // Each MCP-server entry: non-empty name + url; the list as a
145 // whole must have no `name` duplicates (since agents reference
146 // by name) AND no `url` duplicates (no point declaring two
147 // entries pointing at the same upstream).
148 for entry in &self.mcp_servers {
149 if entry.name.is_empty() {
150 return Err("mcp_servers[i].name cannot be empty");
151 }
152 if entry.url.is_empty() {
153 return Err("mcp_servers[i].url cannot be empty");
154 }
155 }
156 for (i, a) in self.mcp_servers.iter().enumerate() {
157 for b in &self.mcp_servers[i + 1..] {
158 if a.name == b.name {
159 return Err("mcp_servers contains duplicate name");
160 }
161 if a.url == b.url {
162 return Err("mcp_servers contains duplicate url");
163 }
164 }
165 }
166 Ok(())
167 }
168}
169
170/// Allow `https://*`. Allow `http://` only when the host is
171/// `localhost` or `127.0.0.1` (development). Reject everything else
172/// — raw http on a public hostname inside a Tauri WebView is a
173/// footgun (plaintext, MITM-able, mixed-content-blocked by the
174/// browser engine in most cases).
175///
176/// Dependency-free: a couple of `starts_with` / split checks beat
177/// pulling the full `url` crate for one validation. Doesn't handle
178/// IPv6 brackets or punycode — neither matters for the localhost
179/// allow-list.
180fn validate_viewer_url(url: &str) -> Result<(), &'static str> {
181 let url = url.trim();
182 if url.is_empty() {
183 return Err("viewer_url cannot be empty");
184 }
185 if url.starts_with("https://") {
186 return Ok(());
187 }
188 if let Some(rest) = url.strip_prefix("http://") {
189 // Host ends at the first '/', ':', '?', '#', or EOF.
190 let host_end = rest
191 .find(|c: char| matches!(c, '/' | ':' | '?' | '#'))
192 .unwrap_or(rest.len());
193 let host = &rest[..host_end];
194 if host == "localhost" || host == "127.0.0.1" {
195 return Ok(());
196 }
197 return Err(
198 "viewer_url with http:// scheme is only allowed for localhost or 127.0.0.1",
199 );
200 }
201 Err("viewer_url must use https:// or http://localhost / http://127.0.0.1")
202}
203
204/// MCP server entry inside [`Manifest::mcp_servers`]. Same `url` +
205/// `authorization` semantics as [`objectiveai_sdk::agent::McpServer`] plus a
206/// `name` field that agent declarations reference (via
207/// [`objectiveai_sdk::agent::ClientObjectiveaiMcpPluginEntry::mcp_servers`]).
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
209#[schemars(rename = "filesystem.plugins.McpServer")]
210pub struct McpServer {
211 /// Author-chosen identifier. Unique per plugin manifest. Agents
212 /// declare which subset of the plugin's MCP servers they want
213 /// exposed by listing names here.
214 pub name: String,
215 /// Upstream MCP server URL.
216 pub url: String,
217 /// Whether the host should attach an `Authorization` header
218 /// when dialing this upstream. Credentials are resolved by the
219 /// host (env vars / config), not the manifest.
220 #[serde(default)]
221 pub authorization: bool,
222}
223
224/// One HTTP route a plugin's viewer registers on the host viewer's
225/// embedded axum server. The full path served is
226/// `/plugin/<repository>/<self.path>`; on a hit, the body is
227/// JSON-decoded and forwarded as a `PluginRequest { type: self.type,
228/// value: body }` event to the frontend.
229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
230#[schemars(rename = "filesystem.plugins.ViewerRoute")]
231pub struct ViewerRoute {
232 /// Path relative to the plugin's namespace. Must start with `/`;
233 /// the host prepends `/plugin/<repository>` before registering.
234 pub path: String,
235
236 /// HTTP method this route handles. Methods other than the listed
237 /// five aren't supported (and don't appear in plugin practice).
238 pub method: HttpMethod,
239
240 /// String tag forwarded to the plugin's iframe as the `type`
241 /// field of the resulting `PluginRequest`. Plugin authors pick
242 /// any value they want; the host doesn't interpret it.
243 #[serde(rename = "type")]
244 pub r#type: String,
245}
246
247/// HTTP methods supported by [`ViewerRoute`]. Serializes as upper-case
248/// (`"GET"`, `"POST"`, …) on the wire.
249#[derive(
250 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema,
251)]
252#[schemars(rename = "filesystem.plugins.HttpMethod")]
253#[serde(rename_all = "UPPERCASE")]
254pub enum HttpMethod {
255 Get,
256 Post,
257 Put,
258 Patch,
259 Delete,
260}
261
262/// A [`Manifest`] enriched with the plugin's identifying `name` and
263/// the `source` it was loaded from. Used when listing or describing
264/// installed plugins, where the bare manifest fields are not enough
265/// to identify which plugin they belong to or where they came from.
266///
267/// `name` sits before the manifest body; `source` sits after. The
268/// `manifest` field is `#[serde(flatten)]`-ed so the wire shape is
269/// one flat JSON object — `serde_json`'s `preserve_order` feature
270/// keeps the declared field order, so consumers see `name` first
271/// and `source` last.
272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
273#[schemars(rename = "filesystem.plugins.ManifestWithNameAndSource")]
274pub struct ManifestWithNameAndSource {
275 /// The plugin's identifier — the filename it lives under in the
276 /// plugins directory (e.g. `psyops` for `~/.objectiveai/plugins/psyops`).
277 pub name: String,
278 #[serde(flatten)]
279 pub manifest: Manifest,
280 /// Where this manifest came from — e.g. an absolute filesystem path,
281 /// a URL, or a registry reference. Free-form string; the host
282 /// just displays it.
283 pub source: String,
284}
285
286impl ManifestWithNameAndSource {
287 /// LLM-visible tool name. See [`Manifest::tool_name`] — this
288 /// helper supplies the `name` field automatically.
289 pub fn tool_name(&self) -> String {
290 self.manifest.tool_name(&self.name)
291 }
292}
293
294// Typed conversion to the SDK's bare-naked wire shape. Lets the
295// `command::plugins::{get, list}` leaves yield SDK `ResponseManifest`
296// items without round-tripping through `serde_json::Value`. The
297// inner type parallels (`ViewerRoute`/`ResponseViewerRoute`,
298// `McpServer`/`ResponseMcpServer`, `HttpMethod`/`ResponseHttpMethod`)
299// are field-identical — collapsing them into shared types is a
300// separate cleanup pass. `exec` is the SDK type already.
301impl From<ManifestWithNameAndSource>
302 for objectiveai_sdk::cli::command::plugins::get::ResponseManifest
303{
304 fn from(m: ManifestWithNameAndSource) -> Self {
305 use objectiveai_sdk::cli::command::plugins::get::{
306 ResponseHttpMethod, ResponseManifest, ResponseMcpServer,
307 ResponseViewerRoute,
308 };
309 let manifest = m.manifest;
310 ResponseManifest {
311 name: m.name,
312 description: manifest.description,
313 version: manifest.version,
314 owner: manifest.owner,
315 author: manifest.author,
316 homepage: manifest.homepage,
317 license: manifest.license,
318 exec: manifest.exec,
319 cli_zip: manifest.cli_zip,
320 viewer_zip: manifest.viewer_zip,
321 viewer_url: manifest.viewer_url,
322 viewer_routes: manifest
323 .viewer_routes
324 .into_iter()
325 .map(|r| ResponseViewerRoute {
326 path: r.path,
327 method: match r.method {
328 HttpMethod::Get => ResponseHttpMethod::Get,
329 HttpMethod::Post => ResponseHttpMethod::Post,
330 HttpMethod::Put => ResponseHttpMethod::Put,
331 HttpMethod::Patch => ResponseHttpMethod::Patch,
332 HttpMethod::Delete => ResponseHttpMethod::Delete,
333 },
334 r#type: r.r#type,
335 })
336 .collect(),
337 mobile_ready: manifest.mobile_ready,
338 mcp_servers: manifest
339 .mcp_servers
340 .into_iter()
341 .map(|s| ResponseMcpServer {
342 name: s.name,
343 url: s.url,
344 authorization: s.authorization,
345 })
346 .collect(),
347 source: m.source,
348 }
349 }
350}