objectiveai-cli 2.1.1

ObjectiveAI command-line interface and embeddable library
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use super::Platform;

/// Declarative metadata a plugin ships with itself. The wire shape is
/// JSON; the on-disk convention (sibling file, embedded resource,
/// `--manifest` flag, …) is deliberately out of scope of this struct
/// and will be settled in a follow-up.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(rename = "filesystem.plugins.Manifest")]
pub struct Manifest {
    /// One-line description of what the plugin does. Surfaced in
    /// listings and the plugin's `--help`-equivalent UI.
    pub description: String,

    /// Version string. Semver convention is recommended but not
    /// enforced — the host just displays whatever's here.
    pub version: String,

    /// GitHub `<owner>` segment of the source repo. Authors write
    /// their canonical owner here; the installer overwrites this
    /// field with whatever owner it was actually installed from (so
    /// forks land on disk with the fork's owner, not the upstream's).
    pub owner: String,

    /// Author or authors of the plugin. Free-form string.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[schemars(extend("omitempty" = true))]
    pub author: Option<String>,

    /// Homepage or repository URL.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[schemars(extend("omitempty" = true))]
    pub homepage: Option<String>,

    /// SPDX license identifier (or any string).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[schemars(extend("omitempty" = true))]
    pub license: Option<String>,

    /// Release-asset filename per platform — what the cli should
    /// download from the GitHub release tagged `v<version>` to install
    /// the plugin's binary on each platform. Values are filenames
    /// (e.g. `psyops-linux-x86_64`, `psyops-windows-x86_64.exe`), NOT
    /// URLs; the URL is composed from the repository + tag + asset
    /// name elsewhere.
    ///
    /// **Every platform field is optional.** Declare entries only for
    /// the platforms this plugin actually ships a binary for; absent
    /// platforms are simply not supported by this release. A plugin
    /// shipping only Linux x86_64 declares one entry; a plugin
    /// shipping all six declares six. All-None ↔ field omitted in
    /// the wire shape.
    #[serde(default, skip_serializing_if = "Binaries::is_empty")]
    #[schemars(extend("omitempty" = true))]
    pub binaries: Binaries,

    /// GitHub-release asset filename for the plugin's viewer UI
    /// bundle (a `.zip` whose root contains `index.html` plus
    /// assets). When absent, the plugin has no viewer tab from this
    /// source. Mutually exclusive with [`Self::viewer_url`] —
    /// validated by [`Self::validate`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[schemars(extend("omitempty" = true))]
    pub viewer_zip: Option<String>,

    /// Remote URL the viewer's iframe loads directly, instead of an
    /// on-disk bundle from [`Self::viewer_zip`]. The full URL is used
    /// as the iframe `src=` verbatim — query string, path, port,
    /// fragment all pass through. Must use `https://`, or `http://`
    /// targeting `localhost` / `127.0.0.1` (development only).
    ///
    /// Mutually exclusive with [`Self::viewer_zip`]. [`Self::viewer_routes`]
    /// and [`Self::mobile_ready`] apply to remote-URL viewers the same
    /// way they apply to zip-bundled viewers — the embedded axum
    /// server still hosts the declared routes; the iframe still
    /// receives the same postMessage protocol regardless of where
    /// its HTML/JS loaded from.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[schemars(extend("omitempty" = true))]
    pub viewer_url: Option<String>,

    /// HTTP routes the viewer exposes on behalf of this plugin.
    /// Each entry registers a handler at
    /// `/plugin/<repository>/<path>` on the viewer's embedded axum
    /// server; a hit emits a `PluginRequest { type, value }` event
    /// to the React frontend, which dispatches to the plugin's
    /// iframe via the postMessage bridge.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub viewer_routes: Vec<ViewerRoute>,

    /// Plugin author opts in to mobile viewer support by setting
    /// this. Mobile viewer builds only surface plugins with this
    /// flag true — mobile has no local backend binary, so plugin
    /// UIs that require a backend will misbehave unless their
    /// authors specifically design for "no-backend" mode. Defaults
    /// to false (desktop-only).
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub mobile_ready: bool,

