Skip to main content

agent_engine/skills/
manifest.rs

1//! Parse .synaps-plugin/plugin.json and .synaps-plugin/marketplace.json.
2
3use serde::Deserialize;
4
5use super::keybinds::ManifestKeybind;
6use super::plugin_index::PluginIndexEntry;
7
8#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
9pub struct PluginCompatibility {
10    #[serde(default)]
11    pub synaps: Option<String>,
12    #[serde(default)]
13    pub extension_protocol: Option<String>,
14}
15
16#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
17#[serde(untagged)]
18pub enum ManifestCommand {
19    Shell(ManifestShellCommand),
20    ExtensionTool(ManifestExtensionToolCommand),
21    SkillPrompt(ManifestSkillPromptCommand),
22    Interactive(ManifestInteractiveCommand),
23}
24
25#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
26pub struct ManifestShellCommand {
27    pub name: String,
28    #[serde(default)]
29    pub description: Option<String>,
30    pub command: String,
31    #[serde(default)]
32    pub args: Vec<String>,
33}
34
35#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
36pub struct ManifestExtensionToolCommand {
37    pub name: String,
38    #[serde(default)]
39    pub description: Option<String>,
40    pub tool: String,
41    #[serde(default)]
42    pub input: serde_json::Value,
43}
44
45#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
46pub struct ManifestSkillPromptCommand {
47    pub name: String,
48    #[serde(default)]
49    pub description: Option<String>,
50    pub skill: String,
51    pub prompt: String,
52}
53
54#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
55pub struct ManifestInteractiveCommand {
56    pub name: String,
57    #[serde(default)]
58    pub description: Option<String>,
59    /// Route this slash command to the plugin extension's `command.invoke` RPC.
60    pub interactive: bool,
61    #[serde(default)]
62    pub subcommands: Vec<String>,
63}
64
65#[derive(Debug, Clone, Deserialize)]
66pub struct PluginManifest {
67    pub name: String,
68    #[serde(default)]
69    pub version: Option<String>,
70    #[serde(default)]
71    pub description: Option<String>,
72    #[serde(default)]
73    pub keybinds: Vec<ManifestKeybind>,
74    #[serde(default)]
75    pub compatibility: Option<PluginCompatibility>,
76    #[serde(default)]
77    pub commands: Vec<ManifestCommand>,
78    #[serde(default)]
79    pub extension: Option<crate::extensions::manifest::ExtensionManifest>,
80    #[serde(default, alias = "help")]
81    pub help_entries: Vec<crate::help::HelpEntry>,
82    #[serde(default)]
83    pub provides: Option<PluginProvides>,
84    /// Plugin-declared Settings categories (Path B Phase 4). Each plugin
85    /// may contribute one or more categories to the `/settings` modal,
86    /// each with declarative `text`/`cycler`/`picker` fields or a
87    /// plugin-rendered `custom` editor (JSON-RPC `settings.editor.*`).
88    #[serde(default)]
89    pub settings: Option<ManifestSettings>,
90}
91
92/// Container for plugin-declared settings categories.
93///
94/// JSON shape:
95/// ```jsonc
96/// "settings": {
97///   "category": [
98///     { "id": "capture", "label": "Sample", "fields": [ ... ] }
99///   ]
100/// }
101/// ```
102/// The TOML equivalent (`[[settings.category]]`) deserializes through the
103/// `category` alias. The plural Rust field name is preferred internally.
104#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
105pub struct ManifestSettings {
106    #[serde(default, alias = "category")]
107    pub categories: Vec<ManifestSettingsCategory>,
108}
109
110#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
111pub struct ManifestSettingsCategory {
112    pub id: String,
113    pub label: String,
114    #[serde(default)]
115    pub fields: Vec<ManifestSettingsField>,
116}
117
118#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
119pub struct ManifestSettingsField {
120    pub key: String,
121    pub label: String,
122    pub editor: ManifestEditorKind,
123    /// Discrete options for `cycler` editors. Ignored otherwise.
124    #[serde(default)]
125    pub options: Vec<String>,
126    #[serde(default)]
127    pub help: Option<String>,
128    /// Optional default value seeded into the plugin's config namespace
129    /// when the field is first read. Type-erased JSON; consumer decides
130    /// how to interpret based on `editor`.
131    #[serde(default)]
132    pub default: Option<serde_json::Value>,
133    /// `true` for fields whose editor is `text` and accepts only numeric
134    /// input. Mirrors `EditorKind::Text { numeric }` in the core schema.
135    #[serde(default)]
136    pub numeric: bool,
137}
138
139#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
140#[serde(rename_all = "lowercase")]
141pub enum ManifestEditorKind {
142    /// Free-text input (optionally numeric — see `numeric`).
143    Text,
144    /// Discrete-option cycler — uses `options`.
145    Cycler,
146    /// Generic picker. Options are supplied by the plugin at editor-open
147    /// time via the `settings.editor.*` JSON-RPC contract.
148    Picker,
149    /// Plugin-rendered overlay using `settings.editor.open` /
150    /// `settings.editor.render` / `settings.editor.key` /
151    /// `settings.editor.commit`. See
152    /// `src/extensions/settings_editor.rs` for the typed payloads.
153    Custom,
154}
155
156/// Plugin-provided capabilities consumed by Synaps CLI core.
157///
158/// Currently only one slot is recognised: `sidecar`. A plugin
159/// advertises a long-running sidecar binary by setting
160/// `provides.sidecar.command`; the integration layer in
161/// `src/sidecar/` discovers and supervises it.
162///
163#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
164pub struct PluginProvides {
165    #[serde(default)]
166    pub sidecar: Option<SidecarManifest>,
167}
168
169/// Sidecar binary that Synaps CLI launches as a long-running plugin
170/// process. Plugin semantics live outside core; any process that fits the
171/// trigger-driven JSONL streaming contract can use this abstraction.
172///
173/// `command` is resolved relative to the plugin root unless absolute.
174/// `protocol_version` is matched against the line-JSON protocol version
175/// understood by `src/sidecar/protocol.rs`.
176#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
177pub struct SidecarManifest {
178    pub command: String,
179    #[serde(default)]
180    pub setup: Option<String>,
181    #[serde(default = "default_sidecar_protocol_version")]
182    pub protocol_version: u16,
183    #[serde(default)]
184    pub model: Option<SidecarModel>,
185    /// Optional plugin-claimed lifecycle UX. When set, core
186    /// auto-registers `<command> toggle` and `<command> status` and
187    /// uses `display_name` for the pill / status / errors. When
188    /// unset, the plugin is reachable via the generic `/sidecar`
189    /// fallback (ambiguity-aware: errors when 2+ unclaimed plugins
190    /// are loaded).
191    #[serde(default)]
192    pub lifecycle: Option<SidecarLifecycle>,
193}
194
195/// Plugin-claimed lifecycle UX for a sidecar. See [`SidecarManifest::lifecycle`].
196///
197/// The plugin chooses how its lifecycle commands and settings appear
198/// to the user. Core uses `display_name` for the pill, status line,
199/// and error messages; auto-registers `<command> toggle/status` as
200/// addressable slash commands; injects a virtual toggle-key field
201/// into `settings_category` (when given).
202///
203/// `importance` controls pill ordering when multiple sidecars are
204/// loaded simultaneously: higher = leftmost. Defaults to `0`. Cap at
205/// `-100..=100`; values outside that range are clamped at parse time.
206#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
207pub struct SidecarLifecycle {
208    /// Slash-command name that owns this sidecar's lifecycle.
209    /// Together with `toggle`/`status` subcommands forms e.g.
210    /// the plugin's toggle command.
211    pub command: String,
212    /// Settings category id (matches a `settings.categories[].id` in
213    /// the plugin manifest) that should host the virtual toggle-key
214    /// field. When `None`, no settings injection happens.
215    #[serde(default)]
216    pub settings_category: Option<String>,
217    /// Display name shown in the pill, status line, and `/extensions`
218    /// (e.g. "Sample", "OCR"). Defaults to `command` when `None`.
219    #[serde(default)]
220    pub display_name: Option<String>,
221    /// Pill-ordering hint (-100..=100, default 0). Higher = leftmost.
222    #[serde(default, deserialize_with = "deserialize_clamped_importance")]
223    pub importance: i32,
224}
225
226impl SidecarLifecycle {
227    /// Resolved display name: `display_name` if set, else `command`.
228    pub fn effective_display_name(&self) -> &str {
229        self.display_name.as_deref().unwrap_or(&self.command)
230    }
231}
232
233/// Clamp `importance` to the documented range `-100..=100`.
234fn deserialize_clamped_importance<'de, D>(d: D) -> Result<i32, D::Error>
235where
236    D: serde::Deserializer<'de>,
237{
238    let raw = i32::deserialize(d)?;
239    Ok(raw.clamp(-100, 100))
240}
241
242fn default_sidecar_protocol_version() -> u16 {
243    1
244}
245
246#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
247pub struct SidecarModel {
248    #[serde(default)]
249    pub default_path: Option<String>,
250    #[serde(default)]
251    pub required: bool,
252}
253
254#[derive(Debug, Clone, Deserialize)]
255pub struct MarketplaceManifest {
256    pub name: String,
257    #[serde(default)]
258    pub version: Option<String>,
259    #[serde(default)]
260    pub description: Option<String>,
261    #[serde(default)]
262    pub categories: Vec<String>,
263    #[serde(default)]
264    pub keywords: Vec<String>,
265    #[serde(default)]
266    pub trust: Option<MarketplaceTrust>,
267    pub plugins: Vec<MarketplacePluginEntry>,
268}
269
270#[derive(Debug, Clone, Deserialize)]
271pub struct MarketplaceTrust {
272    #[serde(default)]
273    pub publisher: Option<String>,
274    #[serde(default)]
275    pub homepage: Option<String>,
276}
277
278#[derive(Debug, Clone, Deserialize)]
279pub struct MarketplacePluginEntry {
280    pub name: String,
281    #[serde(default)]
282    pub source: Option<String>,
283    #[serde(default)]
284    pub version: Option<String>,
285    #[serde(default)]
286    pub description: Option<String>,
287    #[serde(default)]
288    pub category: Option<String>,
289    #[serde(default)]
290    pub keywords: Vec<String>,
291    #[serde(default)]
292    pub license: Option<String>,
293    #[serde(default)]
294    pub index: Option<PluginIndexEntry>,
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    /// Sibling-repo manifest pin: the live `sample-sidecar` plugin manifest
302    /// in `synaps-skills` must round-trip through this crate's parser
303    /// with the Phase 8 lifecycle block + keybinds wired in. If this
304    /// test fails because the file moved, update or delete the path —
305    /// don't loosen the assertions.
306    #[test]
307    fn local_capture_plugin_json_parses_with_phase8_lifecycle_and_keybinds() {
308        let path = "/home/jr/Projects/Maha-Media/.worktrees/\
309            synaps-skills-local-sidecar-plugin-commands-tasks/local-sidecar-plugin/\
310            .synaps-plugin/plugin.json";
311        let Ok(json) = std::fs::read_to_string(path) else {
312            // Sibling worktree absent — skip rather than fail in CI/other
313            // environments. The pin is best-effort local-dev guard.
314            eprintln!("skip: {path} not found");
315            return;
316        };
317        let m: PluginManifest =
318            serde_json::from_str(&json).expect("sample-sidecar manifest must deserialize");
319        assert_eq!(m.name, "sample-sidecar");
320
321        let provides = m.provides.expect("provides present");
322        let sidecar = provides.sidecar.expect("sidecar present");
323        assert_eq!(sidecar.command, "bin/synaps-sidecar-plugin");
324        let lc = sidecar.lifecycle.expect("lifecycle present");
325        assert_eq!(lc.command, "capture");
326        assert_eq!(lc.settings_category.as_deref(), Some("capture"));
327        assert_eq!(lc.effective_display_name(), "Sample");
328        assert_eq!(lc.importance, 50);
329
330        assert_eq!(m.keybinds.len(), 1);
331        let kb = &m.keybinds[0];
332        assert_eq!(kb.key, "C-Space");
333        assert_eq!(kb.action, "slash_command");
334        assert_eq!(kb.command.as_deref(), Some("capture toggle"));
335    }
336
337    #[test]
338    fn plugin_manifest_minimal() {
339        let json = r#"{"name":"web-tools"}"#;
340        let m: PluginManifest = serde_json::from_str(json).unwrap();
341        assert_eq!(m.name, "web-tools");
342        assert_eq!(m.version, None);
343        assert_eq!(m.description, None);
344        assert!(m.commands.is_empty());
345        assert!(m.help_entries.is_empty());
346        assert!(m.compatibility.is_none());
347    }
348
349    #[test]
350    fn plugin_manifest_full_with_extras() {
351        let json = r#"{
352            "name": "web-tools",
353            "version": "1.0.0",
354            "description": "Web tools",
355            "author": {"name": "x"},
356            "repository": "https://...",
357            "license": "MIT",
358            "compatibility": {
359                "synaps": ">=0.1.0",
360                "extension_protocol": "1"
361            },
362            "unknown_field": 42
363        }"#;
364        let m: PluginManifest = serde_json::from_str(json).unwrap();
365        assert_eq!(m.name, "web-tools");
366        assert_eq!(m.version.as_deref(), Some("1.0.0"));
367        assert_eq!(m.description.as_deref(), Some("Web tools"));
368        assert_eq!(
369            m.compatibility.as_ref().unwrap().synaps.as_deref(),
370            Some(">=0.1.0")
371        );
372        assert_eq!(
373            m.compatibility
374                .as_ref()
375                .unwrap()
376                .extension_protocol
377                .as_deref(),
378            Some("1")
379        );
380    }
381
382    #[test]
383    fn plugin_manifest_parses_help_entries_with_usage_examples() {
384        let json = r#"{
385            "name": "web-tools",
386            "help_entries": [
387                {
388                    "id": "web-search-help",
389                    "command": "/web:search",
390                    "title": "Web Search",
391                    "summary": "Search the web from a plugin.",
392                    "category": "Plugin",
393                    "topic": "Command",
394                    "protected": false,
395                    "common": false,
396                    "keywords": ["web", "search"],
397                    "usage": "/web:search <query>",
398                    "examples": [
399                        {
400                            "command": "/web:search rust serde",
401                            "description": "Search for Rust serde resources."
402                        }
403                    ]
404                }
405            ]
406        }"#;
407        let m: PluginManifest = serde_json::from_str(json).unwrap();
408        assert_eq!(m.help_entries.len(), 1);
409        assert_eq!(m.help_entries[0].command, "/web:search");
410        assert_eq!(
411            m.help_entries[0].usage.as_deref(),
412            Some("/web:search <query>")
413        );
414        assert_eq!(
415            m.help_entries[0].examples[0].command,
416            "/web:search rust serde"
417        );
418    }
419
420    #[test]
421    fn plugin_manifest_accepts_help_alias_for_help_entries() {
422        let json = r#"{
423            "name": "web-tools",
424            "help": [
425                {
426                    "id": "web-help",
427                    "command": "/help web",
428                    "title": "Web Tools",
429                    "summary": "Use web tools from the plugin.",
430                    "category": "Plugin",
431                    "topic": "Branch",
432                    "protected": false,
433                    "common": false
434                }
435            ]
436        }"#;
437        let m: PluginManifest = serde_json::from_str(json).unwrap();
438        assert_eq!(m.help_entries.len(), 1);
439        assert_eq!(m.help_entries[0].command, "/help web");
440        assert_eq!(m.help_entries[0].topic, crate::help::HelpTopicKind::Branch);
441    }
442
443    #[test]
444    fn plugin_manifest_can_add_command_and_matching_help_entries_together() {
445        let json = r#"{
446            "name": "dev-tools",
447            "commands": [
448                {
449                    "name": "lint",
450                    "description": "Run lint",
451                    "command": "bash",
452                    "args": ["scripts/lint.sh"]
453                }
454            ],
455            "help_entries": [
456                {
457                    "id": "dev-lint-help",
458                    "command": "/dev-tools:lint",
459                    "title": "Lint",
460                    "summary": "Run plugin lint checks.",
461                    "category": "Plugin",
462                    "topic": "Command",
463                    "protected": false,
464                    "common": false,
465                    "usage": "/dev-tools:lint"
466                }
467            ]
468        }"#;
469        let m: PluginManifest = serde_json::from_str(json).unwrap();
470        assert_eq!(m.commands.len(), 1);
471        assert_eq!(m.help_entries.len(), 1);
472        assert_eq!(m.help_entries[0].command, "/dev-tools:lint");
473    }
474
475    #[test]
476    fn plugin_manifest_help_entries_default_boilerplate_fields() {
477        let json = r#"{
478            "name": "dev-tools",
479            "help": [
480                {
481                    "id": "dev-lint-help",
482                    "command": "/dev-tools:lint",
483                    "title": "Lint",
484                    "summary": "Run plugin lint checks."
485                }
486            ]
487        }"#;
488        let m: PluginManifest = serde_json::from_str(json).unwrap();
489        assert_eq!(m.help_entries.len(), 1);
490        assert_eq!(m.help_entries[0].category, "Plugin");
491        assert_eq!(m.help_entries[0].topic, crate::help::HelpTopicKind::Command);
492        assert!(!m.help_entries[0].protected);
493        assert!(!m.help_entries[0].common);
494    }
495
496    #[test]
497    fn plugin_manifest_rejects_legacy_legacy_sidecar_field() {
498        let json = r#"{
499            "name": "legacy",
500            "provides": {
501                "legacy_sidecar": {
502                    "command": "bin/old",
503                    "protocol_version": 1
504                }
505            }
506        }"#;
507        let m: PluginManifest = serde_json::from_str(json).unwrap();
508        let provides = m.provides.expect("provides block should deserialize");
509        assert!(
510            provides.sidecar.is_none(),
511            "legacy provides.legacy_sidecar must not populate provides.sidecar"
512        );
513    }
514
515    #[test]
516    fn plugin_manifest_parses_provides_sidecar_canonical() {
517        let json = r#"{
518            "name": "local-ocr",
519            "provides": {
520                "sidecar": {
521                    "command": "bin/ocr-sidecar",
522                    "protocol_version": 1
523                }
524            }
525        }"#;
526        let m: PluginManifest = serde_json::from_str(json).unwrap();
527        let provides = m.provides.expect("provides should deserialize");
528        let sidecar = provides
529            .sidecar
530            .expect("canonical `sidecar` field should deserialize");
531        assert_eq!(sidecar.command, "bin/ocr-sidecar");
532        assert_eq!(sidecar.protocol_version, 1);
533    }
534
535    // ---- Phase 8 slice 8A: sidecar lifecycle parsing ----------------------
536
537    #[test]
538    fn sidecar_lifecycle_parses_full_block() {
539        let json = r#"{
540            "name": "p",
541            "provides": {
542                "sidecar": {
543                    "command": "bin/sidecar",
544                    "protocol_version": 1,
545                    "lifecycle": {
546                        "command": "capture",
547                        "settings_category": "capture",
548                        "display_name": "Sample",
549                        "importance": 50
550                    }
551                }
552            }
553        }"#;
554        let m: PluginManifest = serde_json::from_str(json).unwrap();
555        let lc = m
556            .provides
557            .unwrap()
558            .sidecar
559            .unwrap()
560            .lifecycle
561            .expect("lifecycle should deserialize");
562        assert_eq!(lc.command, "capture");
563        assert_eq!(lc.settings_category.as_deref(), Some("capture"));
564        assert_eq!(lc.display_name.as_deref(), Some("Sample"));
565        assert_eq!(lc.importance, 50);
566        assert_eq!(lc.effective_display_name(), "Sample");
567    }
568
569    #[test]
570    fn sidecar_lifecycle_is_optional() {
571        let json = r#"{
572            "name": "p",
573            "provides": {
574                "sidecar": { "command": "bin/sidecar", "protocol_version": 1 }
575            }
576        }"#;
577        let m: PluginManifest = serde_json::from_str(json).unwrap();
578        assert!(m.provides.unwrap().sidecar.unwrap().lifecycle.is_none());
579    }
580
581    #[test]
582    fn sidecar_lifecycle_minimal_only_command_required() {
583        let json = r#"{
584            "name": "p",
585            "provides": {
586                "sidecar": {
587                    "command": "bin/sidecar",
588                    "protocol_version": 1,
589                    "lifecycle": { "command": "capture" }
590                }
591            }
592        }"#;
593        let m: PluginManifest = serde_json::from_str(json).unwrap();
594        let lc = m.provides.unwrap().sidecar.unwrap().lifecycle.unwrap();
595        assert_eq!(lc.command, "capture");
596        assert!(lc.settings_category.is_none());
597        assert!(lc.display_name.is_none());
598        assert_eq!(lc.importance, 0);
599        // effective_display_name falls back to `command` when display_name absent.
600        assert_eq!(lc.effective_display_name(), "capture");
601    }
602
603    #[test]
604    fn sidecar_lifecycle_clamps_importance_above_100() {
605        let json = r#"{
606            "name": "p",
607            "provides": {
608                "sidecar": {
609                    "command": "bin/sidecar",
610                    "protocol_version": 1,
611                    "lifecycle": { "command": "v", "importance": 9999 }
612                }
613            }
614        }"#;
615        let m: PluginManifest = serde_json::from_str(json).unwrap();
616        let lc = m.provides.unwrap().sidecar.unwrap().lifecycle.unwrap();
617        assert_eq!(lc.importance, 100);
618    }
619
620    #[test]
621    fn sidecar_lifecycle_clamps_importance_below_negative_100() {
622        let json = r#"{
623            "name": "p",
624            "provides": {
625                "sidecar": {
626                    "command": "bin/sidecar",
627                    "protocol_version": 1,
628                    "lifecycle": { "command": "v", "importance": -9999 }
629                }
630            }
631        }"#;
632        let m: PluginManifest = serde_json::from_str(json).unwrap();
633        let lc = m.provides.unwrap().sidecar.unwrap().lifecycle.unwrap();
634        assert_eq!(lc.importance, -100);
635    }
636
637    #[test]
638    fn sidecar_lifecycle_missing_command_fails() {
639        let json = r#"{
640            "name": "p",
641            "provides": {
642                "sidecar": {
643                    "command": "bin/sidecar",
644                    "protocol_version": 1,
645                    "lifecycle": { "display_name": "no command" }
646                }
647            }
648        }"#;
649        let err = serde_json::from_str::<PluginManifest>(json).unwrap_err();
650        assert!(
651            err.to_string().contains("missing field `command`"),
652            "expected missing `command` error, got: {err}"
653        );
654    }
655
656    #[test]
657    fn plugin_manifest_without_provides_is_ok() {
658        let json = r#"{"name":"plain"}"#;
659        let m: PluginManifest = serde_json::from_str(json).unwrap();
660        assert!(m.provides.is_none());
661    }
662
663    #[test]
664    fn plugin_manifest_parses_interactive_command() {
665        let json = r#"{
666            "name": "demo-plugin",
667            "commands": [
668                {
669                    "name": "demo",
670                    "description": "Run interactive demo",
671                    "interactive": true,
672                    "subcommands": ["models", "download"]
673                }
674            ]
675        }"#;
676        let m: PluginManifest = serde_json::from_str(json).unwrap();
677        match &m.commands[0] {
678            ManifestCommand::Interactive(cmd) => {
679                assert_eq!(cmd.name, "demo");
680                assert_eq!(cmd.description.as_deref(), Some("Run interactive demo"));
681                assert_eq!(cmd.subcommands, vec!["models", "download"]);
682            }
683            other => panic!("expected interactive command, got {other:?}"),
684        }
685    }
686
687    #[test]
688    fn plugin_manifest_parses_commands() {
689        let json = r#"{
690            "name": "dev-tools",
691            "commands": [
692                {
693                    "name": "lint",
694                    "description": "Run lint",
695                    "command": "bash",
696                    "args": ["scripts/lint.sh"]
697                }
698            ]
699        }"#;
700        let m: PluginManifest = serde_json::from_str(json).unwrap();
701        assert_eq!(m.commands.len(), 1);
702        match &m.commands[0] {
703            ManifestCommand::Shell(cmd) => {
704                assert_eq!(cmd.name, "lint");
705                assert_eq!(cmd.description.as_deref(), Some("Run lint"));
706                assert_eq!(cmd.command, "bash");
707                assert_eq!(cmd.args, vec!["scripts/lint.sh"]);
708            }
709            other => panic!("expected shell command, got {other:?}"),
710        }
711    }
712
713    #[test]
714    fn plugin_manifest_parses_extension_tool_command() {
715        let json = r#"{
716            "name": "dev-tools",
717            "commands": [
718                {
719                    "name": "echo",
720                    "description": "Echo via extension tool",
721                    "tool": "echo",
722                    "input": {"text": "hello"}
723                }
724            ]
725        }"#;
726        let m: PluginManifest = serde_json::from_str(json).unwrap();
727        match &m.commands[0] {
728            ManifestCommand::ExtensionTool(cmd) => {
729                assert_eq!(cmd.name, "echo");
730                assert_eq!(cmd.tool, "echo");
731                assert_eq!(cmd.input["text"], "hello");
732            }
733            other => panic!("expected extension tool command, got {other:?}"),
734        }
735    }
736
737    #[test]
738    fn plugin_manifest_parses_skill_prompt_command() {
739        let json = r#"{
740            "name": "dev-tools",
741            "commands": [
742                {
743                    "name": "review",
744                    "description": "Run review skill",
745                    "skill": "reviewer",
746                    "prompt": "Review this diff"
747                }
748            ]
749        }"#;
750        let m: PluginManifest = serde_json::from_str(json).unwrap();
751        match &m.commands[0] {
752            ManifestCommand::SkillPrompt(cmd) => {
753                assert_eq!(cmd.name, "review");
754                assert_eq!(cmd.skill, "reviewer");
755                assert_eq!(cmd.prompt, "Review this diff");
756            }
757            other => panic!("expected skill prompt command, got {other:?}"),
758        }
759    }
760
761    #[test]
762    fn plugin_manifest_command_missing_command_fails() {
763        let json = r#"{"name":"p","commands":[{"name":"x"}]}"#;
764        let result: Result<PluginManifest, _> = serde_json::from_str(json);
765        assert!(result.is_err());
766    }
767
768    #[test]
769    fn plugin_manifest_missing_name_fails() {
770        let json = r#"{"version":"1.0.0"}"#;
771        let result: Result<PluginManifest, _> = serde_json::from_str(json);
772        assert!(result.is_err());
773    }
774
775    #[test]
776    fn marketplace_manifest_basic() {
777        let json = r#"{
778            "name": "pi-skills",
779            "version": "1.0.0",
780            "description": "Plugin index",
781            "categories": ["productivity"],
782            "keywords": ["local-first"],
783            "trust": {"publisher":"Maha Media","homepage":"https://example.com"},
784            "plugins": [
785                {"name": "web-tools", "source": "./web-tools-plugin", "category":"research", "keywords":["web"]},
786                {"name": "dev-tools", "source": "./dev-tools", "version": "2.0.0", "license":"MIT"}
787            ]
788        }"#;
789        let m: MarketplaceManifest = serde_json::from_str(json).unwrap();
790        assert_eq!(m.name, "pi-skills");
791        assert_eq!(m.categories, vec!["productivity"]);
792        assert_eq!(m.keywords, vec!["local-first"]);
793        assert_eq!(
794            m.trust.as_ref().unwrap().publisher.as_deref(),
795            Some("Maha Media")
796        );
797        assert_eq!(m.plugins.len(), 2);
798        assert_eq!(m.plugins[0].name, "web-tools");
799        assert_eq!(m.plugins[0].source.as_deref(), Some("./web-tools-plugin"));
800        assert_eq!(m.plugins[0].category.as_deref(), Some("research"));
801        assert_eq!(m.plugins[0].keywords, vec!["web"]);
802    }
803
804    #[test]
805    fn marketplace_manifest_missing_plugins_fails() {
806        let json = r#"{"name":"empty"}"#;
807        let result: Result<MarketplaceManifest, _> = serde_json::from_str(json);
808        assert!(result.is_err());
809    }
810
811    #[test]
812    fn plugin_manifest_parses_settings_categories_with_declarative_fields() {
813        let json = r#"{
814            "name": "demo",
815            "settings": {
816                "category": [
817                    {
818                        "id": "demo",
819                        "label": "Demo",
820                        "fields": [
821                            {
822                                "key": "backend",
823                                "label": "Backend",
824                                "editor": "cycler",
825                                "options": ["auto", "cpu", "cuda"]
826                            },
827                            {
828                                "key": "endpoint",
829                                "label": "API endpoint",
830                                "editor": "text",
831                                "help": "Base URL"
832                            },
833                            {
834                                "key": "max_tokens",
835                                "label": "Max tokens",
836                                "editor": "text",
837                                "numeric": true,
838                                "default": 2048
839                            },
840                            {
841                                "key": "model_path",
842                                "label": "Model",
843                                "editor": "custom"
844                            },
845                            {
846                                "key": "preset",
847                                "label": "Preset",
848                                "editor": "picker"
849                            }
850                        ]
851                    }
852                ]
853            }
854        }"#;
855        let m: PluginManifest = serde_json::from_str(json).unwrap();
856        let s = m.settings.expect("settings should deserialize");
857        assert_eq!(s.categories.len(), 1);
858        let cat = &s.categories[0];
859        assert_eq!(cat.id, "demo");
860        assert_eq!(cat.label, "Demo");
861        assert_eq!(cat.fields.len(), 5);
862
863        assert_eq!(cat.fields[0].key, "backend");
864        assert_eq!(cat.fields[0].editor, ManifestEditorKind::Cycler);
865        assert_eq!(cat.fields[0].options, vec!["auto", "cpu", "cuda"]);
866
867        assert_eq!(cat.fields[1].editor, ManifestEditorKind::Text);
868        assert!(!cat.fields[1].numeric);
869        assert_eq!(cat.fields[1].help.as_deref(), Some("Base URL"));
870
871        assert_eq!(cat.fields[2].editor, ManifestEditorKind::Text);
872        assert!(cat.fields[2].numeric);
873        assert_eq!(cat.fields[2].default, Some(serde_json::json!(2048)));
874
875        assert_eq!(cat.fields[3].editor, ManifestEditorKind::Custom);
876        assert_eq!(cat.fields[4].editor, ManifestEditorKind::Picker);
877    }
878
879    #[test]
880    fn plugin_manifest_settings_default_to_none() {
881        let json = r#"{"name":"plain"}"#;
882        let m: PluginManifest = serde_json::from_str(json).unwrap();
883        assert!(m.settings.is_none());
884    }
885
886    #[test]
887    fn plugin_manifest_settings_unknown_editor_kind_fails() {
888        let json = r#"{
889            "name": "demo",
890            "settings": {
891                "category": [
892                    { "id": "x", "label": "X", "fields": [
893                        { "key": "k", "label": "L", "editor": "bogus" }
894                    ] }
895                ]
896            }
897        }"#;
898        let result: Result<PluginManifest, _> = serde_json::from_str(json);
899        assert!(result.is_err());
900    }
901
902    #[test]
903    fn plugin_manifest_settings_additive_with_help_entries_field() {
904        // Verifies the `settings` field (Phase 4) and the `help_entries`
905        // field (help-command series) coexist on PluginManifest.
906        let json = r#"{
907            "name": "merge-friendly",
908            "settings": {
909                "category": [
910                    { "id": "x", "label": "X", "fields": [] }
911                ]
912            },
913            "help_entries": [
914                {
915                    "id": "x-do",
916                    "command": "/x:do",
917                    "title": "Do",
918                    "summary": "do a thing"
919                }
920            ]
921        }"#;
922        let m: PluginManifest = serde_json::from_str(json).unwrap();
923        assert!(m.settings.is_some());
924        assert_eq!(m.settings.unwrap().categories[0].id, "x");
925        assert_eq!(m.help_entries.len(), 1);
926        assert_eq!(m.help_entries[0].command, "/x:do");
927    }
928
929    #[test]
930    fn marketplace_entry_missing_source_is_allowed_for_index_backed_entries() {
931        let json = r#"{"name":"p","plugins":[{"name":"x"}]}"#;
932        let m: MarketplaceManifest = serde_json::from_str(json).unwrap();
933        assert!(m.plugins[0].source.is_none());
934    }
935}