Skip to main content

haloforge_plugin_api/
manifest.rs

1use serde::{Deserialize, Serialize};
2use crate::permissions::Permission;
3
4/// Full parsed plugin manifest (from manifest.json inside .hfpkg).
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct PluginManifest {
7    pub id: String,
8    pub name: String,
9    pub version: String,
10    pub description: String,
11    #[serde(default)]
12    pub long_description: Option<String>,
13    pub author: String,
14    #[serde(default)]
15    pub author_url: Option<String>,
16    #[serde(default)]
17    pub homepage: Option<String>,
18    #[serde(default)]
19    pub license: Option<String>,
20    #[serde(default)]
21    pub keywords: Vec<String>,
22    #[serde(default)]
23    pub icon: Option<String>,
24
25    pub compatibility: CompatibilitySpec,
26
27    /// Which capability levels this plugin uses (e.g. [1, 4]).
28    pub capability_levels: Vec<CapabilityLevel>,
29
30    /// Per-level integration configuration.
31    #[serde(default)]
32    pub integration: IntegrationConfig,
33
34    /// Declarative host window policy for plugin routes and file/resource handlers.
35    #[serde(default)]
36    pub window: WindowPolicyConfig,
37
38    /// Entry points for native library and frontend bundle.
39    #[serde(default)]
40    pub entry: EntryConfig,
41
42    /// Other plugin IDs this plugin depends on.
43    #[serde(default)]
44    pub dependencies: Vec<PluginDependency>,
45
46    /// Declared permissions (checked at install time and enforced at runtime).
47    #[serde(default)]
48    pub permissions: Vec<Permission>,
49
50    /// Declarative access to stable host-side capability groups.
51    /// These should match the host hooks used from `@haloforge/plugin-sdk`.
52    #[serde(default)]
53    pub host_capabilities: Vec<HostCapability>,
54
55    /// JSON Schema for plugin settings (auto-rendered in Plugin Manager).
56    #[serde(default)]
57    pub settings_schema: Option<serde_json::Value>,
58
59    /// IPC commands this plugin registers (informational, for documentation).
60    #[serde(default)]
61    pub commands: Vec<CommandDeclaration>,
62
63    /// SHA-256 checksum of the .hfpkg file. Required for published plugins.
64    #[serde(default)]
65    pub checksum: Option<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct CompatibilitySpec {
70    pub min_app_version: String,
71    #[serde(default)]
72    pub min_host_api_version: Option<String>,
73    #[serde(default)]
74    pub max_app_version: Option<String>,
75    #[serde(default = "all_platforms")]
76    pub platforms: Vec<String>,
77}
78
79fn all_platforms() -> Vec<String> {
80    vec!["windows".into(), "macos".into(), "linux".into()]
81}
82
83/// Capability level integer constants (matching the design doc).
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(from = "u8", into = "u8")]
86pub enum CapabilityLevel {
87    /// Level 0 — Top-level module (same tier as DevKit/AIChat).
88    Module = 0,
89    /// Level 1 — Feature inside an existing module.
90    ModuleFeature = 1,
91    /// Level 2 — UI slot injection / extension.
92    UiExtension = 2,
93    /// Level 3 — AI assistant registration.
94    AiAssistant = 3,
95    /// Level 4 — Headless service / backend extension.
96    Service = 4,
97}
98
99/// Stable, documented host capability groups for black-box-compatible plugins.
100#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum HostCapability {
103    Navigation,
104    AppState,
105    FileIntents,
106    FileDialogs,
107    #[serde(rename = "aichat")]
108    AiChat,
109    EnterpriseGateway,
110    DeepLinks,
111    ThemeRead,
112    EventSubscribe,
113}
114
115impl HostCapability {
116    pub fn as_str(&self) -> &'static str {
117        match self {
118            Self::Navigation => "navigation",
119            Self::AppState => "app_state",
120            Self::FileIntents => "file_intents",
121            Self::FileDialogs => "file_dialogs",
122            Self::AiChat => "aichat",
123            Self::EnterpriseGateway => "enterprise_gateway",
124            Self::DeepLinks => "deep_links",
125            Self::ThemeRead => "theme_read",
126            Self::EventSubscribe => "event_subscribe",
127        }
128    }
129}
130
131impl From<u8> for CapabilityLevel {
132    fn from(v: u8) -> Self {
133        match v {
134            0 => Self::Module,
135            1 => Self::ModuleFeature,
136            2 => Self::UiExtension,
137            3 => Self::AiAssistant,
138            4 => Self::Service,
139            _ => Self::Service,
140        }
141    }
142}
143
144impl From<CapabilityLevel> for u8 {
145    fn from(l: CapabilityLevel) -> u8 {
146        l as u8
147    }
148}
149
150/// Integration configuration block — one sub-block per declared level.
151#[derive(Debug, Clone, Default, Serialize, Deserialize)]
152pub struct IntegrationConfig {
153    #[serde(default)]
154    pub level0: Option<Level0Config>,
155    #[serde(default)]
156    pub level1: Option<Level1Config>,
157    #[serde(default)]
158    pub level2: Option<Level2Config>,
159    #[serde(default)]
160    pub level3: Option<Level3Config>,
161    #[serde(default)]
162    pub level4: Option<Level4Config>,
163}
164
165/// Declarative window behavior requested by a plugin.
166///
167/// The host owns actual window creation and decides whether a request is allowed,
168/// but these fields let a plugin describe how its routes and resources should be
169/// opened when invoked from menus, deeplinks, file associations, or other plugins.
170#[derive(Debug, Clone, Default, Serialize, Deserialize)]
171pub struct WindowPolicyConfig {
172    /// Preferred host role for new windows: "main", "document", "agent", "chat",
173    /// "devkit", "settings", or a future host-recognized role.
174    #[serde(default)]
175    pub preferred_role: Option<String>,
176    /// Default open behavior for plugin routes: "smart", "current", "new_window",
177    /// "reuse_existing", or "reuse_or_new".
178    #[serde(default)]
179    pub default_open_mode: Option<String>,
180    /// Default reuse key: "plugin", "route", "resource", or "none".
181    #[serde(default)]
182    pub reuse_key: Option<String>,
183    /// Whether this plugin may have more than one window at the same time.
184    /// Omitted means the host default is used.
185    #[serde(default)]
186    pub allow_multiple: Option<bool>,
187    /// File/resource handlers declared by this plugin.
188    #[serde(default)]
189    pub document_handlers: Vec<DocumentHandlerConfig>,
190}
191
192/// File or resource entry point that can be routed into a plugin panel.
193#[derive(Debug, Clone, Default, Serialize, Deserialize)]
194pub struct DocumentHandlerConfig {
195    /// Stable handler id, unique within the plugin. Defaults to the route when omitted.
196    #[serde(default)]
197    pub id: Option<String>,
198    /// User-facing handler label, used in menus and open-with UI.
199    #[serde(default)]
200    pub label: Option<String>,
201    /// Lowercase extensions including the leading dot, for example [".md"].
202    #[serde(default)]
203    pub extensions: Vec<String>,
204    /// MIME types handled by this plugin.
205    #[serde(default)]
206    pub mime_types: Vec<String>,
207    /// Plugin route opened for this resource.
208    pub route: String,
209    /// Query parameter that receives the resource path/URI. Defaults to "path".
210    #[serde(default)]
211    pub resource_param: Option<String>,
212    /// Handler-specific open behavior; falls back to the plugin window policy.
213    #[serde(default)]
214    pub open_mode: Option<String>,
215    /// Handler-specific reuse key; falls back to the plugin window policy.
216    #[serde(default)]
217    pub reuse_key: Option<String>,
218    /// Handler-specific preferred role; falls back to the plugin window policy.
219    #[serde(default)]
220    pub preferred_role: Option<String>,
221    /// Handler-specific multiple-window allowance; falls back to the plugin policy.
222    #[serde(default)]
223    pub allow_multiple: Option<bool>,
224}
225
226/// Level 0 — The plugin adds a new top-level module to the sidebar.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct Level0Config {
229    /// Unique module ID (must not collide with "devkit", "aichat", "settings").
230    pub module_id: String,
231    pub module_label: String,
232    /// Lucide icon name.
233    pub module_icon: String,
234    /// "main" = above the settings divider; "bottom" = below it.
235    #[serde(default = "default_sidebar_position")]
236    pub sidebar_position: String,
237    /// Lower = higher up. Defaults to 100.
238    #[serde(default = "default_sidebar_order")]
239    pub sidebar_order: u32,
240    /// Path inside the package to the JS bundle for this module's panel.
241    pub panel_entry: String,
242}
243
244fn default_sidebar_position() -> String { "main".into() }
245fn default_sidebar_order() -> u32 { 100 }
246
247/// Level 1 — The plugin adds a feature tab to an existing module.
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct Level1Config {
250    /// Target module: "devkit", "aichat", or a plugin module_id.
251    pub parent_module: String,
252    /// Unique tab ID within the parent module.
253    pub tab_id: String,
254    pub tab_label: String,
255    /// Lucide icon name.
256    pub tab_icon: String,
257    /// "after:snippet" | "before:summary" | "index:5"
258    #[serde(default)]
259    pub tab_position: Option<String>,
260    /// Path inside the package to the JS bundle for this tab's panel.
261    pub panel_entry: String,
262}
263
264/// Level 2 — The plugin injects into UI slots.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct Level2Config {
267    /// Which slots the plugin injects into (see UI Slot Reference in the design doc).
268    pub slots: Vec<String>,
269}
270
271/// Level 3 — The plugin registers an AI assistant.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct Level3Config {
274    pub assistant_id: String,
275    pub assistant_name: String,
276    #[serde(default)]
277    pub assistant_icon: Option<String>,
278    #[serde(default)]
279    pub assistant_description: Option<String>,
280    /// Path inside the package to the system prompt markdown file.
281    pub system_prompt_file: String,
282    /// Optional: auto-select a specific model_config_id for this assistant.
283    #[serde(default)]
284    pub preferred_model: Option<String>,
285}
286
287/// Level 4 — The plugin registers backend services / workflow step types.
288#[derive(Debug, Clone, Default, Serialize, Deserialize)]
289pub struct Level4Config {
290    /// Step type IDs this plugin registers (e.g. ["p4_sync", "p4_submit"]).
291    #[serde(default)]
292    pub workflow_step_types: Vec<String>,
293}
294
295/// Native library paths per platform.
296#[derive(Debug, Clone, Default, Serialize, Deserialize)]
297pub struct EntryConfig {
298    #[serde(default)]
299    pub native: Option<NativeEntry>,
300    #[serde(default)]
301    pub frontend: Option<String>,
302    #[serde(default)]
303    pub frontend_styles: Option<String>,
304}
305
306#[derive(Debug, Clone, Default, Serialize, Deserialize)]
307pub struct NativeEntry {
308    #[serde(default)]
309    pub macos_arm64: Option<String>,
310    #[serde(default)]
311    pub macos_x64: Option<String>,
312    #[serde(default)]
313    pub windows_x64: Option<String>,
314    #[serde(default)]
315    pub windows_arm64: Option<String>,
316    #[serde(default)]
317    pub linux_x64: Option<String>,
318    #[serde(default)]
319    pub linux_arm64: Option<String>,
320}
321
322impl NativeEntry {
323    /// Return the library path for the current platform/arch, if present.
324    pub fn for_current_platform(&self) -> Option<&str> {
325        #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
326        return self.macos_arm64.as_deref();
327
328        #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
329        return self.macos_x64.as_deref();
330
331        #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
332        return self.windows_x64.as_deref();
333
334        #[cfg(all(target_os = "windows", target_arch = "aarch64"))]
335        return self.windows_arm64.as_deref();
336
337        #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
338        return self.linux_x64.as_deref();
339
340        #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
341        return self.linux_arm64.as_deref();
342
343        #[allow(unreachable_code)]
344        None
345    }
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct PluginDependency {
350    pub id: String,
351    /// SemVer requirement string, e.g. ">=1.0.0".
352    pub version: String,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct CommandDeclaration {
357    pub id: String,
358    #[serde(default)]
359    pub description: Option<String>,
360}
361
362#[cfg(test)]
363mod tests {
364    use super::{HostCapability, PluginManifest};
365
366    #[test]
367    fn manifest_supports_public_host_api_fields() {
368        let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
369            "id": "dev.haloforge.example",
370            "name": "Example",
371            "version": "0.1.0",
372            "description": "Example plugin",
373            "author": "HaloForge Team",
374            "compatibility": {
375                "min_app_version": "0.1.0",
376                "min_host_api_version": "0.1.0",
377                "platforms": ["windows"]
378            },
379            "capability_levels": [2],
380            "host_capabilities": ["navigation", "aichat"],
381            "integration": {
382                "level2": { "slots": ["devkit.toolbar"] }
383            }
384        }))
385        .expect("manifest should deserialize");
386
387        assert_eq!(
388            manifest.compatibility.min_host_api_version.as_deref(),
389            Some("0.1.0")
390        );
391        assert_eq!(
392            manifest.host_capabilities,
393            vec![HostCapability::Navigation, HostCapability::AiChat]
394        );
395    }
396
397    #[test]
398    fn manifest_supports_window_policy_document_handlers() {
399        let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
400            "id": "dev.haloforge.markdown",
401            "name": "Markdown",
402            "version": "0.2.10",
403            "description": "Markdown plugin",
404            "author": "HaloForge Team",
405            "compatibility": {
406                "min_app_version": "0.7.0",
407                "min_host_api_version": "0.2.10",
408                "platforms": ["windows", "macos", "linux"]
409            },
410            "capability_levels": [0],
411            "host_capabilities": ["navigation", "file_intents"],
412            "integration": {
413                "level0": {
414                    "module_id": "markdown",
415                    "module_label": "Markdown",
416                    "module_icon": "FileText",
417                    "panel_entry": "app/dist/index.js"
418                }
419            },
420            "window": {
421                "preferred_role": "document",
422                "default_open_mode": "reuse_or_new",
423                "reuse_key": "resource",
424                "allow_multiple": true,
425                "document_handlers": [{
426                    "id": "markdown",
427                    "label": "Markdown",
428                    "extensions": [".md", ".markdown"],
429                    "mime_types": ["text/markdown"],
430                    "route": "/document",
431                    "resource_param": "path"
432                }]
433            }
434        }))
435        .expect("manifest should deserialize");
436
437        assert_eq!(manifest.window.preferred_role.as_deref(), Some("document"));
438        assert_eq!(manifest.window.default_open_mode.as_deref(), Some("reuse_or_new"));
439        assert_eq!(manifest.window.reuse_key.as_deref(), Some("resource"));
440        assert_eq!(manifest.window.allow_multiple, Some(true));
441        let handler = manifest
442            .window
443            .document_handlers
444            .first()
445            .expect("document handler should deserialize");
446        assert_eq!(handler.extensions, vec![".md", ".markdown"]);
447        assert_eq!(handler.mime_types, vec!["text/markdown"]);
448        assert_eq!(handler.route, "/document");
449        assert_eq!(handler.resource_param.as_deref(), Some("path"));
450    }
451
452    #[test]
453    fn host_capability_names_are_stable() {
454        assert_eq!(HostCapability::FileIntents.as_str(), "file_intents");
455        assert_eq!(HostCapability::FileDialogs.as_str(), "file_dialogs");
456        assert_eq!(HostCapability::DeepLinks.as_str(), "deep_links");
457        assert_eq!(HostCapability::ThemeRead.as_str(), "theme_read");
458    }
459}