@ryuhq/sdk 0.0.5 → 0.1.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.
@@ -167,10 +167,10 @@ describe("round-trip: SDK build → JSON → Core schema parse", () => {
167
167
  }
168
168
  });
169
169
 
170
- it("emitted plugin.json satisfies PluginManifestSchema (Core compat proof)", () => {
170
+ it("emitted manifest.json satisfies PluginManifestSchema (Core compat proof)", () => {
171
171
  // 1. Build a manifest using the SDK.
172
172
  const manifest = new PluginBuilder()
173
- .id("com.example.research-assistant")
173
+ .id("@example/research-assistant")
174
174
  .name("Research Assistant")
175
175
  .version("1.0.0")
176
176
  .runnable(agent().id("agent-researcher").name("Researcher").build())
@@ -188,8 +188,8 @@ describe("round-trip: SDK build → JSON → Core schema parse", () => {
188
188
  })
189
189
  .build();
190
190
 
191
- // 2. Emit to a temp plugin.json (simulating what `ryu pack` writes).
192
- const manifestPath = join(tmpDir, "plugin.json");
191
+ // 2. Emit to a temp manifest.json (simulating what `ryu pack` writes).
192
+ const manifestPath = join(tmpDir, "manifest.json");
193
193
  writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
194
194
 
195
195
  // 3. Read it back and parse through `PluginManifestSchema` — the same
@@ -204,7 +204,7 @@ describe("round-trip: SDK build → JSON → Core schema parse", () => {
204
204
  }
205
205
 
206
206
  const loaded = result.data;
207
- expect(loaded.id).toBe("com.example.research-assistant");
207
+ expect(loaded.id).toBe("@example/research-assistant");
208
208
  expect(loaded.runnables).toHaveLength(4);
209
209
  expect(loaded.permission_grants).toEqual([
210
210
  "mcp:web_search",
@@ -213,11 +213,11 @@ describe("round-trip: SDK build → JSON → Core schema parse", () => {
213
213
  expect(loaded.companion?.shortcut).toBe("ctrl+shift+r");
214
214
  });
215
215
 
216
- it("matches the Core fixture (sample.plugin.json)", () => {
216
+ it("matches the Core fixture (sample.manifest.json)", () => {
217
217
  // The Core Rust test (`sample_fixture_deserializes_into_app_manifest`)
218
218
  // asserts the same values — this verifies TS schema parity.
219
219
  const fixture = {
220
- id: "com.example.research-assistant",
220
+ id: "@example/research-assistant",
221
221
  name: "Research Assistant",
222
222
  version: "1.0.0",
223
223
  runnables: [
@@ -240,7 +240,7 @@ describe("round-trip: SDK build → JSON → Core schema parse", () => {
240
240
  return;
241
241
  }
242
242
 
243
- expect(result.data.id).toBe("com.example.research-assistant");
243
+ expect(result.data.id).toBe("@example/research-assistant");
244
244
  expect(result.data.runnables).toHaveLength(4);
245
245
  const kinds = result.data.runnables.map((r) => r.kind);
246
246
  expect(kinds).toContain("agent");
@@ -424,7 +424,13 @@ describe("AppBuilder", () => {
424
424
  expect(manifest.runnables).toHaveLength(2);
425
425
  expect(manifest.contributes?.widgets).toHaveLength(1);
426
426
  expect(manifest.contributes?.widgets[0]?.tool_id).toBe("checklist__render");
427
- expect(manifest.permission_grants).toEqual(["mcp:file_read"]);
427
+ // The author's own grant, plus the `widget:render` the builder adds because
428
+ // this app synthesises a widget — without it Core silently degrades the
429
+ // widget to plain text.
430
+ expect(manifest.permission_grants).toEqual([
431
+ "mcp:file_read",
432
+ "widget:render",
433
+ ]);
428
434
  });
429
435
 
430
436
  it("throws on missing id", () => {
@@ -475,15 +481,15 @@ describe("requires / targets", () => {
475
481
  .id("com.example.meetings")
476
482
  .name("Meetings")
477
483
  .version("1.0.0")
478
- .dependsOn("com.ryu.spaces", "1.2.0")
479
- .dependsOn("com.ryu.voice")
484
+ .dependsOn("@ryu/spaces", "1.2.0")
485
+ .dependsOn("@ryu/voice")
480
486
  .requiredGrant("spaces:docs")
481
487
  .build();
482
488
 
483
489
  // Survives the builder…
484
490
  expect(manifest.requires?.apps).toEqual([
485
- { id: "com.ryu.spaces", min_version: "1.2.0" },
486
- { id: "com.ryu.voice" },
491
+ { id: "@ryu/spaces", min_version: "1.2.0" },
492
+ { id: "@ryu/voice" },
487
493
  ]);
488
494
  expect(manifest.requires?.grants).toEqual(["spaces:docs"]);
489
495
 
@@ -509,7 +515,7 @@ describe("requires / targets", () => {
509
515
  name: "Partial",
510
516
  version: "1.0.0",
511
517
  runnables: [],
512
- requires: { apps: [{ id: "com.ryu.spaces" }] },
518
+ requires: { apps: [{ id: "@ryu/spaces" }] },
513
519
  });
514
520
 
515
521
  expect(parsed.success).toBe(true);
@@ -587,11 +593,11 @@ describe("requires / targets", () => {
587
593
  slug: "dep-app",
588
594
  uiEntry: "src/dep-app.tsx",
589
595
  tools: [{ name: "render", description: "Render" }],
590
- requires: { apps: [{ id: "com.ryu.spaces", min_version: "1.0.0" }] },
596
+ requires: { apps: [{ id: "@ryu/spaces", min_version: "1.0.0" }] },
591
597
  targets: ["desktop"],
592
598
  });
593
599
 
594
- expect(manifest.requires?.apps[0]?.id).toBe("com.ryu.spaces");
600
+ expect(manifest.requires?.apps[0]?.id).toBe("@ryu/spaces");
595
601
  expect(manifest.requires?.grants).toEqual([]);
596
602
  expect(manifest.targets).toEqual(["desktop"]);
597
603
 
@@ -608,3 +614,81 @@ describe("requires / targets", () => {
608
614
  expect(plain.targets).toEqual([]);
609
615
  });
610
616
  });
617
+
618
+ // ── contributes.lsp_servers (Claude Code language-server parity) ──────────────
619
+ //
620
+ // Same load-bearing property as the block above: `ryu pack` / `ryu publish`
621
+ // persist `PluginManifestSchema.safeParse(...).data`, so a contribution family
622
+ // missing from `ContributesSchema` is silently DELETED before the manifest is
623
+ // signed. These assert the declaration SURVIVES the parse — byte-for-byte, since
624
+ // the entry body is Claude Code's own camelCase vocabulary and Ryu is only its
625
+ // courier.
626
+
627
+ describe("contributes.lsp_servers", () => {
628
+ /** The `.lsp.json` example from Claude Code's plugins reference, verbatim. */
629
+ const claudeCodeGoServer = {
630
+ command: "gopls",
631
+ args: ["serve"],
632
+ extensionToLanguage: { ".go": "go" },
633
+ };
634
+
635
+ it("keeps a Claude Code language server through the pack-path parse", () => {
636
+ const parsed = PluginManifestSchema.safeParse({
637
+ id: "com.example.go-lsp",
638
+ name: "Go LSP",
639
+ version: "1.0.0",
640
+ runnables: [],
641
+ contributes: { lsp_servers: { go: claudeCodeGoServer } },
642
+ });
643
+
644
+ expect(parsed.success).toBe(true);
645
+ if (!parsed.success) {
646
+ return;
647
+ }
648
+ expect(parsed.data.contributes?.lsp_servers.go).toEqual(claudeCodeGoServer);
649
+ });
650
+
651
+ it("keeps an unknown entry key too (Claude Code owns the vocabulary)", () => {
652
+ // The entry is a loose record on purpose: typing the 13 documented fields
653
+ // here would strip a field from a newer Claude release on its way through
654
+ // `ryu pack` — the same silent deletion, one level down.
655
+ const parsed = PluginManifestSchema.safeParse({
656
+ id: "com.example.future-lsp",
657
+ name: "Future LSP",
658
+ version: "1.0.0",
659
+ runnables: [],
660
+ contributes: {
661
+ lsp_servers: {
662
+ go: { ...claudeCodeGoServer, someFutureClaudeField: { deep: true } },
663
+ },
664
+ },
665
+ });
666
+
667
+ expect(parsed.success).toBe(true);
668
+ if (!parsed.success) {
669
+ return;
670
+ }
671
+ expect(parsed.data.contributes?.lsp_servers.go).toMatchObject({
672
+ someFutureClaudeField: { deep: true },
673
+ });
674
+ });
675
+
676
+ it("yields an empty map, never undefined, when none is declared", () => {
677
+ const parsed = PluginManifestSchema.safeParse({
678
+ id: "com.example.legacy",
679
+ name: "Legacy",
680
+ version: "1.0.0",
681
+ runnables: [],
682
+ contributes: {},
683
+ });
684
+
685
+ expect(parsed.success).toBe(true);
686
+ if (!parsed.success) {
687
+ return;
688
+ }
689
+ // Core's field is `#[serde(default, skip_serializing_if = "…is_empty")]`, so
690
+ // an empty map here round-trips to a manifest with no `lsp_servers` key at
691
+ // all — every plugin predating this surface keeps parsing on both sides.
692
+ expect(parsed.data.contributes?.lsp_servers).toEqual({});
693
+ });
694
+ });
package/src/manifest.ts CHANGED
@@ -24,11 +24,11 @@ import { z } from "zod";
24
24
  // must therefore never hard-require the addon at module load. We load it lazily
25
25
  // on first use of a helper that needs it, cache it, and throw a descriptive
26
26
  // error only if a caller actually invokes those helpers without the addon.
27
- type NativeAddon = {
28
- validatePluginId(id: string): void;
27
+ interface NativeAddon {
29
28
  parseAndValidateManifest(manifestJson: string): string;
30
29
  pluginManifestJsonSchema(): string;
31
- };
30
+ validatePluginId(id: string): void;
31
+ }
32
32
 
33
33
  let cachedNative: NativeAddon | null = null;
34
34
  let nativeLoadError: Error | null = null;
@@ -157,20 +157,122 @@ export type CompanionSurface = z.infer<typeof CompanionSurfaceSchema>;
157
157
 
158
158
  /**
159
159
  * A server-side chat turn hook. Mirrors `TurnHookContribution` in
160
- * `apps/core/src/plugin_manifest/mod.rs`. `code` is a JS body run in the plugin
161
- * sandbox with `ctx` + `host` in scope; it returns a directive. Authors usually
162
- * build this via `defineTurnHook` rather than writing the string by hand.
160
+ * `crates/core/kernel-contracts/src/manifest.rs`. The body is a JS fragment run in
161
+ * the plugin sandbox with `ctx` + `host` in scope; it returns a directive.
162
+ *
163
+ * It arrives one of two ways, and **exactly one** must be present:
164
+ *
165
+ * - `code_file` — the authoring form: a path to a real `hooks/<name>.js` file next
166
+ * to the manifest. Readable, lintable, diffable, and reviewable for what it
167
+ * actually does. Every first-party plugin uses this.
168
+ * - `code` — the wire form: the body inline. `ryu pack` produces it by reading
169
+ * `code_file`, which is what keeps the whole hook body INSIDE the Gateway-signed
170
+ * surface; Core also accepts it directly for a hand-written or `defineTurnHook`
171
+ * generated manifest.
172
+ */
173
+ export const TurnHookContributionSchema = z
174
+ .object({
175
+ /** Stable id for this hook (unique within the plugin). */
176
+ id: z.string().min(1),
177
+ /** Turn boundary this fires on. Today only `"post_assistant_turn"`. */
178
+ on: z.string().min(1).default("post_assistant_turn"),
179
+ /** The JS hook body executed in the sandbox (returns a directive). */
180
+ code: z.string().min(1).optional(),
181
+ /** Path to the hook body, relative to the plugin root (`hooks/<name>.js`). */
182
+ code_file: z.string().min(1).optional(),
183
+ /**
184
+ * Cheap pre-gate mirroring Core's `HookMatch` (serde name `match` on
185
+ * `TurnHookContribution.run_when`). MUST round-trip through this schema:
186
+ * `ryu pack`/`publish` persist `safeParse(...).data`, so a field missing here
187
+ * is silently STRIPPED before signing — a tool-gated `pre_tool_use` hook
188
+ * (e.g. `tools: ["bash*"]`) would lose its gate and run on EVERY tool call.
189
+ */
190
+ match: z
191
+ .object({
192
+ /** Run only if the request set this composer flag true. */
193
+ flag: z.string().optional(),
194
+ /** Run if the last user message starts with any of these prefixes. */
195
+ commands: z.array(z.string()).default([]),
196
+ /** Run if the plugin has stored state for this conversation. */
197
+ stateful: z.boolean().default(false),
198
+ /** Run if `ctx.tool_name` matches any of these `*`-wildcard patterns. */
199
+ tools: z.array(z.string()).default([]),
200
+ })
201
+ .optional(),
202
+ })
203
+ // Fail closed, mirroring Core: declaring NEITHER would have the sandbox run an
204
+ // empty body, which no read site can tell apart from a hook that chose to do
205
+ // nothing; declaring BOTH gives two sources of truth for what executes.
206
+ .refine((h) => Boolean(h.code) !== Boolean(h.code_file), {
207
+ message:
208
+ "a turn hook must declare exactly one of 'code' (inline body) or 'code_file' (path to hooks/<name>.js)",
209
+ path: ["code_file"],
210
+ });
211
+
212
+ export type TurnHookContribution = z.infer<typeof TurnHookContributionSchema>;
213
+
214
+ /**
215
+ * One **app event** this plugin declares it emits. Mirrors `HookEventContribution`
216
+ * in `crates/core/kernel-contracts/src/manifest.rs`.
217
+ *
218
+ * `turn_hooks` is the *consuming* half of the hook system; this is the *providing*
219
+ * half. Declaring an event here lets any other plugin react to it with a
220
+ * `turn_hooks[].on` naming the event, and any workflow react to it with an `event`
221
+ * trigger — without the emitter knowing a consumer exists. The event is raised at
222
+ * runtime by this plugin's own sidecar calling the `events.emit` host capability.
223
+ *
224
+ * `id` MUST be `<this plugin's id>#<event name>`. Core validates the namespace half
225
+ * against the owning manifest at load and re-checks it on every emit, which is both
226
+ * what makes collisions with Core's own hook phases impossible (a Core phase never
227
+ * contains `#`) and what stops one app emitting another's events.
163
228
  */
164
- export const TurnHookContributionSchema = z.object({
165
- /** Stable id for this hook (unique within the plugin). */
229
+ export const HookEventContributionSchema = z.object({
230
+ /** Fully-qualified event id: `<plugin id>#<event name>`, e.g. `@acme/meetings#meeting.ended`. */
166
231
  id: z.string().min(1),
167
- /** Turn boundary this fires on. Today only `"post_assistant_turn"`. */
168
- on: z.string().min(1).default("post_assistant_turn"),
169
- /** The JS hook body executed in the sandbox (returns a directive). */
170
- code: z.string().min(1),
232
+ /** Human-readable title for the event picker. */
233
+ title: z.string().min(1),
234
+ /** What the event means and when it fires. */
235
+ description: z.string().optional(),
236
+ /** Example of the `ctx.event` payload. Documentation, not a validated schema. */
237
+ payload_example: z.record(z.string(), z.unknown()).optional(),
171
238
  });
172
239
 
173
- export type TurnHookContribution = z.infer<typeof TurnHookContributionSchema>;
240
+ export type HookEventContribution = z.infer<typeof HookEventContributionSchema>;
241
+
242
+ // ── PiExtensionContribution ───────────────────────────────────────────────────
243
+
244
+ /**
245
+ * One Pi extension the plugin ships — a TypeScript file the managed `ryu` (Pi)
246
+ * agent loads at process start. Mirrors Rust `PiExtensionContribution`.
247
+ *
248
+ * Carries a PATH, never a body: unlike `turn_hooks` there is no inline `code`
249
+ * twin, because nothing downstream reads the source as a string.
250
+ *
251
+ * That makes it a SIDECAR FILE, and `ryu pack` emits a single JSON bundle — so a
252
+ * plugin installed from a packed bundle arrives without its `pi-extensions/`
253
+ * directory and Core resolves the declaration to a visible skip. Same open gap as
254
+ * `skills/**`, which the bundle likewise does not carry. Today the path that works
255
+ * is a plugin whose directory is on disk (a built-in, a satellite checkout, a dev
256
+ * tree). Do not "fix" this by inlining the source into the manifest: a 50 KB
257
+ * TypeScript program escaped into a JSON string is the unauditable form the whole
258
+ * `code_file` extraction exists to prevent.
259
+ *
260
+ * Note this is UNSANDBOXED code: it runs inside the agent process with full host
261
+ * privilege, so Core gates it behind the operator-only `pi:extension` grant for
262
+ * any non-built-in plugin.
263
+ */
264
+ export const PiExtensionContributionSchema = z.object({
265
+ /** Stable id for this extension within the plugin (`[a-z0-9][a-z0-9._-]*`). */
266
+ id: z.string().min(1),
267
+ /** Path to the source, relative to the plugin root: `pi-extensions/<name>.ts`. */
268
+ file: z.string().min(1),
269
+ /** Optional one-liner describing what the extension adds to the agent. */
270
+ description: z.string().optional(),
271
+ });
272
+
273
+ export type PiExtensionContribution = z.infer<
274
+ typeof PiExtensionContributionSchema
275
+ >;
174
276
 
175
277
  // ── WidgetContribution (Ryu Apps) ─────────────────────────────────────────────
176
278
 
@@ -243,6 +345,11 @@ export type ToolAppConfig = z.infer<typeof ToolAppConfigSchema>;
243
345
  */
244
346
  export const ContributesSchema = z.object({
245
347
  turn_hooks: z.array(TurnHookContributionSchema).default([]),
348
+ /** App events this plugin EMITS — the provider half of the hook system, whose
349
+ * consumer half is `turn_hooks`. Mirrors the Rust `Contributes.hook_events`;
350
+ * omitting it here would have `ryu pack` strip every declared event before
351
+ * signing, leaving an app that emits events nothing is allowed to subscribe to. */
352
+ hook_events: z.array(HookEventContributionSchema).default([]),
246
353
  composer_controls: z.array(z.record(z.string(), z.unknown())).default([]),
247
354
  settings_tabs: z.array(z.record(z.string(), z.unknown())).default([]),
248
355
  slash_commands: z.array(z.record(z.string(), z.unknown())).default([]),
@@ -251,6 +358,50 @@ export const ContributesSchema = z.object({
251
358
  * `Contributes.widgets` field, without which the CLI's zod parse would strip
252
359
  * every widget an app authored here declares. */
253
360
  widgets: z.array(WidgetContributionSchema).default([]),
361
+ /** App-registered sidebar sections (header + live list) and buttons (single nav
362
+ * rows). Loosely typed here — the shell owns the spec vocabulary — matching how
363
+ * `composer_controls`/`settings_tabs` are declared. Mirrors the Rust-side
364
+ * `Contributes.sidebar_sections` / `Contributes.sidebar_buttons`. */
365
+ sidebar_sections: z.array(z.record(z.string(), z.unknown())).default([]),
366
+ sidebar_buttons: z.array(z.record(z.string(), z.unknown())).default([]),
367
+ /** App-registered workspace dock panels (a tab in the desktop's bottom/right
368
+ * dock). Loosely typed for the same reason as the surfaces above — the shell
369
+ * owns the `panel` render-mode vocabulary and the `spec` payload. Mirrors the
370
+ * Rust-side `Contributes.dock_panels`; without it the CLI's zod parse would
371
+ * strip the dock panel an app declares here. */
372
+ dock_panels: z.array(z.record(z.string(), z.unknown())).default([]),
373
+ /** Deletable data categories the app owns — one "Delete all X" row in Settings
374
+ * → Danger Zone. Mirrors the Rust-side `Contributes.data_categories`; without
375
+ * it the CLI's zod parse would strip the declaration before signing, and the
376
+ * app's danger-zone row would simply never appear on any node that installed
377
+ * the packed bundle. Loosely typed here for the same reason as the surfaces
378
+ * above — Core is the layer that types it, because Core is the layer that has
379
+ * to resolve the id to something that can actually delete the rows. */
380
+ data_categories: z.array(z.record(z.string(), z.unknown())).default([]),
381
+ /** Language servers the plugin declares, keyed by server name — the mirror of
382
+ * Claude Code's `.lsp.json` / `lspServers`, so a config written for either host
383
+ * loads in the other. Mirrors the Rust-side `Contributes.lsp_servers`; without
384
+ * it the CLI's zod parse would strip every language server a plugin declares,
385
+ * before the manifest is signed.
386
+ *
387
+ * The ENTRY is deliberately a loose record and not a 13-field `z.object()`
388
+ * mirroring `LspServerContribution`. Claude Code owns this field vocabulary,
389
+ * not Ryu: a typed object here would strip a field from a newer Claude release
390
+ * on its way through `ryu pack` — the same silent-deletion bug this field
391
+ * exists to fix, one level down. Core is the layer that types it, because Core
392
+ * is the layer that acts on it. */
393
+ lsp_servers: z
394
+ .record(z.string(), z.record(z.string(), z.unknown()))
395
+ .default({}),
396
+ /** Pi extensions the plugin ships — TypeScript the managed `ryu` (Pi) agent
397
+ * loads at process start. Mirrors the Rust-side `Contributes.pi_extensions`;
398
+ * without it the CLI's zod parse would strip the declaration before signing,
399
+ * and the packed plugin would ship a `pi-extensions/` folder nothing loads.
400
+ *
401
+ * Typed (not a loose record) because Ryu owns this vocabulary — three fields,
402
+ * all of them Core-interpreted — unlike `lsp_servers`, whose entry shape is
403
+ * Claude Code's to extend. */
404
+ pi_extensions: z.array(PiExtensionContributionSchema).default([]),
254
405
  });
255
406
 
256
407
  export type Contributes = z.infer<typeof ContributesSchema>;
@@ -382,7 +533,7 @@ export type Surface = z.infer<typeof SurfaceSchema>;
382
533
  // ── PluginManifest ───────────────────────────────────────────────────────────
383
534
 
384
535
  /**
385
- * Full schema for a `plugin.json` Plugin manifest. Mirrors `PluginManifest` in
536
+ * Full schema for a `manifest.json` Plugin manifest. Mirrors `PluginManifest` in
386
537
  * `apps/core/src/plugin_manifest/mod.rs`.
387
538
  *
388
539
  * Validation rules (matching Core's `PluginManifestLoader`):
@@ -530,6 +681,27 @@ export const PluginManifestSchema = z.object({
530
681
  license: z.string().optional(),
531
682
  /** Square logo/icon URL for the listing card + detail header. */
532
683
  iconUrl: z.string().optional(),
684
+ /**
685
+ * Icon-primitive id for the listing card (Ryu extension): an Iconify/icons0
686
+ * `prefix:name`, a bare Hugeicons name, or a URL, resolved by the shared `Icon`
687
+ * primitive. A monochrome GLYPH masked with the current text colour — distinct
688
+ * from `iconUrl` (a raster logo). Falls back to `iconUrl` when omitted.
689
+ */
690
+ icon: z.string().optional(),
691
+ /**
692
+ * Dithered-gradient background for the card's icon square (Ryu extension),
693
+ * mirroring dither-kit's `DitherGradient` props. `from`/`to` are a palette-colour
694
+ * name (`green`, `blue`, `purple`, `pink`, `orange`, `red`, `grey`) or a hue
695
+ * number (0–360); `direction` is where `to` ends up. Renders behind the glyph in
696
+ * place of a flat `iconBackground`; the render layer validates + falls back.
697
+ */
698
+ iconDither: z
699
+ .object({
700
+ from: z.union([z.string(), z.number()]),
701
+ to: z.union([z.string(), z.number()]).optional(),
702
+ direction: z.enum(["up", "down", "left", "right"]).optional(),
703
+ })
704
+ .optional(),
533
705
  /** Ordered App-Store-style screenshot gallery URLs (Ryu extension). */
534
706
  screenshots: z.array(z.string()).optional(),
535
707
  /** Privacy policy URL surfaced on detail (Ryu extension). */
@@ -561,7 +733,7 @@ export type PluginManifest = z.infer<typeof PluginManifestSchema>;
561
733
  // `PluginManifestSchema` above models the SDK's simpler authoring shape
562
734
  // (runnables = identity metadata only). Until those shapes are reconciled
563
735
  // (follow-up), use the zod schema for SDK authoring and these helpers when you
564
- // need Core-strict validation of a full `plugin.json`.
736
+ // need Core-strict validation of a full `manifest.json`.
565
737
 
566
738
  /**
567
739
  * Validate a plugin id with Core's strict reverse-domain, path-traversal-safe
@@ -572,7 +744,7 @@ export function validatePluginId(id: string): void {
572
744
  }
573
745
 
574
746
  /**
575
- * Validate a full `plugin.json` string against Core's authoritative rules
747
+ * Validate a full `manifest.json` string against Core's authoritative rules
576
748
  * (id, semver, per-kind runnable config contracts). Returns the normalized
577
749
  * manifest JSON string, or throws.
578
750
  */
@@ -581,7 +753,7 @@ export function validateManifestStrict(manifestJson: string): string {
581
753
  }
582
754
 
583
755
  /**
584
- * The Core-derived JSON Schema for a `plugin.json`, as a parsed object. Stays in
756
+ * The Core-derived JSON Schema for a `manifest.json`, as a parsed object. Stays in
585
757
  * lockstep with the Rust types because it is emitted from them.
586
758
  */
587
759
  export function coreManifestJsonSchema(): unknown {
@@ -173,7 +173,7 @@ export interface RyuPlugin {
173
173
  * `subscriptions`; the host disposes them all on `deactivate`. */
174
174
  export interface PluginContext {
175
175
  readonly plugin: RyuPlugin;
176
- /** The plugin's own id (from `plugin.json`). */
176
+ /** The plugin's own id (from `manifest.json`). */
177
177
  readonly pluginId: string;
178
178
  /** Disposables auto-cleaned on deactivate. */
179
179
  readonly subscriptions: Disposable[];
@@ -117,7 +117,7 @@ export interface AgentRunnable<TInput = unknown, TOutput = unknown>
117
117
  /** The lowered slot card (empty edges when no slots were declared). */
118
118
  readonly card: AgentCard;
119
119
  /**
120
- * Lower this agent (card + run identity) to a single-agent `plugin.json`
120
+ * Lower this agent (card + run identity) to a single-agent `manifest.json`
121
121
  * `PluginManifest`: the agent `RunnableMeta` carries the persona/model
122
122
  * config; `requires.capabilities` carries the slot edges. Throws if the
123
123
  * assembled manifest is invalid.
@@ -242,7 +242,7 @@ function cardConfig(card: AgentCard): Record<string, unknown> {
242
242
  return config;
243
243
  }
244
244
 
245
- /** Lower an agent to a single-agent `plugin.json` `PluginManifest`. */
245
+ /** Lower an agent to a single-agent `manifest.json` `PluginManifest`. */
246
246
  function agentToManifest(
247
247
  agent: Runnable & { card: AgentCard },
248
248
  options: AgentManifestOptions
@@ -288,7 +288,7 @@ function agentToManifest(
288
288
  *
289
289
  * The returned value satisfies `Runnable<TInput, TOutput>` with `kind = "agent"`
290
290
  * and additionally exposes the lowered {@link AgentCard} + a `toManifest()`
291
- * lowering, so a slot-composed agent round-trips to a valid `plugin.json`.
291
+ * lowering, so a slot-composed agent round-trips to a valid `manifest.json`.
292
292
  *
293
293
  * @example Classic (unchanged, back-compat):
294
294
  * ```ts
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { PluginManifestSchema } from "../manifest.ts";
3
+ import { appToolId, defineApp, WIDGET_RENDER_GRANT } from "./app.ts";
4
+
5
+ /** A minimal single-render-tool app — the shape both scaffold templates emit. */
6
+ function checklistApp(grants?: string[]) {
7
+ return defineApp({
8
+ id: "com.example.checklist",
9
+ slug: "checklist",
10
+ title: "Checklist",
11
+ version: "1.0.0",
12
+ ...(grants ? { grants } : {}),
13
+ tools: [{ name: "render", description: "Render a checklist" }],
14
+ });
15
+ }
16
+
17
+ describe("defineApp widget grant", () => {
18
+ it("declares widget:render for an app that contributes a widget", () => {
19
+ // The whole point: Core refuses to promote a widget whose owning plugin
20
+ // lacks this grant, and the refusal is an info-log — the widget just
21
+ // silently renders as text. An app scaffolded with no `grants` used to hit
22
+ // that every time.
23
+ const manifest = checklistApp();
24
+ expect(manifest.contributes?.widgets ?? []).toHaveLength(1);
25
+ expect(manifest.permission_grants).toContain(WIDGET_RENDER_GRANT);
26
+ });
27
+
28
+ it("keeps the author's own grants and appends, never replaces", () => {
29
+ const manifest = checklistApp(["mcp:web_search"]);
30
+ expect(manifest.permission_grants).toEqual([
31
+ "mcp:web_search",
32
+ WIDGET_RENDER_GRANT,
33
+ ]);
34
+ });
35
+
36
+ it("does not duplicate a grant the author already declared", () => {
37
+ const manifest = checklistApp([WIDGET_RENDER_GRANT]);
38
+ expect(
39
+ manifest.permission_grants?.filter((g) => g === WIDGET_RENDER_GRANT)
40
+ ).toHaveLength(1);
41
+ });
42
+
43
+ it("does not add the grant to an app that contributes no widget", () => {
44
+ // Every tool marked `accessible` is a companion (call target), so nothing
45
+ // renders — the grant would be an unused capability on the record.
46
+ const manifest = defineApp({
47
+ id: "com.example.tools",
48
+ slug: "tools",
49
+ title: "Tools",
50
+ version: "1.0.0",
51
+ tools: [{ name: "toggle", description: "Toggle", accessible: true }],
52
+ });
53
+ expect(manifest.contributes?.widgets ?? []).toHaveLength(0);
54
+ expect(manifest.permission_grants ?? []).not.toContain(WIDGET_RENDER_GRANT);
55
+ });
56
+
57
+ it("still emits a manifest Core's own schema accepts", () => {
58
+ expect(() => PluginManifestSchema.parse(checklistApp())).not.toThrow();
59
+ });
60
+
61
+ it("binds the widget to the render tool's fully-qualified id", () => {
62
+ const manifest = checklistApp();
63
+ expect(manifest.contributes?.widgets?.[0]?.tool_id).toBe(
64
+ appToolId("checklist", "render")
65
+ );
66
+ });
67
+ });