@ryuhq/sdk 0.1.12 → 0.1.14

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.
@@ -293,6 +293,24 @@ var SurfaceSchema = z.enum([
293
293
  /** The terminal client. */
294
294
  "cli"
295
295
  ]);
296
+ var EnginesReqSchema = z.object({
297
+ /** Floor for the terminal (`cli`) surface. */
298
+ cli: z.string().optional(),
299
+ /** Floor for the Tauri desktop app. */
300
+ desktop: z.string().optional(),
301
+ /** Floor for the browser extension. */
302
+ extension: z.string().optional(),
303
+ /** Floor for the Ryu Gateway. */
304
+ gateway: z.string().optional(),
305
+ /** Floor for the dynamic-island companion. */
306
+ island: z.string().optional(),
307
+ /** Floor for the mobile app. */
308
+ mobile: z.string().optional(),
309
+ /** Floor for the running **Core** (e.g. `">=0.3.0"`). Required. */
310
+ ryu: z.string().min(1, "engines.ryu is required when engines is present"),
311
+ /** Floor for the Next.js web app. */
312
+ web: z.string().optional()
313
+ });
296
314
  var PluginManifestSchema = z.object({
297
315
  /** Reverse-domain unique identifier (e.g. `"com.example.my-plugin"`). */
298
316
  id: z.string().min(1, "id is required"),
@@ -359,6 +377,22 @@ var PluginManifestSchema = z.object({
359
377
  * an unsupported-target plugin stays installable and inspectable.
360
378
  */
361
379
  targets: z.array(SurfaceSchema).default([]),
380
+ /**
381
+ * Host version floors — the semver requirement each surface must satisfy for
382
+ * this plugin to install. Mirrors Core's `EnginesReq`
383
+ * (`crates/core/kernel-contracts/src/manifest.rs`).
384
+ *
385
+ * `ryu` is the **Core** floor and the only required key (it is the legacy
386
+ * spelling; every manifest in the wild carries just that one). The rest are
387
+ * optional per-surface floors.
388
+ *
389
+ * REGRESSION THIS FIXES: `engines` was absent from this schema entirely, and
390
+ * zod strips unlisted keys — so `ryu pack` silently dropped the whole block
391
+ * from every bundle it produced. A plugin could declare a Core floor, publish,
392
+ * and ship a bundle that declared none. Any new host floor MUST be added here
393
+ * as well as in the Rust contract, or it does not survive packing.
394
+ */
395
+ engines: EnginesReqSchema.optional(),
362
396
  /**
363
397
  * Optional per-item AFFILIATE terms: the commission paid to a referrer when a
364
398
  * referred user buys this (paid) item. `value` is basis points for `percent`
@@ -477,6 +511,7 @@ export {
477
511
  CapabilityReqSchema,
478
512
  RequiresSchema,
479
513
  SurfaceSchema,
514
+ EnginesReqSchema,
480
515
  PluginManifestSchema,
481
516
  validatePluginId,
482
517
  validateManifestStrict,
package/dist/cli.cjs CHANGED
@@ -777,6 +777,24 @@ var SurfaceSchema = import_zod.z.enum([
777
777
  /** The terminal client. */
778
778
  "cli"
779
779
  ]);
780
+ var EnginesReqSchema = import_zod.z.object({
781
+ /** Floor for the terminal (`cli`) surface. */
782
+ cli: import_zod.z.string().optional(),
783
+ /** Floor for the Tauri desktop app. */
784
+ desktop: import_zod.z.string().optional(),
785
+ /** Floor for the browser extension. */
786
+ extension: import_zod.z.string().optional(),
787
+ /** Floor for the Ryu Gateway. */
788
+ gateway: import_zod.z.string().optional(),
789
+ /** Floor for the dynamic-island companion. */
790
+ island: import_zod.z.string().optional(),
791
+ /** Floor for the mobile app. */
792
+ mobile: import_zod.z.string().optional(),
793
+ /** Floor for the running **Core** (e.g. `">=0.3.0"`). Required. */
794
+ ryu: import_zod.z.string().min(1, "engines.ryu is required when engines is present"),
795
+ /** Floor for the Next.js web app. */
796
+ web: import_zod.z.string().optional()
797
+ });
780
798
  var PluginManifestSchema = import_zod.z.object({
781
799
  /** Reverse-domain unique identifier (e.g. `"com.example.my-plugin"`). */
782
800
  id: import_zod.z.string().min(1, "id is required"),
@@ -843,6 +861,22 @@ var PluginManifestSchema = import_zod.z.object({
843
861
  * an unsupported-target plugin stays installable and inspectable.
844
862
  */
845
863
  targets: import_zod.z.array(SurfaceSchema).default([]),
864
+ /**
865
+ * Host version floors — the semver requirement each surface must satisfy for
866
+ * this plugin to install. Mirrors Core's `EnginesReq`
867
+ * (`crates/core/kernel-contracts/src/manifest.rs`).
868
+ *
869
+ * `ryu` is the **Core** floor and the only required key (it is the legacy
870
+ * spelling; every manifest in the wild carries just that one). The rest are
871
+ * optional per-surface floors.
872
+ *
873
+ * REGRESSION THIS FIXES: `engines` was absent from this schema entirely, and
874
+ * zod strips unlisted keys — so `ryu pack` silently dropped the whole block
875
+ * from every bundle it produced. A plugin could declare a Core floor, publish,
876
+ * and ship a bundle that declared none. Any new host floor MUST be added here
877
+ * as well as in the Rust contract, or it does not survive packing.
878
+ */
879
+ engines: EnginesReqSchema.optional(),
846
880
  /**
847
881
  * Optional per-item AFFILIATE terms: the commission paid to a referrer when a
848
882
  * referred user buys this (paid) item. `value` is basis points for `percent`
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
3
  PluginManifestSchema
4
- } from "./chunk-CUY2QOFC.js";
4
+ } from "./chunk-AO2KJRDD.js";
5
5
  import {
6
6
  AGENT_PLUGIN_MANIFEST_FILE,
7
7
  AGENT_PLUGIN_MCP_FILE,
package/dist/index.cjs CHANGED
@@ -1123,6 +1123,24 @@ var SurfaceSchema = import_zod.z.enum([
1123
1123
  /** The terminal client. */
1124
1124
  "cli"
1125
1125
  ]);
1126
+ var EnginesReqSchema = import_zod.z.object({
1127
+ /** Floor for the terminal (`cli`) surface. */
1128
+ cli: import_zod.z.string().optional(),
1129
+ /** Floor for the Tauri desktop app. */
1130
+ desktop: import_zod.z.string().optional(),
1131
+ /** Floor for the browser extension. */
1132
+ extension: import_zod.z.string().optional(),
1133
+ /** Floor for the Ryu Gateway. */
1134
+ gateway: import_zod.z.string().optional(),
1135
+ /** Floor for the dynamic-island companion. */
1136
+ island: import_zod.z.string().optional(),
1137
+ /** Floor for the mobile app. */
1138
+ mobile: import_zod.z.string().optional(),
1139
+ /** Floor for the running **Core** (e.g. `">=0.3.0"`). Required. */
1140
+ ryu: import_zod.z.string().min(1, "engines.ryu is required when engines is present"),
1141
+ /** Floor for the Next.js web app. */
1142
+ web: import_zod.z.string().optional()
1143
+ });
1126
1144
  var PluginManifestSchema = import_zod.z.object({
1127
1145
  /** Reverse-domain unique identifier (e.g. `"com.example.my-plugin"`). */
1128
1146
  id: import_zod.z.string().min(1, "id is required"),
@@ -1189,6 +1207,22 @@ var PluginManifestSchema = import_zod.z.object({
1189
1207
  * an unsupported-target plugin stays installable and inspectable.
1190
1208
  */
1191
1209
  targets: import_zod.z.array(SurfaceSchema).default([]),
1210
+ /**
1211
+ * Host version floors — the semver requirement each surface must satisfy for
1212
+ * this plugin to install. Mirrors Core's `EnginesReq`
1213
+ * (`crates/core/kernel-contracts/src/manifest.rs`).
1214
+ *
1215
+ * `ryu` is the **Core** floor and the only required key (it is the legacy
1216
+ * spelling; every manifest in the wild carries just that one). The rest are
1217
+ * optional per-surface floors.
1218
+ *
1219
+ * REGRESSION THIS FIXES: `engines` was absent from this schema entirely, and
1220
+ * zod strips unlisted keys — so `ryu pack` silently dropped the whole block
1221
+ * from every bundle it produced. A plugin could declare a Core floor, publish,
1222
+ * and ship a bundle that declared none. Any new host floor MUST be added here
1223
+ * as well as in the Rust contract, or it does not survive packing.
1224
+ */
1225
+ engines: EnginesReqSchema.optional(),
1192
1226
  /**
1193
1227
  * Optional per-item AFFILIATE terms: the commission paid to a referrer when a
1194
1228
  * referred user buys this (paid) item. `value` is basis points for `percent`
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  coreManifestJsonSchema,
13
13
  validateManifestStrict,
14
14
  validatePluginId
15
- } from "./chunk-CUY2QOFC.js";
15
+ } from "./chunk-AO2KJRDD.js";
16
16
  import {
17
17
  Agent,
18
18
  PRIMITIVE_BINDINGS,
package/dist/manifest.cjs CHANGED
@@ -24,6 +24,7 @@ __export(manifest_exports, {
24
24
  CapabilityReqSchema: () => CapabilityReqSchema,
25
25
  CompanionSurfaceSchema: () => CompanionSurfaceSchema,
26
26
  ContributesSchema: () => ContributesSchema,
27
+ EnginesReqSchema: () => EnginesReqSchema,
27
28
  HookEventContributionSchema: () => HookEventContributionSchema,
28
29
  OutputStyleContributionSchema: () => OutputStyleContributionSchema,
29
30
  PiExtensionContributionSchema: () => PiExtensionContributionSchema,
@@ -342,6 +343,24 @@ var SurfaceSchema = import_zod.z.enum([
342
343
  /** The terminal client. */
343
344
  "cli"
344
345
  ]);
346
+ var EnginesReqSchema = import_zod.z.object({
347
+ /** Floor for the terminal (`cli`) surface. */
348
+ cli: import_zod.z.string().optional(),
349
+ /** Floor for the Tauri desktop app. */
350
+ desktop: import_zod.z.string().optional(),
351
+ /** Floor for the browser extension. */
352
+ extension: import_zod.z.string().optional(),
353
+ /** Floor for the Ryu Gateway. */
354
+ gateway: import_zod.z.string().optional(),
355
+ /** Floor for the dynamic-island companion. */
356
+ island: import_zod.z.string().optional(),
357
+ /** Floor for the mobile app. */
358
+ mobile: import_zod.z.string().optional(),
359
+ /** Floor for the running **Core** (e.g. `">=0.3.0"`). Required. */
360
+ ryu: import_zod.z.string().min(1, "engines.ryu is required when engines is present"),
361
+ /** Floor for the Next.js web app. */
362
+ web: import_zod.z.string().optional()
363
+ });
345
364
  var PluginManifestSchema = import_zod.z.object({
346
365
  /** Reverse-domain unique identifier (e.g. `"com.example.my-plugin"`). */
347
366
  id: import_zod.z.string().min(1, "id is required"),
@@ -408,6 +427,22 @@ var PluginManifestSchema = import_zod.z.object({
408
427
  * an unsupported-target plugin stays installable and inspectable.
409
428
  */
410
429
  targets: import_zod.z.array(SurfaceSchema).default([]),
430
+ /**
431
+ * Host version floors — the semver requirement each surface must satisfy for
432
+ * this plugin to install. Mirrors Core's `EnginesReq`
433
+ * (`crates/core/kernel-contracts/src/manifest.rs`).
434
+ *
435
+ * `ryu` is the **Core** floor and the only required key (it is the legacy
436
+ * spelling; every manifest in the wild carries just that one). The rest are
437
+ * optional per-surface floors.
438
+ *
439
+ * REGRESSION THIS FIXES: `engines` was absent from this schema entirely, and
440
+ * zod strips unlisted keys — so `ryu pack` silently dropped the whole block
441
+ * from every bundle it produced. A plugin could declare a Core floor, publish,
442
+ * and ship a bundle that declared none. Any new host floor MUST be added here
443
+ * as well as in the Rust contract, or it does not survive packing.
444
+ */
445
+ engines: EnginesReqSchema.optional(),
411
446
  /**
412
447
  * Optional per-item AFFILIATE terms: the commission paid to a referrer when a
413
448
  * referred user buys this (paid) item. `value` is basis points for `percent`
@@ -514,6 +549,7 @@ function coreManifestJsonSchema() {
514
549
  CapabilityReqSchema,
515
550
  CompanionSurfaceSchema,
516
551
  ContributesSchema,
552
+ EnginesReqSchema,
517
553
  HookEventContributionSchema,
518
554
  OutputStyleContributionSchema,
519
555
  PiExtensionContributionSchema,
@@ -334,6 +334,31 @@ declare const SurfaceSchema: z.ZodEnum<{
334
334
  cli: "cli";
335
335
  }>;
336
336
  type Surface = z.infer<typeof SurfaceSchema>;
337
+ /**
338
+ * The `engines` block: a semver **requirement** per host surface, mirroring
339
+ * VS-Code's `engines.vscode`. Mirrors Core's `EnginesReq`.
340
+ *
341
+ * `ryu` is the Core floor, named that way for backwards compatibility — every
342
+ * manifest written before per-surface floors existed spells it `ryu`, and Core's
343
+ * `EnginesReq::floor_for` maps the `core` surface onto it. The remaining keys are
344
+ * optional floors for the surfaces a plugin actually touches.
345
+ *
346
+ * Values are NOT validated as semver ranges here: Core is the authority and
347
+ * rejects an unparseable requirement at manifest load with a precise message.
348
+ * Duplicating a range parser in the SDK would only create a second, drifting
349
+ * opinion about what `">=1.2, <2"` means.
350
+ */
351
+ declare const EnginesReqSchema: z.ZodObject<{
352
+ cli: z.ZodOptional<z.ZodString>;
353
+ desktop: z.ZodOptional<z.ZodString>;
354
+ extension: z.ZodOptional<z.ZodString>;
355
+ gateway: z.ZodOptional<z.ZodString>;
356
+ island: z.ZodOptional<z.ZodString>;
357
+ mobile: z.ZodOptional<z.ZodString>;
358
+ ryu: z.ZodString;
359
+ web: z.ZodOptional<z.ZodString>;
360
+ }, z.core.$strip>;
361
+ type EnginesReq = z.infer<typeof EnginesReqSchema>;
337
362
  /**
338
363
  * Full schema for a `manifest.json` Plugin manifest. Mirrors `PluginManifest` in
339
364
  * `apps/core/src/plugin_manifest/mod.rs`.
@@ -434,6 +459,16 @@ declare const PluginManifestSchema: z.ZodObject<{
434
459
  web: "web";
435
460
  cli: "cli";
436
461
  }>>>;
462
+ engines: z.ZodOptional<z.ZodObject<{
463
+ cli: z.ZodOptional<z.ZodString>;
464
+ desktop: z.ZodOptional<z.ZodString>;
465
+ extension: z.ZodOptional<z.ZodString>;
466
+ gateway: z.ZodOptional<z.ZodString>;
467
+ island: z.ZodOptional<z.ZodString>;
468
+ mobile: z.ZodOptional<z.ZodString>;
469
+ ryu: z.ZodString;
470
+ web: z.ZodOptional<z.ZodString>;
471
+ }, z.core.$strip>>;
437
472
  affiliate: z.ZodOptional<z.ZodObject<{
438
473
  enabled: z.ZodDefault<z.ZodBoolean>;
439
474
  rule: z.ZodOptional<z.ZodObject<{
@@ -508,4 +543,4 @@ declare function validateManifestStrict(manifestJson: string): string;
508
543
  */
509
544
  declare function coreManifestJsonSchema(): unknown;
510
545
 
511
- export { type AppDependency, AppDependencySchema, type CapabilityReq, CapabilityReqSchema, type CompanionSurface, CompanionSurfaceSchema, type Contributes, ContributesSchema, type HookEventContribution, HookEventContributionSchema, type OutputStyleContribution, OutputStyleContributionSchema, type PiExtensionContribution, PiExtensionContributionSchema, type PluginManifest, PluginManifestSchema, type Requires, RequiresSchema, type RunnableKind, RunnableKindSchema, type RunnableMeta, RunnableMetaSchema, type SetupStep, SetupStepSchema, type Surface, SurfaceSchema, type ToolAppConfig, ToolAppConfigSchema, type TurnHookContribution, TurnHookContributionSchema, type WidgetContribution, WidgetContributionSchema, coreManifestJsonSchema, labelImpersonatesSystemChrome, validateManifestStrict, validatePluginId };
546
+ export { type AppDependency, AppDependencySchema, type CapabilityReq, CapabilityReqSchema, type CompanionSurface, CompanionSurfaceSchema, type Contributes, ContributesSchema, type EnginesReq, EnginesReqSchema, type HookEventContribution, HookEventContributionSchema, type OutputStyleContribution, OutputStyleContributionSchema, type PiExtensionContribution, PiExtensionContributionSchema, type PluginManifest, PluginManifestSchema, type Requires, RequiresSchema, type RunnableKind, RunnableKindSchema, type RunnableMeta, RunnableMetaSchema, type SetupStep, SetupStepSchema, type Surface, SurfaceSchema, type ToolAppConfig, ToolAppConfigSchema, type TurnHookContribution, TurnHookContributionSchema, type WidgetContribution, WidgetContributionSchema, coreManifestJsonSchema, labelImpersonatesSystemChrome, validateManifestStrict, validatePluginId };
@@ -334,6 +334,31 @@ declare const SurfaceSchema: z.ZodEnum<{
334
334
  cli: "cli";
335
335
  }>;
336
336
  type Surface = z.infer<typeof SurfaceSchema>;
337
+ /**
338
+ * The `engines` block: a semver **requirement** per host surface, mirroring
339
+ * VS-Code's `engines.vscode`. Mirrors Core's `EnginesReq`.
340
+ *
341
+ * `ryu` is the Core floor, named that way for backwards compatibility — every
342
+ * manifest written before per-surface floors existed spells it `ryu`, and Core's
343
+ * `EnginesReq::floor_for` maps the `core` surface onto it. The remaining keys are
344
+ * optional floors for the surfaces a plugin actually touches.
345
+ *
346
+ * Values are NOT validated as semver ranges here: Core is the authority and
347
+ * rejects an unparseable requirement at manifest load with a precise message.
348
+ * Duplicating a range parser in the SDK would only create a second, drifting
349
+ * opinion about what `">=1.2, <2"` means.
350
+ */
351
+ declare const EnginesReqSchema: z.ZodObject<{
352
+ cli: z.ZodOptional<z.ZodString>;
353
+ desktop: z.ZodOptional<z.ZodString>;
354
+ extension: z.ZodOptional<z.ZodString>;
355
+ gateway: z.ZodOptional<z.ZodString>;
356
+ island: z.ZodOptional<z.ZodString>;
357
+ mobile: z.ZodOptional<z.ZodString>;
358
+ ryu: z.ZodString;
359
+ web: z.ZodOptional<z.ZodString>;
360
+ }, z.core.$strip>;
361
+ type EnginesReq = z.infer<typeof EnginesReqSchema>;
337
362
  /**
338
363
  * Full schema for a `manifest.json` Plugin manifest. Mirrors `PluginManifest` in
339
364
  * `apps/core/src/plugin_manifest/mod.rs`.
@@ -434,6 +459,16 @@ declare const PluginManifestSchema: z.ZodObject<{
434
459
  web: "web";
435
460
  cli: "cli";
436
461
  }>>>;
462
+ engines: z.ZodOptional<z.ZodObject<{
463
+ cli: z.ZodOptional<z.ZodString>;
464
+ desktop: z.ZodOptional<z.ZodString>;
465
+ extension: z.ZodOptional<z.ZodString>;
466
+ gateway: z.ZodOptional<z.ZodString>;
467
+ island: z.ZodOptional<z.ZodString>;
468
+ mobile: z.ZodOptional<z.ZodString>;
469
+ ryu: z.ZodString;
470
+ web: z.ZodOptional<z.ZodString>;
471
+ }, z.core.$strip>>;
437
472
  affiliate: z.ZodOptional<z.ZodObject<{
438
473
  enabled: z.ZodDefault<z.ZodBoolean>;
439
474
  rule: z.ZodOptional<z.ZodObject<{
@@ -508,4 +543,4 @@ declare function validateManifestStrict(manifestJson: string): string;
508
543
  */
509
544
  declare function coreManifestJsonSchema(): unknown;
510
545
 
511
- export { type AppDependency, AppDependencySchema, type CapabilityReq, CapabilityReqSchema, type CompanionSurface, CompanionSurfaceSchema, type Contributes, ContributesSchema, type HookEventContribution, HookEventContributionSchema, type OutputStyleContribution, OutputStyleContributionSchema, type PiExtensionContribution, PiExtensionContributionSchema, type PluginManifest, PluginManifestSchema, type Requires, RequiresSchema, type RunnableKind, RunnableKindSchema, type RunnableMeta, RunnableMetaSchema, type SetupStep, SetupStepSchema, type Surface, SurfaceSchema, type ToolAppConfig, ToolAppConfigSchema, type TurnHookContribution, TurnHookContributionSchema, type WidgetContribution, WidgetContributionSchema, coreManifestJsonSchema, labelImpersonatesSystemChrome, validateManifestStrict, validatePluginId };
546
+ export { type AppDependency, AppDependencySchema, type CapabilityReq, CapabilityReqSchema, type CompanionSurface, CompanionSurfaceSchema, type Contributes, ContributesSchema, type EnginesReq, EnginesReqSchema, type HookEventContribution, HookEventContributionSchema, type OutputStyleContribution, OutputStyleContributionSchema, type PiExtensionContribution, PiExtensionContributionSchema, type PluginManifest, PluginManifestSchema, type Requires, RequiresSchema, type RunnableKind, RunnableKindSchema, type RunnableMeta, RunnableMetaSchema, type SetupStep, SetupStepSchema, type Surface, SurfaceSchema, type ToolAppConfig, ToolAppConfigSchema, type TurnHookContribution, TurnHookContributionSchema, type WidgetContribution, WidgetContributionSchema, coreManifestJsonSchema, labelImpersonatesSystemChrome, validateManifestStrict, validatePluginId };
package/dist/manifest.js CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  CapabilityReqSchema,
4
4
  CompanionSurfaceSchema,
5
5
  ContributesSchema,
6
+ EnginesReqSchema,
6
7
  HookEventContributionSchema,
7
8
  OutputStyleContributionSchema,
8
9
  PiExtensionContributionSchema,
@@ -19,12 +20,13 @@ import {
19
20
  labelImpersonatesSystemChrome,
20
21
  validateManifestStrict,
21
22
  validatePluginId
22
- } from "./chunk-CUY2QOFC.js";
23
+ } from "./chunk-AO2KJRDD.js";
23
24
  export {
24
25
  AppDependencySchema,
25
26
  CapabilityReqSchema,
26
27
  CompanionSurfaceSchema,
27
28
  ContributesSchema,
29
+ EnginesReqSchema,
28
30
  HookEventContributionSchema,
29
31
  OutputStyleContributionSchema,
30
32
  PiExtensionContributionSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryuhq/sdk",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "description": "Ryu developer SDK: typed builders and CLI for authoring manifest.json Plugin bundles",
6
6
  "main": "./dist/index.cjs",
@@ -49,7 +49,7 @@
49
49
  "clean": "rm -rf dist"
50
50
  },
51
51
  "dependencies": {
52
- "@ryuhq/sdk-native": "0.1.12",
52
+ "@ryuhq/sdk-native": "0.1.14",
53
53
  "zod": "^4.1.13"
54
54
  },
55
55
  "devDependencies": {
@@ -117,7 +117,33 @@ export interface PluginManifest {
117
117
  */
118
118
  backend_sha256?: string | null;
119
119
  /**
120
- * Detail-page hero banner spec ({colors,style,seed}); opaque passthrough (Ryu ext).
120
+ * Detail-page hero banner spec; opaque passthrough (Ryu ext).
121
+ *
122
+ * The banner is the listing's OWN background, not its icon enlarged. Declared
123
+ * or not, the hero always paints something: with no `banner` the detail page
124
+ * derives its wash from `icon_dither`, so an app that never thinks about this
125
+ * key still opens on its own colour rather than a grey slab. Declaring one is
126
+ * how an author says "my hero is not just my icon, bigger".
127
+ *
128
+ * Accepted keys, all optional — the render layer picks the first that paints
129
+ * and falls back down the list, so an unknown or malformed value degrades to
130
+ * the derived wash rather than failing:
131
+ *
132
+ * - `background` — a flat CSS background (a colour, a `linear-gradient(…)`).
133
+ * - `imageUrl` — a raster banner, painted `object-cover`. http(s) only.
134
+ * - `colors: [String]` — two or more stops, ramped 135°.
135
+ * - `style: "gradient" | "dither" | "flat" | "image"` — how to treat the
136
+ * above; `dither` adds the noise overlay, `flat`/`image` select `background`
137
+ * / `imageUrl` explicitly.
138
+ * - `seed: Number` — the dither noise seed, so two apps sharing a palette do
139
+ * not share a texture.
140
+ *
141
+ * Kept as raw JSON like `icon_dither`, for the same reason: this is
142
+ * PUBLISHER-supplied and reaches a CSS background, so it must never fail the
143
+ * manifest parse, and the client validates before painting (`safeHttpUrl` for
144
+ * `imageUrl`; the flat string is trusted exactly as far as `icon_background`
145
+ * already is). Core does not read any of these keys — it copies the whole
146
+ * value onto the catalog entry — so a new one needs no Core release.
121
147
  */
122
148
  banner?: {
123
149
  [k: string]: unknown;
@@ -214,6 +240,24 @@ export interface PluginManifest {
214
240
  iconDither?: {
215
241
  [k: string]: unknown;
216
242
  };
243
+ /**
244
+ * Inset for the card's icon square (Ryu extension: `iconPadding`).
245
+ *
246
+ * A product LOGO that is edge-to-edge in its own art has no breathing room
247
+ * inside the square and reads as a sticker rather than an icon. One of
248
+ * `none` | `sm` | `md` | `lg`.
249
+ *
250
+ * Any value other than `none` ALSO letterboxes the art (`object-contain`)
251
+ * instead of cropping it. That coupling is load-bearing, not a convenience:
252
+ * a listing declaring a bare `iconUrl` (no `icon`) is not in the brand lane,
253
+ * so it is painted `object-cover` — inset alone would be silently inert for
254
+ * exactly the raw-logo case this field exists for.
255
+ *
256
+ * `Option<String>` rather than a Rust enum, for the same forward-compat
257
+ * reason `icon_dither` is raw JSON: an unknown value must never fail the
258
+ * manifest parse. The render layer validates and falls back.
259
+ */
260
+ iconPadding?: string | null;
217
261
  /**
218
262
  * Logo URL (contract key `iconUrl`; Ryu extension).
219
263
  */
@@ -595,6 +639,22 @@ export interface Contributes {
595
639
  * [`Contributes::message_actions`].
596
640
  */
597
641
  context_menu_items?: ContextMenuContribution[];
642
+ /**
643
+ * "New X" rows the app contributes to the shell's create menu (the sidebar
644
+ * footer "+"). See [`CreateActionContribution`].
645
+ *
646
+ * This exists because the create menu's only app seam used to be
647
+ * `sidebar_sections[].spec.create` — section-scoped, so an app that
648
+ * contributes no sidebar section could not put a row there at all. The shell
649
+ * therefore hardcoded rows for apps it happened to know about, and those rows
650
+ * stayed in the menu when the app was not installed, leading straight to an
651
+ * error page. A create action is its own contribution precisely so the row
652
+ * appears and disappears with the app.
653
+ *
654
+ * **Stored raw, validated at the chokepoint** — same rule as
655
+ * [`Contributes::context_menu_items`].
656
+ */
657
+ create_actions?: CreateActionContribution[];
598
658
  /**
599
659
  * **Deletable data categories** the app owns — one "Delete all X" row in
600
660
  * Settings → Danger Zone (see [`DataCategoryContribution`]).
@@ -847,6 +907,24 @@ export interface Contributes {
847
907
  * (no live list, just a label/icon + a client route). See [`SidebarButtonContribution`].
848
908
  */
849
909
  sidebar_buttons?: SidebarButtonContribution[];
910
+ /**
911
+ * App-registered sidebar **modes** — a named preset of the whole left sidebar:
912
+ * which sections it offers as tabs, and which one it opens on.
913
+ *
914
+ * The third axis of the sidebar contract, after "what sections exist"
915
+ * ([`Contributes::sidebar_sections`]) and "what nav rows exist"
916
+ * ([`Contributes::sidebar_buttons`]): **how the sidebar as a whole is
917
+ * arranged**. The shell ships three modes of its own (every section stacked;
918
+ * every section as a tab; Agent mode, which is the pair Sessions ⇄ Agents), and
919
+ * before this member an app could add a section to that list but could not
920
+ * propose an arrangement — so a plugin wanting the Grok/Hermes bot-mode posture
921
+ * had to ask for a shell change. See [`SidebarModeContribution`].
922
+ *
923
+ * Self-contained (it names sections, not runnables), so it stays out of
924
+ * [`Contributes::referenced_ids`]; served + tagged with the owning `plugin` id
925
+ * at `GET /api/plugins/contributions`.
926
+ */
927
+ sidebar_modes?: SidebarModeContribution[];
850
928
  /**
851
929
  * App-registered sidebar **sections** — a header plus a live list of rows the
852
930
  * shell fetches from a declared Core `/api/` path. Lets an app own its sidebar
@@ -1031,6 +1109,47 @@ export interface ContextMenuContribution {
1031
1109
  */
1032
1110
  order?: number | null;
1033
1111
  }
1112
+ /**
1113
+ * One "New X" row a plugin contributes to the shell's create menu (see
1114
+ * [`Contributes::create_actions`]).
1115
+ */
1116
+ export interface CreateActionContribution {
1117
+ args?: unknown;
1118
+ /**
1119
+ * Granted capability to invoke instead of navigating, plus static `args` —
1120
+ * for a create that is an action rather than a destination. Dispatched
1121
+ * through the same host seam as a context-menu row.
1122
+ */
1123
+ capability?: string | null;
1124
+ /**
1125
+ * Optional glyph id resolved by the shell's Icon primitive. The desktop's
1126
+ * create menu draws no icons today (its rows are label-only by design), so
1127
+ * this is read and ignored there — it exists for shells that do.
1128
+ */
1129
+ icon?: string | null;
1130
+ /**
1131
+ * Stable id for this row within the plugin.
1132
+ */
1133
+ id: string;
1134
+ /**
1135
+ * Row label, written as the user reads it — "New workflow", not "Workflow".
1136
+ */
1137
+ label: string;
1138
+ /**
1139
+ * Sort position among contributed rows (ascending).
1140
+ */
1141
+ order?: number | null;
1142
+ /**
1143
+ * In-app route the shell opens, e.g. `/workflows/new`. Must be a path, not a
1144
+ * URL: this is a navigation inside the shell, and accepting a scheme here
1145
+ * would turn a create row into an arbitrary-link affordance.
1146
+ */
1147
+ target?: string | null;
1148
+ /**
1149
+ * Title for the tab `target` opens. Falls back to `label`.
1150
+ */
1151
+ title?: string | null;
1152
+ }
1034
1153
  /**
1035
1154
  * One **deletable data category** an app owns (see [`Contributes::data_categories`]).
1036
1155
  *
@@ -1598,6 +1717,68 @@ export interface SidebarButtonContribution {
1598
1717
  */
1599
1718
  title: string;
1600
1719
  }
1720
+ /**
1721
+ * One app-registered **sidebar mode** — a named arrangement of the whole left
1722
+ * sidebar: the sections it offers as tabs, and the one it opens on.
1723
+ *
1724
+ * The shape is deliberately thin, and every field it does NOT have is the point:
1725
+ *
1726
+ * - **No renderer, no code.** A mode names existing sections. It cannot draw a row,
1727
+ * which is why it needs no grants and cannot be a carriage channel — the worst a
1728
+ * hostile mode can do is offer a tab list the user does not want, one menu row
1729
+ * away from being switched off.
1730
+ * - **No row style.** How a section's rows draw belongs to that SECTION
1731
+ * (`SidebarSectionSpec.rowStyle` in `@ryu/app-host/views`), because it is a
1732
+ * property of the feed, not of an arrangement: a roster of named bots wants
1733
+ * avatars whether or not the user is in a mode that features it. Putting it here
1734
+ * would also mean a mode reaching across into another contribution's rendering,
1735
+ * which is the coupling this member exists to avoid.
1736
+ * - **No `hidden` list.** A mode is a positive statement about what to show. The
1737
+ * sections it does not name are simply not tabs in it.
1738
+ *
1739
+ * Section ids are the shell's own keys (`agents`, `chats`, `spaces`, …) or another
1740
+ * contributed section's namespaced key (`plugin:<pluginId>:<sectionId>`). A named
1741
+ * section that does not resolve is dropped rather than failing the mode — an app
1742
+ * may legitimately name a section from a sibling app the user has not installed,
1743
+ * and losing one tab is a better answer than losing the mode.
1744
+ */
1745
+ export interface SidebarModeContribution {
1746
+ /**
1747
+ * Which of `sections` the mode opens on. Absent (or naming a section not in
1748
+ * `sections`) = the first one. This is the field that makes a mode an opinion
1749
+ * rather than a filter: the shell's own Agent mode lists Sessions first but
1750
+ * opens on Agents, because the roster is what the mode is for.
1751
+ */
1752
+ default_section?: string | null;
1753
+ /**
1754
+ * One-line description shown under the title where the mode is offered.
1755
+ */
1756
+ description?: string | null;
1757
+ /**
1758
+ * Optional glyph id resolved by the shell's Icon primitive (Iconify/Hugeicons).
1759
+ */
1760
+ icon?: string | null;
1761
+ /**
1762
+ * Stable id for this mode within the plugin (namespaced by the shell into the
1763
+ * stored mode key as `plugin:<pluginId>:<id>`, so two apps can both ship a
1764
+ * `bots` mode).
1765
+ */
1766
+ id: string;
1767
+ /**
1768
+ * Optional ordering hint among the modes on offer (lower = earlier).
1769
+ */
1770
+ order?: number | null;
1771
+ /**
1772
+ * The sections this mode offers as tabs, in display order. Empty = the mode is
1773
+ * inert and the shell ignores it; a mode with one entry is a legitimate
1774
+ * single-surface arrangement, not an error.
1775
+ */
1776
+ sections?: string[];
1777
+ /**
1778
+ * Label shown in the sidebar's mode menu and the Appearance tab.
1779
+ */
1780
+ title: string;
1781
+ }
1601
1782
  /**
1602
1783
  * One app-registered **sidebar section** — a header plus a live list of rows the
1603
1784
  * desktop's compact sidebar renderer draws (the app-owned replacement for the
@@ -1914,16 +2095,78 @@ export interface WidgetContribution {
1914
2095
  uri: string;
1915
2096
  }
1916
2097
  /**
1917
- * `engines` block — the required Ryu version, mirroring VS-Code's
1918
- * `engines.vscode`. `ryu` is a semver **requirement** string.
2098
+ * `engines` block — the **host** version floors, mirroring VS-Code's
2099
+ * `engines.vscode`. Every value is a semver **requirement** string.
2100
+ *
2101
+ * `ryu` is the Core floor and is the only required key (every manifest written
2102
+ * before per-surface floors existed carries just that one). The rest are optional
2103
+ * per-[`Surface`] floors: a plugin that needs a Gateway API added in 0.1.5 and a
2104
+ * desktop panel API added in 0.2.0 says so, instead of over-declaring one Core
2105
+ * floor and hoping the release train kept them in step.
2106
+ *
2107
+ * ## Why this is a flat struct and not a `BTreeMap<Surface, String>`
2108
+ *
2109
+ * The [`PluginManifest::surfaces`] map would be the obvious home, but it is
2110
+ * **absent from the SDK's zod mirror** (`packages/sdk/src/manifest.ts`), and zod
2111
+ * strips unlisted keys — so a floor declared there would be silently dropped from
2112
+ * every bundle `ryu pack` produces. `engines` is the block that already means
2113
+ * "host floor", it is what a manifest author reaches for, and mirroring it costs
2114
+ * one schema addition rather than a nested map.
2115
+ *
2116
+ * ## Unknown ≠ unsatisfied
2117
+ *
2118
+ * Core observes its own version and (via `/health`) the Gateway's. It does NOT
2119
+ * know the desktop, island, mobile, extension or web version — those are separate
2120
+ * installs that never report in. A floor against a surface whose version is
2121
+ * unknown is therefore **advisory, never blocking**: see
2122
+ * `HostVersions::evaluate`. Blocking on unknown would delist every plugin from
2123
+ * every surface Core cannot see, which is most of them.
1919
2124
  */
1920
2125
  export interface EnginesReq {
1921
2126
  /**
1922
- * Semver requirement the running Core version must satisfy (e.g. `">=0.3.0"`,
1923
- * `"^1.2"`). Parsed as a [`semver::VersionReq`]; an unparseable value or an
1924
- * unsatisfied requirement causes the loader to reject the manifest.
2127
+ * Floor for the **terminal** (`cli`) surface the TUI that dispatches
2128
+ * `ryu <app> <cmd>`.
2129
+ */
2130
+ cli?: string | null;
2131
+ /**
2132
+ * Floor for the **desktop** app (Tauri shell).
2133
+ */
2134
+ desktop?: string | null;
2135
+ /**
2136
+ * Floor for the **browser extension** surface.
2137
+ */
2138
+ extension?: string | null;
2139
+ /**
2140
+ * Floor for the **Gateway**. The one non-Core surface Core can actually
2141
+ * observe (it spawns the Gateway and reads `version` from its `/health`), so a
2142
+ * floor here is genuinely enforceable rather than advisory.
2143
+ */
2144
+ gateway?: string | null;
2145
+ /**
2146
+ * Floor for the **island** (the always-on overlay surface).
2147
+ */
2148
+ island?: string | null;
2149
+ /**
2150
+ * Floor for the **mobile** app. The one surface with a genuinely independent
2151
+ * release train (App Store / Play review lag), so it is the floor most likely
2152
+ * to be unsatisfied in practice.
2153
+ */
2154
+ mobile?: string | null;
2155
+ /**
2156
+ * Semver requirement the running **Core** version must satisfy (e.g.
2157
+ * `">=0.3.0"`, `"^1.2"`). Parsed as a [`semver::VersionReq`]; an unparseable
2158
+ * value causes the loader to reject the manifest, and an unsatisfied one moves
2159
+ * it to the incompatible lane (shown in the marketplace, refused at install).
2160
+ *
2161
+ * Named `ryu` rather than `core` for backwards compatibility: every manifest
2162
+ * in the wild spells it this way. [`EnginesReq::floor_for`] maps
2163
+ * [`Surface::Core`] onto it.
1925
2164
  */
1926
2165
  ryu: string;
2166
+ /**
2167
+ * Floor for the **web** surface.
2168
+ */
2169
+ web?: string | null;
1927
2170
  }
1928
2171
  /**
1929
2172
  * One declarative **stdio MCP server** a plugin registers (see
@@ -455,6 +455,88 @@ describe("AppBuilder", () => {
455
455
  // silently delete a plugin's dependencies BEFORE the manifest is signed. So every
456
456
  // case below asserts the field SURVIVES the parse, not merely that it parses.
457
457
 
458
+ describe("engines (host version floors)", () => {
459
+ /** The regression: `engines` was absent from `PluginManifestSchema`, and zod
460
+ * strips unlisted keys — so `ryu pack` dropped the block from every bundle. A
461
+ * plugin could declare a Core floor, publish, and ship a bundle declaring none. */
462
+ it("does not strip `engines` — the whole block survives the parse", () => {
463
+ const parsed = PluginManifestSchema.safeParse({
464
+ engines: {
465
+ cli: ">=0.1.0",
466
+ desktop: ">=0.2.0",
467
+ extension: ">=0.1.0",
468
+ gateway: ">=0.1.5",
469
+ island: ">=0.1.0",
470
+ mobile: ">=1.0.0",
471
+ ryu: ">=0.1.0",
472
+ web: ">=0.1.0",
473
+ },
474
+ id: "com.example.floors",
475
+ name: "Floors",
476
+ runnables: [],
477
+ version: "1.0.0",
478
+ });
479
+
480
+ expect(parsed.success).toBe(true);
481
+ if (!parsed.success) {
482
+ return;
483
+ }
484
+ expect(parsed.data.engines).toEqual({
485
+ cli: ">=0.1.0",
486
+ desktop: ">=0.2.0",
487
+ extension: ">=0.1.0",
488
+ gateway: ">=0.1.5",
489
+ island: ">=0.1.0",
490
+ mobile: ">=1.0.0",
491
+ ryu: ">=0.1.0",
492
+ web: ">=0.1.0",
493
+ });
494
+ });
495
+
496
+ it("keeps a legacy `{ ryu }`-only block intact and adds no sibling keys", () => {
497
+ const parsed = PluginManifestSchema.safeParse({
498
+ engines: { ryu: ">=0.1.0" },
499
+ id: "com.example.legacy-engines",
500
+ name: "Legacy",
501
+ runnables: [],
502
+ version: "1.0.0",
503
+ });
504
+
505
+ expect(parsed.success).toBe(true);
506
+ if (!parsed.success) {
507
+ return;
508
+ }
509
+ expect(parsed.data.engines).toEqual({ ryu: ">=0.1.0" });
510
+ });
511
+
512
+ it("leaves `engines` undefined when the manifest declares none", () => {
513
+ const parsed = PluginManifestSchema.safeParse({
514
+ id: "com.example.no-engines",
515
+ name: "None",
516
+ runnables: [],
517
+ version: "1.0.0",
518
+ });
519
+
520
+ expect(parsed.success).toBe(true);
521
+ if (!parsed.success) {
522
+ return;
523
+ }
524
+ expect(parsed.data.engines).toBeUndefined();
525
+ });
526
+
527
+ it("rejects an `engines` block with no Core floor", () => {
528
+ const parsed = PluginManifestSchema.safeParse({
529
+ engines: { desktop: ">=0.2.0" },
530
+ id: "com.example.bad-engines",
531
+ name: "Bad",
532
+ runnables: [],
533
+ version: "1.0.0",
534
+ });
535
+
536
+ expect(parsed.success).toBe(false);
537
+ });
538
+ });
539
+
458
540
  describe("requires / targets", () => {
459
541
  it("keeps a manifest with NEITHER requires nor targets valid (all 37 shipped plugins)", () => {
460
542
  const parsed = PluginManifestSchema.safeParse({
package/src/manifest.ts CHANGED
@@ -578,6 +578,43 @@ export const SurfaceSchema = z.enum([
578
578
 
579
579
  export type Surface = z.infer<typeof SurfaceSchema>;
580
580
 
581
+ // ── engines (host version floors) ────────────────────────────────────────────
582
+
583
+ /**
584
+ * The `engines` block: a semver **requirement** per host surface, mirroring
585
+ * VS-Code's `engines.vscode`. Mirrors Core's `EnginesReq`.
586
+ *
587
+ * `ryu` is the Core floor, named that way for backwards compatibility — every
588
+ * manifest written before per-surface floors existed spells it `ryu`, and Core's
589
+ * `EnginesReq::floor_for` maps the `core` surface onto it. The remaining keys are
590
+ * optional floors for the surfaces a plugin actually touches.
591
+ *
592
+ * Values are NOT validated as semver ranges here: Core is the authority and
593
+ * rejects an unparseable requirement at manifest load with a precise message.
594
+ * Duplicating a range parser in the SDK would only create a second, drifting
595
+ * opinion about what `">=1.2, <2"` means.
596
+ */
597
+ export const EnginesReqSchema = z.object({
598
+ /** Floor for the terminal (`cli`) surface. */
599
+ cli: z.string().optional(),
600
+ /** Floor for the Tauri desktop app. */
601
+ desktop: z.string().optional(),
602
+ /** Floor for the browser extension. */
603
+ extension: z.string().optional(),
604
+ /** Floor for the Ryu Gateway. */
605
+ gateway: z.string().optional(),
606
+ /** Floor for the dynamic-island companion. */
607
+ island: z.string().optional(),
608
+ /** Floor for the mobile app. */
609
+ mobile: z.string().optional(),
610
+ /** Floor for the running **Core** (e.g. `">=0.3.0"`). Required. */
611
+ ryu: z.string().min(1, "engines.ryu is required when engines is present"),
612
+ /** Floor for the Next.js web app. */
613
+ web: z.string().optional(),
614
+ });
615
+
616
+ export type EnginesReq = z.infer<typeof EnginesReqSchema>;
617
+
581
618
  // ── PluginManifest ───────────────────────────────────────────────────────────
582
619
 
583
620
  /**
@@ -669,6 +706,23 @@ export const PluginManifestSchema = z.object({
669
706
  */
670
707
  targets: z.array(SurfaceSchema).default([]),
671
708
 
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(),
725
+
672
726
  /**
673
727
  * Optional per-item AFFILIATE terms: the commission paid to a referrer when a
674
728
  * referred user buys this (paid) item. `value` is basis points for `percent`
@@ -204,9 +204,12 @@ export function httpPrimitiveTransport(
204
204
  return (parsed.text ?? "").trim();
205
205
  };
206
206
 
207
- // `/api/voice/speak` — Core streams raw `audio/wav` bytes back (not JSON, not a
208
- // data: URL). Convert the response to a renderable data: URL, as the desktop
209
- // host's rpc.ts does, so the shipped type contract ("returns a data: URL") holds.
207
+ // `/api/voice/speak` — Core streams raw audio bytes back (not JSON, not a
208
+ // data: URL). Usually `audio/wav`; the cloud engine (`engine: "gateway"`) can
209
+ // answer with whatever the routed provider produced, so the media type is read
210
+ // off the response rather than assumed. Convert to a renderable data: URL, as
211
+ // the desktop host's rpc.ts does, so the shipped type contract ("returns a
212
+ // data: URL") holds.
210
213
  const speakDirect = async (body: unknown): Promise<string> => {
211
214
  const res = await doFetch(`${base}/api/voice/speak`, {
212
215
  method: "POST",