    /// MCP servers the plugin wants the host to expose. Each entry
    /// has a `name` (the identifier agents reference via
    /// [`objectiveai_sdk::agent::ClientObjectiveaiMcpPluginEntry::mcp_servers`])
    /// plus the same `url` + `authorization` shape
    /// [`objectiveai_sdk::agent::McpServer`] uses. Auth-requiring entries flag
    /// `authorization = true`; credentials are resolved by the host
    /// (env vars / config), not the manifest.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    #[schemars(extend("omitempty" = true))]
    pub mcp_servers: Vec<McpServer>,
}

impl Manifest {
    /// Whether this plugin presents a viewer tab in the host. True
    /// iff either viewer source field — [`Self::viewer_zip`] or
    /// [`Self::viewer_url`] — is set.
    pub fn has_viewer(&self) -> bool {
        self.viewer_zip.is_some() || self.viewer_url.is_some()
    }

    /// LLM-visible tool name. See
    /// [`objectiveai_sdk::agent::materialize_tool_name`].
    pub fn tool_name(&self, name: &str) -> String {
        objectiveai_sdk::agent::materialize_tool_name(&self.owner, name, &self.version)
    }

    /// Validate fields that can't be enforced by serde alone:
    /// `viewer_zip` and `viewer_url` are mutually exclusive, and
    /// `viewer_url` (when present) must be `https://` or `http://`
    /// targeting `localhost` / `127.0.0.1`. Called at every parse
    /// boundary (remote-fetched install, on-disk read) so a broken
    /// manifest can't sneak through.
    pub fn validate(&self) -> Result<(), &'static str> {
        if self.viewer_zip.is_some() && self.viewer_url.is_some() {
            return Err("viewer_zip and viewer_url are mutually exclusive");
        }
        if let Some(url) = self.viewer_url.as_deref() {
            validate_viewer_url(url)?;
        }
        // Each MCP-server entry: non-empty name + url; the list as a
        // whole must have no `name` duplicates (since agents reference
        // by name) AND no `url` duplicates (no point declaring two
        // entries pointing at the same upstream).
        for entry in &self.mcp_servers {
            if entry.name.is_empty() {
                return Err("mcp_servers[i].name cannot be empty");
            }
            if entry.url.is_empty() {
                return Err("mcp_servers[i].url cannot be empty");
            }
        }
        for (i, a) in self.mcp_servers.iter().enumerate() {
            for b in &self.mcp_servers[i + 1..] {
                if a.name == b.name {
                    return Err("mcp_servers contains duplicate name");
                }
                if a.url == b.url {
                    return Err("mcp_servers contains duplicate url");
                }
            }
        }
        Ok(())
    }
}

/// Allow `https://*`. Allow `http://` only when the host is
/// `localhost` or `127.0.0.1` (development). Reject everything else
/// — raw http on a public hostname inside a Tauri WebView is a
/// footgun (plaintext, MITM-able, mixed-content-blocked by the
/// browser engine in most cases).
///
/// Dependency-free: a couple of `starts_with` / split checks beat
/// pulling the full `url` crate for one validation. Doesn't handle
/// IPv6 brackets or punycode — neither matters for the localhost
/// allow-list.
fn validate_viewer_url(url: &str) -> Result<(), &'static str> {
    let url = url.trim();
    if url.is_empty() {
        return Err("viewer_url cannot be empty");
    }
    if url.starts_with("https://") {
        return Ok(());
    }
    if let Some(rest) = url.strip_prefix("http://") {
        // Host ends at the first '/', ':', '?', '#', or EOF.
        let host_end = rest
            .find(|c: char| matches!(c, '/' | ':' | '?' | '#'))
            .unwrap_or(rest.len());
        let host = &rest[..host_end];
        if host == "localhost" || host == "127.0.0.1" {
            return Ok(());
        }
        return Err(
            "viewer_url with http:// scheme is only allowed for localhost or 127.0.0.1",
        );
    }
    Err("viewer_url must use https:// or http://localhost / http://127.0.0.1")
}

