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    /// Entry points for native library and frontend bundle.
35    #[serde(default)]
36    pub entry: EntryConfig,
37
38    /// Other plugin IDs this plugin depends on.
39    #[serde(default)]
40    pub dependencies: Vec<PluginDependency>,
41
42    /// Declared permissions (checked at install time and enforced at runtime).
43    #[serde(default)]
44    pub permissions: Vec<Permission>,
45
46    /// Declarative access to stable host-side capability groups.
47    /// These should match the host hooks used from `@haloforge/plugin-sdk`.
48    #[serde(default)]
49    pub host_capabilities: Vec<HostCapability>,
50
51    /// JSON Schema for plugin settings (auto-rendered in Plugin Manager).
52    #[serde(default)]
53    pub settings_schema: Option<serde_json::Value>,
54
55    /// IPC commands this plugin registers (informational, for documentation).
56    #[serde(default)]
57    pub commands: Vec<CommandDeclaration>,
58
59    /// SHA-256 checksum of the .hfpkg file. Required for published plugins.
60    #[serde(default)]
61    pub checksum: Option<String>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct CompatibilitySpec {
66    pub min_app_version: String,
67    #[serde(default)]
68    pub min_host_api_version: Option<String>,
69    #[serde(default)]
70    pub max_app_version: Option<String>,
71    #[serde(default = "all_platforms")]
72    pub platforms: Vec<String>,
73}
74
75fn all_platforms() -> Vec<String> {
76    vec!["windows".into(), "macos".into(), "linux".into()]
77}
78
79/// Capability level integer constants (matching the design doc).
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(from = "u8", into = "u8")]
82pub enum CapabilityLevel {
83    /// Level 0 — Top-level module (same tier as DevKit/AIChat).
84    Module = 0,
85    /// Level 1 — Feature inside an existing module.
86    ModuleFeature = 1,
87    /// Level 2 — UI slot injection / extension.
88    UiExtension = 2,
89    /// Level 3 — AI assistant registration.
90    AiAssistant = 3,
91    /// Level 4 — Headless service / backend extension.
92    Service = 4,
93}
94
95/// Stable, documented host capability groups for black-box-compatible plugins.
96#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum HostCapability {
99    Navigation,
100    AppState,
101    FileIntents,
102    FileDialogs,
103    #[serde(rename = "aichat")]
104    AiChat,
105    EnterpriseGateway,
106    DeepLinks,
107    ThemeRead,
108    EventSubscribe,
109}
110
111impl HostCapability {
112    pub fn as_str(&self) -> &'static str {
113        match self {
114            Self::Navigation => "navigation",
115            Self::AppState => "app_state",
116            Self::FileIntents => "file_intents",
117            Self::FileDialogs => "file_dialogs",
118            Self::AiChat => "aichat",
119            Self::EnterpriseGateway => "enterprise_gateway",
120            Self::DeepLinks => "deep_links",
121            Self::ThemeRead => "theme_read",
122            Self::EventSubscribe => "event_subscribe",
123        }
124    }
125}
126
127impl From<u8> for CapabilityLevel {
128    fn from(v: u8) -> Self {
129        match v {
130            0 => Self::Module,
131            1 => Self::ModuleFeature,
132            2 => Self::UiExtension,
133            3 => Self::AiAssistant,
134            4 => Self::Service,
135            _ => Self::Service,
136        }
137    }
138}
139
140impl From<CapabilityLevel> for u8 {
141    fn from(l: CapabilityLevel) -> u8 {
142        l as u8
143    }
144}
145
146/// Integration configuration block — one sub-block per declared level.
147#[derive(Debug, Clone, Default, Serialize, Deserialize)]
148pub struct IntegrationConfig {
149    #[serde(default)]
150    pub level0: Option<Level0Config>,
151    #[serde(default)]
152    pub level1: Option<Level1Config>,
153    #[serde(default)]
154    pub level2: Option<Level2Config>,
155    #[serde(default)]
156    pub level3: Option<Level3Config>,
157    #[serde(default)]
158    pub level4: Option<Level4Config>,
159}
160
161/// Level 0 — The plugin adds a new top-level module to the sidebar.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct Level0Config {
164    /// Unique module ID (must not collide with "devkit", "aichat", "settings").
165    pub module_id: String,
166    pub module_label: String,
167    /// Lucide icon name.
168    pub module_icon: String,
169    /// "main" = above the settings divider; "bottom" = below it.
170    #[serde(default = "default_sidebar_position")]
171    pub sidebar_position: String,
172    /// Lower = higher up. Defaults to 100.
173    #[serde(default = "default_sidebar_order")]
174    pub sidebar_order: u32,
175    /// Path inside the package to the JS bundle for this module's panel.
176    pub panel_entry: String,
177}
178
179fn default_sidebar_position() -> String { "main".into() }
180fn default_sidebar_order() -> u32 { 100 }
181
182/// Level 1 — The plugin adds a feature tab to an existing module.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct Level1Config {
185    /// Target module: "devkit", "aichat", or a plugin module_id.
186    pub parent_module: String,
187    /// Unique tab ID within the parent module.
188    pub tab_id: String,
189    pub tab_label: String,
190    /// Lucide icon name.
191    pub tab_icon: String,
192    /// "after:snippet" | "before:summary" | "index:5"
193    #[serde(default)]
194    pub tab_position: Option<String>,
195    /// Path inside the package to the JS bundle for this tab's panel.
196    pub panel_entry: String,
197}
198
199/// Level 2 — The plugin injects into UI slots.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct Level2Config {
202    /// Which slots the plugin injects into (see UI Slot Reference in the design doc).
203    pub slots: Vec<String>,
204}
205
206/// Level 3 — The plugin registers an AI assistant.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct Level3Config {
209    pub assistant_id: String,
210    pub assistant_name: String,
211    #[serde(default)]
212    pub assistant_icon: Option<String>,
213    #[serde(default)]
214    pub assistant_description: Option<String>,
215    /// Path inside the package to the system prompt markdown file.
216    pub system_prompt_file: String,
217    /// Optional: auto-select a specific model_config_id for this assistant.
218    #[serde(default)]
219    pub preferred_model: Option<String>,
220}
221
222/// Level 4 — The plugin registers backend services / workflow step types.
223#[derive(Debug, Clone, Default, Serialize, Deserialize)]
224pub struct Level4Config {
225    /// Step type IDs this plugin registers (e.g. ["p4_sync", "p4_submit"]).
226    #[serde(default)]
227    pub workflow_step_types: Vec<String>,
228}
229
230/// Native library paths per platform.
231#[derive(Debug, Clone, Default, Serialize, Deserialize)]
232pub struct EntryConfig {
233    #[serde(default)]
234    pub native: Option<NativeEntry>,
235    #[serde(default)]
236    pub frontend: Option<String>,
237    #[serde(default)]
238    pub frontend_styles: Option<String>,
239}
240
241#[derive(Debug, Clone, Default, Serialize, Deserialize)]
242pub struct NativeEntry {
243    #[serde(default)]
244    pub macos_arm64: Option<String>,
245    #[serde(default)]
246    pub macos_x64: Option<String>,
247    #[serde(default)]
248    pub windows_x64: Option<String>,
249    #[serde(default)]
250    pub windows_arm64: Option<String>,
251    #[serde(default)]
252    pub linux_x64: Option<String>,
253    #[serde(default)]
254    pub linux_arm64: Option<String>,
255}
256
257impl NativeEntry {
258    /// Return the library path for the current platform/arch, if present.
259    pub fn for_current_platform(&self) -> Option<&str> {
260        #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
261        return self.macos_arm64.as_deref();
262
263        #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
264        return self.macos_x64.as_deref();
265
266        #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
267        return self.windows_x64.as_deref();
268
269        #[cfg(all(target_os = "windows", target_arch = "aarch64"))]
270        return self.windows_arm64.as_deref();
271
272        #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
273        return self.linux_x64.as_deref();
274
275        #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
276        return self.linux_arm64.as_deref();
277
278        #[allow(unreachable_code)]
279        None
280    }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct PluginDependency {
285    pub id: String,
286    /// SemVer requirement string, e.g. ">=1.0.0".
287    pub version: String,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct CommandDeclaration {
292    pub id: String,
293    #[serde(default)]
294    pub description: Option<String>,
295}
296
297#[cfg(test)]
298mod tests {
299    use super::{HostCapability, PluginManifest};
300
301    #[test]
302    fn manifest_supports_public_host_api_fields() {
303        let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
304            "id": "dev.haloforge.example",
305            "name": "Example",
306            "version": "0.1.0",
307            "description": "Example plugin",
308            "author": "HaloForge Team",
309            "compatibility": {
310                "min_app_version": "0.1.0",
311                "min_host_api_version": "0.1.0",
312                "platforms": ["windows"]
313            },
314            "capability_levels": [2],
315            "host_capabilities": ["navigation", "aichat"],
316            "integration": {
317                "level2": { "slots": ["devkit.toolbar"] }
318            }
319        }))
320        .expect("manifest should deserialize");
321
322        assert_eq!(
323            manifest.compatibility.min_host_api_version.as_deref(),
324            Some("0.1.0")
325        );
326        assert_eq!(
327            manifest.host_capabilities,
328            vec![HostCapability::Navigation, HostCapability::AiChat]
329        );
330    }
331
332    #[test]
333    fn host_capability_names_are_stable() {
334        assert_eq!(HostCapability::FileIntents.as_str(), "file_intents");
335        assert_eq!(HostCapability::FileDialogs.as_str(), "file_dialogs");
336        assert_eq!(HostCapability::DeepLinks.as_str(), "deep_links");
337        assert_eq!(HostCapability::ThemeRead.as_str(), "theme_read");
338    }
339}