@ryuhq/sdk 0.1.14 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +69 -7
  2. package/dist/action.cjs +839 -0
  3. package/dist/action.d.cts +88 -0
  4. package/dist/action.d.ts +88 -0
  5. package/dist/action.js +8 -0
  6. package/dist/agent-plugin.cjs +46 -0
  7. package/dist/agent-plugin.d.cts +43 -33
  8. package/dist/agent-plugin.d.ts +43 -33
  9. package/dist/agent-plugin.js +1 -1
  10. package/dist/agent.cjs +16 -0
  11. package/dist/agent.d.cts +377 -1
  12. package/dist/agent.d.ts +377 -1
  13. package/dist/agent.js +4 -2
  14. package/dist/app-B0Z9Ew_R.d.cts +141 -0
  15. package/dist/app-C-BDJwfG.d.ts +141 -0
  16. package/dist/builder.cjs +1194 -0
  17. package/dist/builder.d.cts +217 -0
  18. package/dist/builder.d.ts +217 -0
  19. package/dist/builder.js +29 -0
  20. package/dist/chunk-4TPUZDTI.js +94 -0
  21. package/dist/chunk-BC3A7HMO.js +164 -0
  22. package/dist/chunk-FZSFZOIN.js +200 -0
  23. package/dist/{chunk-ODFEUVPW.js → chunk-HACAGK65.js} +1 -18
  24. package/dist/chunk-HLKJZAFK.js +361 -0
  25. package/dist/chunk-IKEDLLFY.js +19 -0
  26. package/dist/{chunk-G6FLVEC4.js → chunk-JX6DDRXV.js} +46 -0
  27. package/dist/{chunk-AO2KJRDD.js → chunk-NZKVOSC2.js} +192 -3
  28. package/dist/chunk-QYFUNJOH.js +83 -0
  29. package/dist/chunk-SN2QBJUF.js +148 -0
  30. package/dist/chunk-TLDPEGC7.js +21 -0
  31. package/dist/chunk-TXSHHZF2.js +0 -0
  32. package/dist/chunk-VLIRNNAE.js +154 -0
  33. package/dist/{chunk-MTUBUPIV.js → chunk-Z57QDDJR.js} +8 -227
  34. package/dist/chunk-ZTJWBRUL.js +282 -0
  35. package/dist/cli.cjs +347 -18
  36. package/dist/cli.js +128 -23
  37. package/dist/client-D5U6ssPc.d.cts +84 -0
  38. package/dist/client-D5U6ssPc.d.ts +84 -0
  39. package/dist/index.cjs +810 -86
  40. package/dist/index.d.cts +19 -634
  41. package/dist/index.d.ts +19 -634
  42. package/dist/index.js +82 -699
  43. package/dist/manifest.cjs +200 -4
  44. package/dist/manifest.d.cts +260 -5
  45. package/dist/manifest.d.ts +260 -5
  46. package/dist/manifest.js +15 -1
  47. package/dist/mcp/client.cjs +180 -0
  48. package/dist/mcp/client.d.cts +49 -0
  49. package/dist/mcp/client.d.ts +49 -0
  50. package/dist/mcp/client.js +10 -0
  51. package/dist/mcp/server.cjs +370 -0
  52. package/dist/mcp/server.d.cts +127 -0
  53. package/dist/mcp/server.d.ts +127 -0
  54. package/dist/mcp/server.js +9 -0
  55. package/dist/mcp.cjs +376 -0
  56. package/dist/mcp.d.cts +2 -0
  57. package/dist/mcp.d.ts +2 -0
  58. package/dist/mcp.js +17 -0
  59. package/dist/model.cjs +141 -0
  60. package/dist/model.d.cts +33 -0
  61. package/dist/model.d.ts +33 -0
  62. package/dist/model.js +18 -0
  63. package/dist/plugin.cjs +46 -0
  64. package/dist/plugin.d.cts +215 -0
  65. package/dist/plugin.d.ts +215 -0
  66. package/dist/plugin.js +8 -0
  67. package/dist/runnable.cjs +1438 -0
  68. package/dist/runnable.d.cts +274 -0
  69. package/dist/runnable.d.ts +274 -0
  70. package/dist/runnable.js +34 -0
  71. package/dist/{index-B6SkaAjJ.d.ts → tool-AjkdFvhE.d.ts} +88 -461
  72. package/dist/{index-BvAB5eMk.d.cts → tool-CgzW92O_.d.cts} +88 -461
  73. package/package.json +48 -3
  74. package/src/agent/loop.test.ts +4 -4
  75. package/src/agent/tools.ts +3 -3
  76. package/src/agent-plugin.test.ts +58 -0
  77. package/src/agent-plugin.ts +109 -36
  78. package/src/builder.ts +9 -0
  79. package/src/cli/dev.test.ts +26 -47
  80. package/src/cli/dev.ts +10 -2
  81. package/src/cli-security.test.ts +109 -0
  82. package/src/cli.ts +141 -25
  83. package/src/contracts-lockstep.test.ts +16 -2
  84. package/src/exports-lockstep.test.ts +93 -0
  85. package/src/generated/plugin-manifest.ts +322 -29
  86. package/src/index.ts +53 -0
  87. package/src/manifest-schema.test.ts +30 -1
  88. package/src/manifest.fixtures.test.ts +22 -6
  89. package/src/manifest.test.ts +165 -10
  90. package/src/manifest.ts +454 -188
  91. package/src/mcp/index.ts +18 -0
  92. package/src/mcp/server.ts +2 -1
  93. package/src/model/index.ts +22 -0
  94. package/src/plugin/ryu-plugin.ts +82 -0
  95. package/src/runnable/action.test.ts +128 -0
  96. package/src/runnable/action.ts +202 -0
  97. package/src/runnable/app.test.ts +2 -0
  98. package/src/runnable/app.ts +50 -14
  99. package/src/runnable/index.ts +20 -3
  100. package/src/runnable/primitives.test.ts +34 -0
  101. package/src/runnable/primitives.ts +116 -0
  102. package/src/runnable/runnable-types.ts +3 -0
  103. package/src/runnable/tool.ts +36 -5
  104. package/src/runnable/turn-hook.ts +8 -3
  105. package/src/slash-command.test.ts +69 -0