/// Release-asset filename per platform. Every field is optional;
/// declare only the platforms a plugin ships for. The wire shape is
/// a flat JSON object — absent platforms are omitted, never
/// serialised as `null`.
///
/// Exposes a [`Self::get`] method that takes a [`Platform`] enum so
/// callers can read the asset filename for the current host without
/// pattern-matching the field set themselves.
#[derive(
    Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq,
)]
#[schemars(rename = "filesystem.plugins.Binaries")]
pub struct Binaries {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[schemars(extend("omitempty" = true))]
    pub linux_x86_64: Option<String>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[schemars(extend("omitempty" = true))]
    pub linux_aarch64: Option<String>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[schemars(extend("omitempty" = true))]
    pub windows_x86_64: Option<String>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[schemars(extend("omitempty" = true))]
    pub windows_aarch64: Option<String>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[schemars(extend("omitempty" = true))]
    pub macos_x86_64: Option<String>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[schemars(extend("omitempty" = true))]
    pub macos_aarch64: Option<String>,
}

impl Binaries {
    /// Asset filename for the given platform, if declared.
    pub fn get(&self, platform: Platform) -> Option<&String> {
        match platform {
            Platform::LinuxX86_64 => self.linux_x86_64.as_ref(),
            Platform::LinuxAarch64 => self.linux_aarch64.as_ref(),
            Platform::WindowsX86_64 => self.windows_x86_64.as_ref(),
            Platform::WindowsAarch64 => self.windows_aarch64.as_ref(),
            Platform::MacosX86_64 => self.macos_x86_64.as_ref(),
            Platform::MacosAarch64 => self.macos_aarch64.as_ref(),
        }
    }

    /// True when no platform has an asset declared.
    pub fn is_empty(&self) -> bool {
        self.linux_x86_64.is_none()
            && self.linux_aarch64.is_none()
            && self.windows_x86_64.is_none()
            && self.windows_aarch64.is_none()
            && self.macos_x86_64.is_none()
            && self.macos_aarch64.is_none()
    }

    /// Count of declared platforms.
    pub fn len(&self) -> usize {
        [
            &self.linux_x86_64,
            &self.linux_aarch64,
            &self.windows_x86_64,
            &self.windows_aarch64,
            &self.macos_x86_64,
            &self.macos_aarch64,
        ]
        .iter()
        .filter(|o| o.is_some())
        .count()
    }
}

/// MCP server entry inside [`Manifest::mcp_servers`]. Same `url` +
/// `authorization` semantics as [`objectiveai_sdk::agent::McpServer`] plus a
/// `name` field that agent declarations reference (via
/// [`objectiveai_sdk::agent::ClientObjectiveaiMcpPluginEntry::mcp_servers`]).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[schemars(rename = "filesystem.plugins.McpServer")]
pub struct McpServer {
    /// Author-chosen identifier. Unique per plugin manifest. Agents
    /// declare which subset of the plugin's MCP servers they want
    /// exposed by listing names here.
    pub name: String,
    /// Upstream MCP server URL.
    pub url: String,
    /// Whether the host should attach an `Authorization` header
    /// when dialing this upstream. Credentials are resolved by the
    /// host (env vars / config), not the manifest.
    #[serde(default)]
    pub authorization: bool,
}

/// One HTTP route a plugin's viewer registers on the host viewer's
/// embedded axum server. The full path served is
/// `/plugin/<repository>/<self.path>`; on a hit, the body is
/// JSON-decoded and forwarded as a `PluginRequest { type: self.type,
/// value: body }` event to the frontend.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(rename = "filesystem.plugins.ViewerRoute")]
pub struct ViewerRoute {
    /// Path relative to the plugin's namespace. Must start with `/`;
    /// the host prepends `/plugin/<repository>` before registering.
    pub path: String,

    /// HTTP method this route handles. Methods other than the listed
    /// five aren't supported (and don't appear in plugin practice).
    pub method: HttpMethod,

    /// String tag forwarded to the plugin's iframe as the `type`
    /// field of the resulting `PluginRequest`. Plugin authors pick
    /// any value they want; the host doesn't interpret it.
    #[serde(rename = "type")]
    pub r#type: String,
}

