Skip to main content

synaps_cli/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!(m.compatibility.as_ref().unwrap().synaps.as_deref(), Some(">=0.1.0"));
369        assert_eq!(m.compatibility.as_ref().unwrap().extension_protocol.as_deref(), Some("1"));
370    }
371
372    #[test]
373    fn plugin_manifest_parses_help_entries_with_usage_examples() {
374        let json = r#"{
375            "name": "web-tools",
376            "help_entries": [
377                {
378                    "id": "web-search-help",
379                    "command": "/web:search",
380                    "title": "Web Search",
381                    "summary": "Search the web from a plugin.",
382                    "category": "Plugin",
383                    "topic": "Command",
384                    "protected": false,
385                    "common": false,
386                    "keywords": ["web", "search"],
387                    "usage": "/web:search <query>",
388                    "examples": [
389                        {
390                            "command": "/web:search rust serde",
391                            "description": "Search for Rust serde resources."
392                        }
393                    ]
394                }
395            ]
396        }"#;
397        let m: PluginManifest = serde_json::from_str(json).unwrap();
398        assert_eq!(m.help_entries.len(), 1);
399        assert_eq!(m.help_entries[0].command, "/web:search");
400        assert_eq!(m.help_entries[0].usage.as_deref(), Some("/web:search <query>"));
401        assert_eq!(m.help_entries[0].examples[0].command, "/web:search rust serde");
402    }
403
404    #[test]
405    fn plugin_manifest_accepts_help_alias_for_help_entries() {
406        let json = r#"{
407            "name": "web-tools",
408            "help": [
409                {
410                    "id": "web-help",
411                    "command": "/help web",
412                    "title": "Web Tools",
413                    "summary": "Use web tools from the plugin.",
414                    "category": "Plugin",
415                    "topic": "Branch",
416                    "protected": false,
417                    "common": false
418                }
419            ]
420        }"#;
421        let m: PluginManifest = serde_json::from_str(json).unwrap();
422        assert_eq!(m.help_entries.len(), 1);
423        assert_eq!(m.help_entries[0].command, "/help web");
424        assert_eq!(m.help_entries[0].topic, crate::help::HelpTopicKind::Branch);
425    }
426
427    #[test]
428    fn plugin_manifest_can_add_command_and_matching_help_entries_together() {
429        let json = r#"{
430            "name": "dev-tools",
431            "commands": [
432                {
433                    "name": "lint",
434                    "description": "Run lint",
435                    "command": "bash",
436                    "args": ["scripts/lint.sh"]
437                }
438            ],
439            "help_entries": [
440                {
441                    "id": "dev-lint-help",
442                    "command": "/dev-tools:lint",
443                    "title": "Lint",
444                    "summary": "Run plugin lint checks.",
445                    "category": "Plugin",
446                    "topic": "Command",
447                    "protected": false,
448                    "common": false,
449                    "usage": "/dev-tools:lint"
450                }
451            ]
452        }"#;
453        let m: PluginManifest = serde_json::from_str(json).unwrap();
454        assert_eq!(m.commands.len(), 1);
455        assert_eq!(m.help_entries.len(), 1);
456        assert_eq!(m.help_entries[0].command, "/dev-tools:lint");
457    }
458
459    #[test]
460    fn plugin_manifest_help_entries_default_boilerplate_fields() {
461        let json = r#"{
462            "name": "dev-tools",
463            "help": [
464                {
465                    "id": "dev-lint-help",
466                    "command": "/dev-tools:lint",
467                    "title": "Lint",
468                    "summary": "Run plugin lint checks."
469                }
470            ]
471        }"#;
472        let m: PluginManifest = serde_json::from_str(json).unwrap();
473        assert_eq!(m.help_entries.len(), 1);
474        assert_eq!(m.help_entries[0].category, "Plugin");
475        assert_eq!(m.help_entries[0].topic, crate::help::HelpTopicKind::Command);
476        assert!(!m.help_entries[0].protected);
477        assert!(!m.help_entries[0].common);
478    }
479
480    #[test]
481    fn plugin_manifest_rejects_legacy_legacy_sidecar_field() {
482        let json = r#"{
483            "name": "legacy",
484            "provides": {
485                "legacy_sidecar": {
486                    "command": "bin/old",
487                    "protocol_version": 1
488                }
489            }
490        }"#;
491        let m: PluginManifest = serde_json::from_str(json).unwrap();
492        let provides = m.provides.expect("provides block should deserialize");
493        assert!(
494            provides.sidecar.is_none(),
495            "legacy provides.legacy_sidecar must not populate provides.sidecar"
496        );
497    }
498
499    #[test]
500    fn plugin_manifest_parses_provides_sidecar_canonical() {
501        let json = r#"{
502            "name": "local-ocr",
503            "provides": {
504                "sidecar": {
505                    "command": "bin/ocr-sidecar",
506                    "protocol_version": 1
507                }
508            }
509        }"#;
510        let m: PluginManifest = serde_json::from_str(json).unwrap();
511        let provides = m.provides.expect("provides should deserialize");
512        let sidecar = provides.sidecar.expect("canonical `sidecar` field should deserialize");
513        assert_eq!(sidecar.command, "bin/ocr-sidecar");
514        assert_eq!(sidecar.protocol_version, 1);
515    }
516
517    // ---- Phase 8 slice 8A: sidecar lifecycle parsing ----------------------
518
519    #[test]
520    fn sidecar_lifecycle_parses_full_block() {
521        let json = r#"{
522            "name": "p",
523            "provides": {
524                "sidecar": {
525                    "command": "bin/sidecar",
526                    "protocol_version": 1,
527                    "lifecycle": {
528                        "command": "capture",
529                        "settings_category": "capture",
530                        "display_name": "Sample",
531                        "importance": 50
532                    }
533                }
534            }
535        }"#;
536        let m: PluginManifest = serde_json::from_str(json).unwrap();
537        let lc = m
538            .provides
539            .unwrap()
540            .sidecar
541            .unwrap()
542            .lifecycle
543            .expect("lifecycle should deserialize");
544        assert_eq!(lc.command, "capture");
545        assert_eq!(lc.settings_category.as_deref(), Some("capture"));
546        assert_eq!(lc.display_name.as_deref(), Some("Sample"));
547        assert_eq!(lc.importance, 50);
548        assert_eq!(lc.effective_display_name(), "Sample");
549    }
550
551    #[test]
552    fn sidecar_lifecycle_is_optional() {
553        let json = r#"{
554            "name": "p",
555            "provides": {
556                "sidecar": { "command": "bin/sidecar", "protocol_version": 1 }
557            }
558        }"#;
559        let m: PluginManifest = serde_json::from_str(json).unwrap();
560        assert!(m.provides.unwrap().sidecar.unwrap().lifecycle.is_none());
561    }
562
563    #[test]
564    fn sidecar_lifecycle_minimal_only_command_required() {
565        let json = r#"{
566            "name": "p",
567            "provides": {
568                "sidecar": {
569                    "command": "bin/sidecar",
570                    "protocol_version": 1,
571                    "lifecycle": { "command": "capture" }
572                }
573            }
574        }"#;
575        let m: PluginManifest = serde_json::from_str(json).unwrap();
576        let lc = m
577            .provides
578            .unwrap()
579            .sidecar
580            .unwrap()
581            .lifecycle
582            .unwrap();
583        assert_eq!(lc.command, "capture");
584        assert!(lc.settings_category.is_none());
585        assert!(lc.display_name.is_none());
586        assert_eq!(lc.importance, 0);
587        // effective_display_name falls back to `command` when display_name absent.
588        assert_eq!(lc.effective_display_name(), "capture");
589    }
590
591    #[test]
592    fn sidecar_lifecycle_clamps_importance_above_100() {
593        let json = r#"{
594            "name": "p",
595            "provides": {
596                "sidecar": {
597                    "command": "bin/sidecar",
598                    "protocol_version": 1,
599                    "lifecycle": { "command": "v", "importance": 9999 }
600                }
601            }
602        }"#;
603        let m: PluginManifest = serde_json::from_str(json).unwrap();
604        let lc = m.provides.unwrap().sidecar.unwrap().lifecycle.unwrap();
605        assert_eq!(lc.importance, 100);
606    }
607
608    #[test]
609    fn sidecar_lifecycle_clamps_importance_below_negative_100() {
610        let json = r#"{
611            "name": "p",
612            "provides": {
613                "sidecar": {
614                    "command": "bin/sidecar",
615                    "protocol_version": 1,
616                    "lifecycle": { "command": "v", "importance": -9999 }
617                }
618            }
619        }"#;
620        let m: PluginManifest = serde_json::from_str(json).unwrap();
621        let lc = m.provides.unwrap().sidecar.unwrap().lifecycle.unwrap();
622        assert_eq!(lc.importance, -100);
623    }
624
625    #[test]
626    fn sidecar_lifecycle_missing_command_fails() {
627        let json = r#"{
628            "name": "p",
629            "provides": {
630                "sidecar": {
631                    "command": "bin/sidecar",
632                    "protocol_version": 1,
633                    "lifecycle": { "display_name": "no command" }
634                }
635            }
636        }"#;
637        let err = serde_json::from_str::<PluginManifest>(json).unwrap_err();
638        assert!(
639            err.to_string().contains("missing field `command`"),
640            "expected missing `command` error, got: {err}"
641        );
642    }
643
644    #[test]
645    fn plugin_manifest_without_provides_is_ok() {
646        let json = r#"{"name":"plain"}"#;
647        let m: PluginManifest = serde_json::from_str(json).unwrap();
648        assert!(m.provides.is_none());
649    }
650
651
652    #[test]
653    fn plugin_manifest_parses_interactive_command() {
654        let json = r#"{
655            "name": "demo-plugin",
656            "commands": [
657                {
658                    "name": "demo",
659                    "description": "Run interactive demo",
660                    "interactive": true,
661                    "subcommands": ["models", "download"]
662                }
663            ]
664        }"#;
665        let m: PluginManifest = serde_json::from_str(json).unwrap();
666        match &m.commands[0] {
667            ManifestCommand::Interactive(cmd) => {
668                assert_eq!(cmd.name, "demo");
669                assert_eq!(cmd.description.as_deref(), Some("Run interactive demo"));
670                assert_eq!(cmd.subcommands, vec!["models", "download"]);
671            }
672            other => panic!("expected interactive command, got {other:?}"),
673        }
674    }
675
676    #[test]
677    fn plugin_manifest_parses_commands() {
678        let json = r#"{
679            "name": "dev-tools",
680            "commands": [
681                {
682                    "name": "lint",
683                    "description": "Run lint",
684                    "command": "bash",
685                    "args": ["scripts/lint.sh"]
686                }
687            ]
688        }"#;
689        let m: PluginManifest = serde_json::from_str(json).unwrap();
690        assert_eq!(m.commands.len(), 1);
691        match &m.commands[0] {
692            ManifestCommand::Shell(cmd) => {
693                assert_eq!(cmd.name, "lint");
694                assert_eq!(cmd.description.as_deref(), Some("Run lint"));
695                assert_eq!(cmd.command, "bash");
696                assert_eq!(cmd.args, vec!["scripts/lint.sh"]);
697            }
698            other => panic!("expected shell command, got {other:?}"),
699        }
700    }
701
702    #[test]
703    fn plugin_manifest_parses_extension_tool_command() {
704        let json = r#"{
705            "name": "dev-tools",
706            "commands": [
707                {
708                    "name": "echo",
709                    "description": "Echo via extension tool",
710                    "tool": "echo",
711                    "input": {"text": "hello"}
712                }
713            ]
714        }"#;
715        let m: PluginManifest = serde_json::from_str(json).unwrap();
716        match &m.commands[0] {
717            ManifestCommand::ExtensionTool(cmd) => {
718                assert_eq!(cmd.name, "echo");
719                assert_eq!(cmd.tool, "echo");
720                assert_eq!(cmd.input["text"], "hello");
721            }
722            other => panic!("expected extension tool command, got {other:?}"),
723        }
724    }
725
726    #[test]
727    fn plugin_manifest_parses_skill_prompt_command() {
728        let json = r#"{
729            "name": "dev-tools",
730            "commands": [
731                {
732                    "name": "review",
733                    "description": "Run review skill",
734                    "skill": "reviewer",
735                    "prompt": "Review this diff"
736                }
737            ]
738        }"#;
739        let m: PluginManifest = serde_json::from_str(json).unwrap();
740        match &m.commands[0] {
741            ManifestCommand::SkillPrompt(cmd) => {
742                assert_eq!(cmd.name, "review");
743                assert_eq!(cmd.skill, "reviewer");
744                assert_eq!(cmd.prompt, "Review this diff");
745            }
746            other => panic!("expected skill prompt command, got {other:?}"),
747        }
748    }
749
750    #[test]
751    fn plugin_manifest_command_missing_command_fails() {
752        let json = r#"{"name":"p","commands":[{"name":"x"}]}"#;
753        let result: Result<PluginManifest, _> = serde_json::from_str(json);
754        assert!(result.is_err());
755    }
756
757    #[test]
758    fn plugin_manifest_missing_name_fails() {
759        let json = r#"{"version":"1.0.0"}"#;
760        let result: Result<PluginManifest, _> = serde_json::from_str(json);
761        assert!(result.is_err());
762    }
763
764    #[test]
765    fn marketplace_manifest_basic() {
766        let json = r#"{
767            "name": "pi-skills",
768            "version": "1.0.0",
769            "description": "Plugin index",
770            "categories": ["productivity"],
771            "keywords": ["local-first"],
772            "trust": {"publisher":"Maha Media","homepage":"https://example.com"},
773            "plugins": [
774                {"name": "web-tools", "source": "./web-tools-plugin", "category":"research", "keywords":["web"]},
775                {"name": "dev-tools", "source": "./dev-tools", "version": "2.0.0", "license":"MIT"}
776            ]
777        }"#;
778        let m: MarketplaceManifest = serde_json::from_str(json).unwrap();
779        assert_eq!(m.name, "pi-skills");
780        assert_eq!(m.categories, vec!["productivity"]);
781        assert_eq!(m.keywords, vec!["local-first"]);
782        assert_eq!(m.trust.as_ref().unwrap().publisher.as_deref(), Some("Maha Media"));
783        assert_eq!(m.plugins.len(), 2);
784        assert_eq!(m.plugins[0].name, "web-tools");
785        assert_eq!(m.plugins[0].source.as_deref(), Some("./web-tools-plugin"));
786        assert_eq!(m.plugins[0].category.as_deref(), Some("research"));
787        assert_eq!(m.plugins[0].keywords, vec!["web"]);
788    }
789
790    #[test]
791    fn marketplace_manifest_missing_plugins_fails() {
792        let json = r#"{"name":"empty"}"#;
793        let result: Result<MarketplaceManifest, _> = serde_json::from_str(json);
794        assert!(result.is_err());
795    }
796
797    #[test]
798    fn plugin_manifest_parses_settings_categories_with_declarative_fields() {
799        let json = r#"{
800            "name": "demo",
801            "settings": {
802                "category": [
803                    {
804                        "id": "demo",
805                        "label": "Demo",
806                        "fields": [
807                            {
808                                "key": "backend",
809                                "label": "Backend",
810                                "editor": "cycler",
811                                "options": ["auto", "cpu", "cuda"]
812                            },
813                            {
814                                "key": "endpoint",
815                                "label": "API endpoint",
816                                "editor": "text",
817                                "help": "Base URL"
818                            },
819                            {
820                                "key": "max_tokens",
821                                "label": "Max tokens",
822                                "editor": "text",
823                                "numeric": true,
824                                "default": 2048
825                            },
826                            {
827                                "key": "model_path",
828                                "label": "Model",
829                                "editor": "custom"
830                            },
831                            {
832                                "key": "preset",
833                                "label": "Preset",
834                                "editor": "picker"
835                            }
836                        ]
837                    }
838                ]
839            }
840        }"#;
841        let m: PluginManifest = serde_json::from_str(json).unwrap();
842        let s = m.settings.expect("settings should deserialize");
843        assert_eq!(s.categories.len(), 1);
844        let cat = &s.categories[0];
845        assert_eq!(cat.id, "demo");
846        assert_eq!(cat.label, "Demo");
847        assert_eq!(cat.fields.len(), 5);
848
849        assert_eq!(cat.fields[0].key, "backend");
850        assert_eq!(cat.fields[0].editor, ManifestEditorKind::Cycler);
851        assert_eq!(cat.fields[0].options, vec!["auto", "cpu", "cuda"]);
852
853        assert_eq!(cat.fields[1].editor, ManifestEditorKind::Text);
854        assert!(!cat.fields[1].numeric);
855        assert_eq!(cat.fields[1].help.as_deref(), Some("Base URL"));
856
857        assert_eq!(cat.fields[2].editor, ManifestEditorKind::Text);
858        assert!(cat.fields[2].numeric);
859        assert_eq!(cat.fields[2].default, Some(serde_json::json!(2048)));
860
861        assert_eq!(cat.fields[3].editor, ManifestEditorKind::Custom);
862        assert_eq!(cat.fields[4].editor, ManifestEditorKind::Picker);
863    }
864
865    #[test]
866    fn plugin_manifest_settings_default_to_none() {
867        let json = r#"{"name":"plain"}"#;
868        let m: PluginManifest = serde_json::from_str(json).unwrap();
869        assert!(m.settings.is_none());
870    }
871
872    #[test]
873    fn plugin_manifest_settings_unknown_editor_kind_fails() {
874        let json = r#"{
875            "name": "demo",
876            "settings": {
877                "category": [
878                    { "id": "x", "label": "X", "fields": [
879                        { "key": "k", "label": "L", "editor": "bogus" }
880                    ] }
881                ]
882            }
883        }"#;
884        let result: Result<PluginManifest, _> = serde_json::from_str(json);
885        assert!(result.is_err());
886    }
887
888    #[test]
889    fn plugin_manifest_settings_additive_with_help_entries_field() {
890        // Verifies the `settings` field (Phase 4) and the `help_entries`
891        // field (help-command series) coexist on PluginManifest.
892        let json = r#"{
893            "name": "merge-friendly",
894            "settings": {
895                "category": [
896                    { "id": "x", "label": "X", "fields": [] }
897                ]
898            },
899            "help_entries": [
900                {
901                    "id": "x-do",
902                    "command": "/x:do",
903                    "title": "Do",
904                    "summary": "do a thing"
905                }
906            ]
907        }"#;
908        let m: PluginManifest = serde_json::from_str(json).unwrap();
909        assert!(m.settings.is_some());
910        assert_eq!(m.settings.unwrap().categories[0].id, "x");
911        assert_eq!(m.help_entries.len(), 1);
912        assert_eq!(m.help_entries[0].command, "/x:do");
913    }
914
915    #[test]
916    fn marketplace_entry_missing_source_is_allowed_for_index_backed_entries() {
917        let json = r#"{"name":"p","plugins":[{"name":"x"}]}"#;
918        let m: MarketplaceManifest = serde_json::from_str(json).unwrap();
919        assert!(m.plugins[0].source.is_none());
920    }
921}