package/src/manifest.ts CHANGED
@@ -344,6 +344,54 @@ export const WidgetContributionSchema = z.object({
344
344
 
345
345
  export type WidgetContribution = z.infer<typeof WidgetContributionSchema>;
346
346
 
347
+ /** Metadata-only chat affordance. The host owns rendering and dispatch; the
348
+ * manifest carries identifiers and copy only. */
349
+ export const ChatWidgetTemplateSchema = z
350
+ .object({
351
+ id: z.string().regex(/^[a-z0-9][a-z0-9._:-]*$/),
352
+ title: z.string().min(1),
353
+ description: z.string().optional(),
354
+ triggers: z.array(z.string()).default([]),
355
+ examples: z.array(z.string()).default([]),
356
+ backing: z.object({
357
+ tool_id: z
358
+ .string()
359
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/)
360
+ .optional(),
361
+ view_id: z
362
+ .string()
363
+ .regex(/^[a-z0-9][a-z0-9._:-]*$/)
364
+ .optional(),
365
+ }),
366
+ display_mode: z.string().min(1),
367
+ safe_action_ids: z
368
+ .array(z.string().regex(/^[a-z0-9][a-z0-9._-]*$/))
369
+ .default([]),
370
+ availability: z.string().default("available"),
371
+ })
372
+ .superRefine((value, ctx) => {
373
+ const count =
374
+ Number(Boolean(value.backing.tool_id)) +
375
+ Number(Boolean(value.backing.view_id));
376
+ if (count !== 1 && value.availability === "available") {
377
+ ctx.addIssue({
378
+ code: "custom",
379
+ path: ["backing"],
380
+ message:
381
+ "available templates need exactly one backing tool_id or view_id",
382
+ });
383
+ }
384
+ if (count > 1) {
385
+ ctx.addIssue({
386
+ code: "custom",
387
+ path: ["backing"],
388
+ message: "backing must declare at most one of tool_id or view_id",
389
+ });
390
+ }
391
+ });
392
+
393
+ export type ChatWidgetTemplate = z.infer<typeof ChatWidgetTemplateSchema>;
394
+
347
395
  // ── ToolAppConfig (Ryu Apps per-tool config) ─────────────────────────────────
348
396
 
349
397
  /**
@@ -355,7 +403,7 @@ export type WidgetContribution = z.infer<typeof WidgetContributionSchema>;
355
403
  * derives `outputTemplate` / `toolInvocation` / `widgetAccessible`.
356
404
  */
357
405
  export const ToolAppConfigSchema = z.object({
358
- /** MCP tool slug this runnable wraps — the fully-qualified `<server>__<name>` id. */
406
+ /** MCP tool slug this runnable wraps — the fully-qualified `<server>.<name>` id. */
359
407
  slug: z.string().min(1),
360
408
  /** The tool description the model reads when choosing it. Carried here because a
361
409
  * packed app's manifest is the only channel (there is no `generated.rs`); Core's
@@ -377,10 +425,70 @@ export const ToolAppConfigSchema = z.object({
377
425
 
378
426
  export type ToolAppConfig = z.infer<typeof ToolAppConfigSchema>;
379
427
 
428
+ /** One selectable value for a plugin/app slash-command argument. */
429
+ export const SlashCommandOptionSchema = z
430
+ .object({
431
+ description: z.string().optional(),
432
+ label: z.string().min(1),
433
+ value: z.string().min(1),
434
+ })
435
+ .passthrough();
436
+
437
+ export type SlashCommandOption = z.infer<typeof SlashCommandOptionSchema>;
438
+
439
+ /** A registered free-form option shown alongside an argument's choices. */
440
+ export const SlashCommandCustomOptionSchema = z.union([
441
+ z.literal(true),
442
+ z
443
+ .object({
444
+ description: z.string().optional(),
445
+ label: z.string().min(1).optional(),
446
+ })
447
+ .passthrough(),
448
+ ]);
449
+
450
+ export type SlashCommandCustomOption = z.infer<
451
+ typeof SlashCommandCustomOptionSchema
452
+ >;
453
+
454
+ /** One sequential argument in a plugin/app slash command. */
455
+ export const SlashCommandArgumentSchema = z
456
+ .object({
457
+ allow_custom: z.boolean().optional(),
458
+ custom: SlashCommandCustomOptionSchema.optional(),
459
+ description: z.string().optional(),
460
+ name: z.string().min(1),
461
+ options: z.array(SlashCommandOptionSchema).optional(),
462
+ })
463
+ .passthrough();
464
+
465
+ export type SlashCommandArgument = z.infer<typeof SlashCommandArgumentSchema>;
466
+
467
+ /**
468
+ * A command registered by a plugin or Ryu App. `args` is the preferred key;
469
+ * `parameters` is accepted as a readable alias for hand-authored manifests.
470
+ * Each argument's options are plugin-owned, so the shell never needs a closed
471
+ * enum for app-specific values.
472
+ */
473
+ export const SlashCommandContributionSchema = z
474
+ .object({
475
+ args: z.array(SlashCommandArgumentSchema).optional(),
476
+ body: z.string().optional(),
477
+ command: z.string().min(1),
478
+ description: z.string().optional(),
479
+ id: z.string().optional(),
480
+ parameters: z.array(SlashCommandArgumentSchema).optional(),
481
+ })
482
+ .passthrough();
483
+
484
+ export type SlashCommandContribution = z.infer<
485
+ typeof SlashCommandContributionSchema
486
+ >;
487
+
380
488
  /**
381
489
  * The `contributes` block. Mirrors `Contributes` in
382
490
  * `apps/core/src/plugin_manifest/mod.rs`. The declarative UI surfaces
383
- * (`composer_controls` / `settings_tabs` / `slash_commands`) are passed verbatim
491
+ * (`composer_controls` / `chat_features` / `settings_tabs`) are passed verbatim
384
492
  * to the desktop renderer, so they are typed loosely here (records).
385
493
  */
386
494
  export const ContributesSchema = z.object({
@@ -391,13 +499,20 @@ export const ContributesSchema = z.object({
391
499
  * signing, leaving an app that emits events nothing is allowed to subscribe to. */
392
500
  hook_events: z.array(HookEventContributionSchema).default([]),
393
501
  composer_controls: z.array(z.record(z.string(), z.unknown())).default([]),
502
+ /** Chat feature descriptors whose behavior is implemented by the host shell.
503
+ * Mirrors the Rust-side `Contributes.chat_features`; keeping this field in the
504
+ * authoring schema prevents `ryu pack` from silently deleting a plugin's chat
505
+ * feature declaration before signing. */
506
+ chat_features: z.array(z.record(z.string(), z.unknown())).default([]),
394
507
  settings_tabs: z.array(z.record(z.string(), z.unknown())).default([]),
395
- slash_commands: z.array(z.record(z.string(), z.unknown())).default([]),
508
+ slash_commands: z.array(SlashCommandContributionSchema).default([]),
396
509
  /** App widgets (Ryu Apps). Each binds a render tool id to its
397
510
  * `ui://widget/<slug>.html` template. Mirrors the Rust-side
398
511
  * `Contributes.widgets` field, without which the CLI's zod parse would strip
399
512
  * every widget an app authored here declares. */
400
513
  widgets: z.array(WidgetContributionSchema).default([]),
514
+ /** Metadata-only chat widget templates. */
515
+ chat_widget_templates: z.array(ChatWidgetTemplateSchema).optional(),
401
516
  /** App-registered sidebar sections (header + live list) and buttons (single nav
402
517
  * rows). Loosely typed here — the shell owns the spec vocabulary — matching how
403
518
  * `composer_controls`/`settings_tabs` are declared. Mirrors the Rust-side
@@ -410,6 +525,13 @@ export const ContributesSchema = z.object({
410
525
  * Rust-side `Contributes.dock_panels`; without it the CLI's zod parse would
411
526
  * strip the dock panel an app declares here. */
412
527
  dock_panels: z.array(z.record(z.string(), z.unknown())).default([]),
528
+ /** App-registered live activities (the desktop "Dynamic Island" cards). Loosely
529
+ * typed for the same reason as the surfaces above — the shell owns the
530
+ * `spec` vocabulary. Mirrors the Rust-side `Contributes.live_activities`;
531
+ * without it the CLI's zod parse would strip every live activity an app
532
+ * declares here, so a packed bundle would ship a dock that silently stays
533
+ * empty. */
534
+ live_activities: z.array(z.record(z.string(), z.unknown())).default([]),
413
535
  /** Deletable data categories the app owns — one "Delete all X" row in Settings
414
536
  * → Danger Zone. Mirrors the Rust-side `Contributes.data_categories`; without
415
537
  * it the CLI's zod parse would strip the declaration before signing, and the
@@ -450,6 +572,12 @@ export const ContributesSchema = z.object({
450
572
  * would be no residue to notice — the plugin would install clean and contribute
451
573
  * nothing. */
452
574
  output_styles: z.array(OutputStyleContributionSchema).default([]),
575
+ /** Per-message actions contributed by an enabled plugin. Kept as loose records
576
+ * so renderer-specific `kind`/`args` payloads survive `ryu pack` unchanged. */
577
+ message_actions: z.array(z.record(z.string(), z.unknown())).default([]),
578
+ /** Buttons contributed to the floating text-selection toolbar. Kept as loose
579
+ * records so host-owned dispatch args survive `ryu pack` unchanged. */
580
+ selection_actions: z.array(z.record(z.string(), z.unknown())).default([]),
453
581
  });
454
582
 
455
583
  export type Contributes = z.infer<typeof ContributesSchema>;
@@ -615,6 +743,78 @@ export const EnginesReqSchema = z.object({
615
743
 
616
744
  export type EnginesReq = z.infer<typeof EnginesReqSchema>;
617
745
 
746
+ export const McpServerAuthSchema = z
747
+ .object({
748
+ client_id: z.string().min(1).optional(),
749
+ type: z.literal("oauth"),
750
+ })
751
+ .strict();
752
+
753
+ export const McpServerDeclSchema = z
754
+ .object({
755
+ args: z.array(z.string()).default([]),
756
+ auth: McpServerAuthSchema.optional(),
757
+ command: z.string().optional(),
758
+ command_env: z.string().optional(),
759
+ description: z.string().optional(),
760
+ enabled: z.boolean().default(true),
761
+ env: z.record(z.string(), z.string()).default({}),
762
+ headers: z.record(z.string(), z.string()).default({}),
763
+ type: z
764
+ .enum(["stdio", "http", "streamable-http", "streamable_http", "sse"])
765
+ .optional(),
766
+ url: z.url().optional(),
767
+ })
768
+ .superRefine((server, context) => {
769
+ if (!(server.command || server.url)) {
770
+ context.addIssue({
771
+ code: "custom",
772
+ message: "an MCP server requires command or url",
773
+ });
774
+ }
775
+ if (!server.auth) {
776
+ return;
777
+ }
778
+ if (server.command || server.type === "stdio" || !server.url) {
779
+ context.addIssue({
780
+ code: "custom",
781
+ message: "OAuth is supported only for remote HTTP MCP servers",
782
+ });
783
+ return;
784
+ }
785
+ if (
786
+ Object.keys(server.headers).some(
787
+ (name) => name.toLowerCase() === "authorization"
788
+ )
789
+ ) {
790
+ context.addIssue({
791
+ code: "custom",
792
+ message: "OAuth cannot be combined with a static Authorization header",
793
+ });
794
+ }
795
+ const url = new URL(server.url);
796
+ const loopback =
797
+ url.hostname === "localhost" ||
798
+ url.hostname.startsWith("127.") ||
799
+ url.hostname === "[::1]" ||
800
+ url.hostname === "::1";
801
+ if (url.username || url.password || url.hash) {
802
+ context.addIssue({
803
+ code: "custom",
804
+ message: "OAuth MCP URLs cannot contain credentials or fragments",
805
+ });
806
+ }
807
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
808
+ context.addIssue({
809
+ code: "custom",
810
+ message: "OAuth MCP URLs must use HTTPS except on loopback",
811
+ });
812
+ }
813
+ });
814
+
815
+ export type McpServerAuth = z.infer<typeof McpServerAuthSchema>;
816
+ export type McpServerDecl = z.infer<typeof McpServerDeclSchema>;
817
+
618
818
  // ── PluginManifest ───────────────────────────────────────────────────────────
619
819
 
620
820
  /**
@@ -627,203 +827,269 @@ export type EnginesReq = z.infer<typeof EnginesReqSchema>;
627
827
  * - `runnables` may be empty for a "surface-only" plugin, but each entry must be
628
828
  * a valid `RunnableMeta`
629
829
  */
630
- export const PluginManifestSchema = z.object({
631
- /** Reverse-domain unique identifier (e.g. `"com.example.my-plugin"`). */
632
- id: z.string().min(1, "id is required"),
830
+ export const PluginManifestSchema = z
831
+ .object({
832
+ /** Reverse-domain unique identifier (e.g. `"com.example.my-plugin"`). */
833
+ id: z.string().min(1, "id is required"),
633
834
 
634
- /** Human-readable display name shown in the plugin store / launcher. */
635
- name: z.string().min(1, "name is required"),
835
+ /** Human-readable display name shown in the plugin store / launcher. */
836
+ name: z.string().min(1, "name is required"),
636
837
 
637
- /**
638
- * Semver version string (e.g. `"1.0.0"`). Core's loader rejects any manifest
639
- * whose version is not valid semver; the regex here enforces the same rule at
640
- * SDK-build time.
641
- */
642
- version: z
643
- .string()
644
- .regex(
645
- /^\d+\.\d+\.\d+(?:-[\w.]+)?(?:\+[\w.]+)?$/,
646
- "version must be a valid semver string (e.g. 1.0.0)"
647
- ),
838
+ /**
839
+ * Semver version string (e.g. `"1.0.0"`). Core's loader rejects any manifest
840
+ * whose version is not valid semver; the regex here enforces the same rule at
841
+ * SDK-build time.
842
+ */
843
+ version: z
844
+ .string()
845
+ .regex(
846
+ /^\d+\.\d+\.\d+(?:-[\w.]+)?(?:\+[\w.]+)?$/,
847
+ "version must be a valid semver string (e.g. 1.0.0)"
848
+ ),
648
849
 
649
- /**
650
- * Lower-case hex `sha256(utf8_bytes(ui_code))` binding the plugin's bundled
651
- * sandboxed-UI code to this manifest. `ryu pack` / `ryu publish` compute it and
652
- * write it here BEFORE the manifest is signed, so the hash rides INSIDE the
653
- * Gateway-signed surface while the `ui_code` blob rides OUTSIDE it as payload;
654
- * Core's install path recomputes the hash over the fetched code and rejects a
655
- * mismatch fail-closed. Absent for a manifest-only plugin (no bundled UI).
656
- * Mirrors Core's `PluginManifest.ui_code_sha256`.
657
- */
658
- ui_code_sha256: z.string().nullish(),
850
+ /** Core-owned release maturity metadata. The Rust contract validates the
851
+ * richer shape; the SDK authoring parser must preserve it for pack/publish. */
852
+ stability: z.unknown().optional(),
659
853
 
660
- /** The Runnables this plugin bundles. */
661
- runnables: z.array(RunnableMetaSchema).default([]),
854
+ /**
855
+ * Lower-case hex `sha256(utf8_bytes(ui_code))` binding the plugin's bundled
856
+ * sandboxed-UI code to this manifest. `ryu pack` / `ryu publish` compute it and
857
+ * write it here BEFORE the manifest is signed, so the hash rides INSIDE the
858
+ * Gateway-signed surface while the `ui_code` blob rides OUTSIDE it as payload;
859
+ * Core's install path recomputes the hash over the fetched code and rejects a
860
+ * mismatch fail-closed. Absent for a manifest-only plugin (no bundled UI).
861
+ * Mirrors Core's `PluginManifest.ui_code_sha256`.
862
+ */
863
+ ui_code_sha256: z.string().nullish(),
662
864
 
663
- /**
664
- * Permission grants this plugin declares it needs (e.g. `"mcp:web_search"`).
665
- * Declarations only — grant enforcement is the Gateway's responsibility.
666
- */
667
- permission_grants: z.array(z.string()).default([]),
865
+ /** The Runnables this plugin bundles. */
866
+ runnables: z.array(RunnableMetaSchema).default([]),
668
867
 
669
- /**
670
- * Optional Companion surface (an in-desktop overlay or sidebar panel).
671
- * Absent when the plugin has no Companion surface.
672
- */
673
- companion: CompanionSurfaceSchema.optional(),
868
+ /**
869
+ * Permission grants this plugin declares it needs (e.g. `"mcp:web_search"`).
870
+ * Declarations only grant enforcement is the Gateway's responsibility.
871
+ */
872
+ permission_grants: z.array(z.string()).default([]),
674
873
 
675
- /**
676
- * VS-Code-style activation events (`"*"`, `"onStartup"`, `"onChat"`,
677
- * `"onCommand:<id>"`). Empty = eager. Turn-hook plugins are driven by their
678
- * enabled flag, so `["*"]` is the usual value.
679
- */
680
- activation_events: z.array(z.string()).default([]),
874
+ /** Deny-by-default sandbox permissions. Core remains authoritative; this
875
+ * schema mirrors the fields so `ryu pack` cannot strip them. */
876
+ permissions: z
877
+ .object({
878
+ fs: z
879
+ .object({
880
+ read: z.array(z.string()).default([]),
881
+ write: z.array(z.string()).default([]),
882
+ })
883
+ .optional(),
884
+ child_process: z.boolean().optional(),
885
+ run: z.array(z.string()).default([]),
886
+ network: z.union([z.boolean(), z.array(z.string())]).optional(),
887
+ tool: z.array(z.string()).default([]),
888
+ })
889
+ .optional(),
681
890
 
682
- /**
683
- * Contribution points: server-side turn hooks + declarative UI widgets.
684
- * Absent for a plugin that contributes nothing here.
685
- */
686
- contributes: ContributesSchema.optional(),
891
+ /** Core-owned permission presentation levels. Preserved verbatim here and
892
+ * validated by Core's authoritative manifest contract. */
893
+ permission_levels: z.unknown().optional(),
687
894
 
688
- /**
689
- * **Plugin-to-plugin dependencies** — the other plugins this one needs. Core
690
- * resolves them into a topological enable order (dependencies enable first;
691
- * disabling one is refused while an enabled dependent needs it).
692
- *
693
- * Absent = **no dependencies**, the backward-compatible default. Kept
694
- * `.optional()` (never defaulted) so a manifest that declares none serialises
695
- * with no `requires` key at all, exactly like Core's
696
- * `#[serde(skip_serializing_if = "Option::is_none")]`.
697
- */
698
- requires: RequiresSchema.optional(),
895
+ /** Remote or stdio MCP servers registered by this plugin. */
896
+ mcp_servers: z.record(z.string(), McpServerDeclSchema).optional(),
699
897
 
700
- /**
701
- * Host surfaces this plugin runs on. **Empty or absent = runs on EVERY
702
- * surface** — the backward-compatible default, which must never be read as
703
- * "runs nowhere". Core filters only when the list is explicitly non-empty, and
704
- * only at the read boundary (`GET /api/plugins`, keyed on `x-ryu-surface`), so
705
- * an unsupported-target plugin stays installable and inspectable.
706
- */
707
- targets: z.array(SurfaceSchema).default([]),
898
+ /** Core-owned sidecar declarations. Their full process/HTTP schema stays in
899
+ * the Rust contract; this authoring layer must never strip them. */
900
+ sidecars: z.unknown().optional(),
708
901
 
709
- /**
710
- * Host version floors the semver requirement each surface must satisfy for
711
- * this plugin to install. Mirrors Core's `EnginesReq`
712
- * (`crates/core/kernel-contracts/src/manifest.rs`).
713
- *
714
- * `ryu` is the **Core** floor and the only required key (it is the legacy
715
- * spelling; every manifest in the wild carries just that one). The rest are
716
- * optional per-surface floors.
717
- *
718
- * REGRESSION THIS FIXES: `engines` was absent from this schema entirely, and
719
- * zod strips unlisted keys — so `ryu pack` silently dropped the whole block
720
- * from every bundle it produced. A plugin could declare a Core floor, publish,
721
- * and ship a bundle that declared none. Any new host floor MUST be added here
722
- * as well as in the Rust contract, or it does not survive packing.
723
- */
724
- engines: EnginesReqSchema.optional(),
902
+ /**
903
+ * Optional Companion surface (an in-desktop overlay or sidebar panel).
904
+ * Absent when the plugin has no Companion surface.
905
+ */
906
+ companion: CompanionSurfaceSchema.optional(),
725
907
 
726
- /**
727
- * Optional per-item AFFILIATE terms: the commission paid to a referrer when a
728
- * referred user buys this (paid) item. `value` is basis points for `percent`
729
- * (2000 = 20%) or minor units (cents) for `flat`. Absent (or `enabled:false`)
730
- * falls back to the seller org owner's default affiliate terms. This is the
731
- * authoring surface for the marketplace publish body's `affiliate` field (the
732
- * server re-validates it); it only takes effect on a paid item.
733
- */
734
- affiliate: z
735
- .object({
736
- enabled: z.boolean().default(false),
737
- rule: z
738
- .object({
739
- type: z.enum(["percent", "flat"]),
740
- value: z.number().nonnegative(),
741
- recurring: z.boolean().default(false),
742
- durationMonths: z.number().int().positive().nullish(),
743
- fundedBy: z.enum(["platform", "seller"]).default("platform"),
744
- })
745
- .optional(),
746
- })
747
- .optional(),
748
-
749
- // ── Rich listing metadata (Phase 1.5) ──────────────────────────────────────
750
- // Optional store-listing fields a plugin author declares so the marketplace
751
- // detail dialog renders a richer App-Store-style preview. Field names align
752
- // with the Claude `.claude-plugin/marketplace.json` plugin-entry standard where
753
- // one exists (`author`, `homepage`, `keywords`, `category`, `license`); the
754
- // rest are Ryu extensions. `ryu publish` forwards these FLAT into the publish
755
- // body (not inside the signed manifest blob) so the control plane stores them.
756
- // All optional + additive: a manifest omitting them still validates.
757
-
758
- /** Longer plain/markdown description shown in the detail dialog. */
759
- description: z.string().optional(),
760
- /** Short one-line pitch shown under the name (Ryu extension). */
761
- tagline: z.string().optional(),
762
- /**
763
- * Publisher identity. A bare string OR a Claude-style object; `ryu publish`
764
- * resolves it to the display `developer` (`author.name` when an object).
765
- */
766
- author: z
767
- .union([
768
- z.string(),
769
- z.object({
770
- name: z.string(),
771
- email: z.string().optional(),
772
- url: z.string().optional(),
773
- }),
774
- ])
775
- .optional(),
776
- /** Project/marketing homepage maps to the listing `website` (Claude field). */
777
- homepage: z.string().optional(),
778
- /** Free-text search keywords (Claude field). */
779
- keywords: z.array(z.string()).optional(),
780
- /** Taxonomy category beyond the runnable kinds (Claude field). */
781
- category: z.string().optional(),
782
- /** SPDX-ish license identifier (Claude field). */
783
- license: z.string().optional(),
784
- /** Square logo/icon URL for the listing card + detail header. */
785
- iconUrl: z.string().optional(),
786
- /**
787
- * Icon-primitive id for the listing card (Ryu extension): an Iconify/icons0
788
- * `prefix:name`, a bare Hugeicons name, or a URL, resolved by the shared `Icon`
789
- * primitive. A monochrome GLYPH masked with the current text colour — distinct
790
- * from `iconUrl` (a raster logo). Falls back to `iconUrl` when omitted.
791
- */
792
- icon: z.string().optional(),
793
- /**
794
- * Dithered-gradient background for the card's icon square (Ryu extension),
795
- * mirroring dither-kit's `DitherGradient` props. `from`/`to` are a palette-colour
796
- * name (`green`, `blue`, `purple`, `pink`, `orange`, `red`, `grey`) or a hue
797
- * number (0–360); `direction` is where `to` ends up. Renders behind the glyph in
798
- * place of a flat `iconBackground`; the render layer validates + falls back.
799
- */
800
- iconDither: z
801
- .object({
802
- from: z.union([z.string(), z.number()]),
803
- to: z.union([z.string(), z.number()]).optional(),
804
- direction: z.enum(["up", "down", "left", "right"]).optional(),
805
- })
806
- .optional(),
807
- /** Ordered App-Store-style screenshot gallery URLs (Ryu extension). */
808
- screenshots: z.array(z.string()).optional(),
809
- /** Privacy policy URL surfaced on detail (Ryu extension). */
810
- privacyPolicyUrl: z.string().optional(),
811
- /** Terms-of-service URL surfaced on detail (Ryu extension). */
812
- termsOfServiceUrl: z.string().optional(),
813
- /**
814
- * Human-readable capability strings (Ryu extension). When omitted the control
815
- * plane derives a default from `permission_grants`, so declaring this is only
816
- * needed to override the derived labels.
817
- */
818
- capabilities: z.array(z.string()).optional(),
819
- /** Example prompt chips shown on detail (Ryu extension). */
820
- examplePrompts: z.array(z.string()).optional(),
821
- /**
822
- * Optional companion/config card (Ryu extension): a single setup step or an
823
- * array of steps guiding the user through post-install configuration.
824
- */
825
- setup: z.union([SetupStepSchema, z.array(SetupStepSchema)]).optional(),
826
- });
908
+ /**
909
+ * VS-Code-style activation events (`"*"`, `"onStartup"`, `"onChat"`,
910
+ * `"onCommand:<id>"`). Empty = eager. Turn-hook plugins are driven by their
911
+ * enabled flag, so `["*"]` is the usual value.
912
+ */
913
+ activation_events: z.array(z.string()).default([]),
914
+
915
+ /**
916
+ * Contribution points: server-side turn hooks + declarative UI widgets.
917
+ * Absent for a plugin that contributes nothing here.
918
+ */
919
+ contributes: ContributesSchema.optional(),
920
+
921
+ /**
922
+ * **Plugin-to-plugin dependencies** — the other plugins this one needs. Core
923
+ * resolves them into a topological enable order (dependencies enable first;
924
+ * disabling one is refused while an enabled dependent needs it).
925
+ *
926
+ * Absent = **no dependencies**, the backward-compatible default. Kept
927
+ * `.optional()` (never defaulted) so a manifest that declares none serialises
928
+ * with no `requires` key at all, exactly like Core's
929
+ * `#[serde(skip_serializing_if = "Option::is_none")]`.
930
+ */
931
+ requires: RequiresSchema.optional(),
932
+
933
+ /**
934
+ * Host surfaces this plugin runs on. **Empty or absent = runs on EVERY
935
+ * surface** the backward-compatible default, which must never be read as
936
+ * "runs nowhere". Core filters only when the list is explicitly non-empty, and
937
+ * only at the read boundary (`GET /api/plugins`, keyed on `x-ryu-surface`), so
938
+ * an unsupported-target plugin stays installable and inspectable.
939
+ */
940
+ targets: z.array(SurfaceSchema).default([]),
941
+
942
+ /** Rich per-surface declarations (`support`, UI, contributed commands, ).
943
+ * Core owns and validates the nested vocabulary. */
944
+ surfaces: z.unknown().optional(),
945
+
946
+ /**
947
+ * Host version floors — the semver requirement each surface must satisfy for
948
+ * this plugin to install. Mirrors Core's `EnginesReq`
949
+ * (`crates/core/kernel-contracts/src/manifest.rs`).
950
+ *
951
+ * `ryu` is the **Core** floor and the only required key (it is the legacy
952
+ * spelling; every manifest in the wild carries just that one). The rest are
953
+ * optional per-surface floors.
954
+ *
955
+ * REGRESSION THIS FIXES: `engines` was absent from this schema entirely, and
956
+ * zod strips unlisted keys — so `ryu pack` silently dropped the whole block
957
+ * from every bundle it produced. A plugin could declare a Core floor, publish,
958
+ * and ship a bundle that declared none. Any new host floor MUST be added here
959
+ * as well as in the Rust contract, or it does not survive packing.
960
+ */
961
+ engines: EnginesReqSchema.optional(),
962
+
963
+ /**
964
+ * Optional per-item AFFILIATE terms: the commission paid to a referrer when a
965
+ * referred user buys this (paid) item. `value` is basis points for `percent`
966
+ * (2000 = 20%) or minor units (cents) for `flat`. Absent (or `enabled:false`)
967
+ * falls back to the seller org owner's default affiliate terms. This is the
968
+ * authoring surface for the marketplace publish body's `affiliate` field (the
969
+ * server re-validates it); it only takes effect on a paid item.
970
+ */
971
+ affiliate: z
972
+ .object({
973
+ enabled: z.boolean().default(false),
974
+ rule: z
975
+ .object({
976
+ type: z.enum(["percent", "flat"]),
977
+ value: z.number().nonnegative(),
978
+ recurring: z.boolean().default(false),
979
+ durationMonths: z.number().int().positive().nullish(),
980
+ fundedBy: z.enum(["platform", "seller"]).default("platform"),
981
+ })
982
+ .optional(),
983
+ })
984
+ .optional(),
985
+
986
+ // ── Rich listing metadata (Phase 1.5) ──────────────────────────────────────
987
+ // Optional store-listing fields a plugin author declares so the marketplace
988
+ // detail dialog renders a richer App-Store-style preview. Field names align
989
+ // with the Claude `.claude-plugin/marketplace.json` plugin-entry standard where
990
+ // one exists (`author`, `homepage`, `keywords`, `category`, `license`); the
991
+ // rest are Ryu extensions. `ryu publish` forwards these FLAT into the publish
992
+ // body (not inside the signed manifest blob) so the control plane stores them.
993
+ // All optional + additive: a manifest omitting them still validates.
994
+
995
+ /** Longer plain/markdown description shown in the detail dialog. */
996
+ description: z.string().optional(),
997
+ /** Short one-line pitch shown under the name (Ryu extension). */
998
+ tagline: z.string().optional(),
999
+ /**
1000
+ * Publisher identity. A bare string OR a Claude-style object; `ryu publish`
1001
+ * resolves it to the display `developer` (`author.name` when an object).
1002
+ */
1003
+ author: z
1004
+ .union([
1005
+ z.string(),
1006
+ z.object({
1007
+ name: z.string(),
1008
+ email: z.string().optional(),
1009
+ url: z.string().optional(),
1010
+ }),
1011
+ ])
1012
+ .optional(),
1013
+ /** Public source repository URL (Claude/Codex `repository`). */
1014
+ repository: z.string().url().optional(),
1015
+ /** True when the provider operates outside the local Ryu runtime. */
1016
+ external: z.boolean().optional(),
1017
+ /** Project/marketing homepage — maps to the listing `website` (Claude field). */
1018
+ homepage: z.string().optional(),
1019
+ /** Free-text search keywords (Claude field). */
1020
+ keywords: z.array(z.string()).optional(),
1021
+ /** Stable Marketplace filter labels (Ryu extension). */
1022
+ tags: z.array(z.string()).optional(),
1023
+ /** Taxonomy category beyond the runnable kinds (Claude field). */
1024
+ category: z.string().optional(),
1025
+ /** SPDX-ish license identifier (Claude field). */
1026
+ license: z.string().optional(),
1027
+ /** Square logo/icon URL for the listing card + detail header. */
1028
+ iconUrl: z.string().optional(),
1029
+ /**
1030
+ * Icon-primitive id for the listing card (Ryu extension): an Iconify/icons0
1031
+ * `prefix:name`, a bare Hugeicons name, or a URL, resolved by the shared `Icon`
1032
+ * primitive. A monochrome GLYPH masked with the current text colour — distinct
1033
+ * from `iconUrl` (a raster logo). Falls back to `iconUrl` when omitted.
1034
+ */
1035
+ icon: z.string().optional(),
1036
+ /** Optional detail-page hero banner metadata forwarded to the marketplace. */
1037
+ banner: z.record(z.string(), z.unknown()).optional(),
1038
+ /**
1039
+ * Dithered-gradient background for the card's icon square (Ryu extension),
1040
+ * mirroring dither-kit's `DitherGradient` props. `from`/`to` are a palette-colour
1041
+ * name (`green`, `blue`, `purple`, `pink`, `orange`, `red`, `grey`) or a hue
1042
+ * number (0–360); `direction` is where `to` ends up. Renders behind the glyph in
1043
+ * place of a flat `iconBackground`; the render layer validates + falls back.
1044
+ */
1045
+ iconDither: z
1046
+ .object({
1047
+ from: z.union([z.string(), z.number()]),
1048
+ to: z.union([z.string(), z.number()]).optional(),
1049
+ direction: z.enum(["up", "down", "left", "right"]).optional(),
1050
+ })
1051
+ .optional(),
1052
+ /** Ordered App-Store-style screenshot gallery URLs (Ryu extension). */
1053
+ screenshots: z.array(z.string()).optional(),
1054
+ /** Privacy policy URL surfaced on detail (Ryu extension). */
1055
+ privacyPolicyUrl: z.string().optional(),
1056
+ /** Terms-of-service URL surfaced on detail (Ryu extension). */
1057
+ termsOfServiceUrl: z.string().optional(),
1058
+ /**
1059
+ * Human-readable capability strings (Ryu extension). When omitted the control
1060
+ * plane derives a default from `permission_grants`, so declaring this is only
1061
+ * needed to override the derived labels.
1062
+ */
1063
+ capabilities: z.array(z.string()).optional(),
1064
+ /** Example prompt chips shown on detail (Ryu extension). */
1065
+ examplePrompts: z.array(z.string()).optional(),
1066
+ /**
1067
+ * Optional companion/config card (Ryu extension): a single setup step or an
1068
+ * array of steps guiding the user through post-install configuration.
1069
+ */
1070
+ setup: z.union([SetupStepSchema, z.array(SetupStepSchema)]).optional(),
1071
+ })
1072
+ // Core's Rust-derived schema is the full wire authority. Keep this deliberately
1073
+ // forward-compatible so a newly-added Core field survives SDK pack/publish even
1074
+ // before the simpler authoring schema grows first-class validation for it.
1075
+ .passthrough()
1076
+ .superRefine((manifest, context) => {
1077
+ const hasOAuthServer = Object.values(manifest.mcp_servers ?? {}).some(
1078
+ (server) => server.auth?.type === "oauth"
1079
+ );
1080
+ if (!hasOAuthServer) {
1081
+ return;
1082
+ }
1083
+ for (const grant of ["mcp:server", "identity.read"] as const) {
1084
+ if (!manifest.permission_grants.includes(grant)) {
1085
+ context.addIssue({
1086
+ code: "custom",
1087
+ message: `OAuth MCP servers require the ${grant} permission grant`,
1088
+ path: ["permission_grants"],
1089
+ });
1090
+ }
1091
+ }
1092
+ });
827
1093
 
828
1094
  export type PluginManifest = z.infer<typeof PluginManifestSchema>;
829
1095