/// HTTP methods supported by [`ViewerRoute`]. Serializes as upper-case
/// (`"GET"`, `"POST"`, …) on the wire.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema,
)]
#[schemars(rename = "filesystem.plugins.HttpMethod")]
#[serde(rename_all = "UPPERCASE")]
pub enum HttpMethod {
    Get,
    Post,
    Put,
    Patch,
    Delete,
}

/// A [`Manifest`] enriched with the plugin's identifying `name` and
/// the `source` it was loaded from. Used when listing or describing
/// installed plugins, where the bare manifest fields are not enough
/// to identify which plugin they belong to or where they came from.
///
/// `name` sits before the manifest body; `source` sits after. The
/// `manifest` field is `#[serde(flatten)]`-ed so the wire shape is
/// one flat JSON object — `serde_json`'s `preserve_order` feature
/// keeps the declared field order, so consumers see `name` first
/// and `source` last.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(rename = "filesystem.plugins.ManifestWithNameAndSource")]
pub struct ManifestWithNameAndSource {
    /// The plugin's identifier — the filename it lives under in the
    /// plugins directory (e.g. `psyops` for `~/.objectiveai/plugins/psyops`).
    pub name: String,
    #[serde(flatten)]
    pub manifest: Manifest,
    /// Where this manifest came from — e.g. an absolute filesystem path,
    /// a URL, or a registry reference. Free-form string; the host
    /// just displays it.
    pub source: String,
}

impl ManifestWithNameAndSource {
    /// LLM-visible tool name. See [`Manifest::tool_name`] — this
    /// helper supplies the `name` field automatically.
    pub fn tool_name(&self) -> String {
        self.manifest.tool_name(&self.name)
    }
}

// Typed conversion to the SDK's bare-naked wire shape. Lets the
// `command::plugins::{get, list}` leaves yield SDK `ResponseManifest`
// items without round-tripping through `serde_json::Value`. The
// inner type parallels (`Binaries`/`ResponseBinaries`,
// `ViewerRoute`/`ResponseViewerRoute`, `McpServer`/`ResponseMcpServer`,
// `HttpMethod`/`ResponseHttpMethod`) are field-identical — collapsing
// them into shared types is a separate cleanup pass.
impl From<ManifestWithNameAndSource>
    for objectiveai_sdk::cli::command::plugins::get::ResponseManifest
{
    fn from(m: ManifestWithNameAndSource) -> Self {
        use objectiveai_sdk::cli::command::plugins::get::{
            ResponseBinaries, ResponseHttpMethod, ResponseManifest, ResponseMcpServer,
            ResponseViewerRoute,
        };
        let manifest = m.manifest;
        ResponseManifest {
            name: m.name,
            description: manifest.description,
            version: manifest.version,
            owner: manifest.owner,
            author: manifest.author,
            homepage: manifest.homepage,
            license: manifest.license,
            binaries: ResponseBinaries {
                linux_x86_64: manifest.binaries.linux_x86_64,
                linux_aarch64: manifest.binaries.linux_aarch64,
                windows_x86_64: manifest.binaries.windows_x86_64,
                windows_aarch64: manifest.binaries.windows_aarch64,
                macos_x86_64: manifest.binaries.macos_x86_64,
                macos_aarch64: manifest.binaries.macos_aarch64,
            },
            viewer_zip: manifest.viewer_zip,
            viewer_url: manifest.viewer_url,
            viewer_routes: manifest
                .viewer_routes
                .into_iter()
                .map(|r| ResponseViewerRoute {
                    path: r.path,
                    method: match r.method {
                        HttpMethod::Get => ResponseHttpMethod::Get,
                        HttpMethod::Post => ResponseHttpMethod::Post,
                        HttpMethod::Put => ResponseHttpMethod::Put,
                        HttpMethod::Patch => ResponseHttpMethod::Patch,
                        HttpMethod::Delete => ResponseHttpMethod::Delete,
                    },
                    r#type: r.r#type,
                })
                .collect(),
            mobile_ready: manifest.mobile_ready,
            mcp_servers: manifest
                .mcp_servers
                .into_iter()
                .map(|s| ResponseMcpServer {
                    name: s.name,
                    url: s.url,
                    authorization: s.authorization,
                })
                .collect(),
            source: m.source,
        }
    }
}