@ryuhq/sdk 0.0.5 → 0.0.17

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,87 @@ 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>;
174
241
 
175
242
  // ── WidgetContribution (Ryu Apps) ─────────────────────────────────────────────
176
243
 
@@ -243,6 +310,11 @@ export type ToolAppConfig = z.infer<typeof ToolAppConfigSchema>;
243
310
  */
244
311
  export const ContributesSchema = z.object({
245
312
  turn_hooks: z.array(TurnHookContributionSchema).default([]),
313
+ /** App events this plugin EMITS — the provider half of the hook system, whose
314
+ * consumer half is `turn_hooks`. Mirrors the Rust `Contributes.hook_events`;
315
+ * omitting it here would have `ryu pack` strip every declared event before
316
+ * signing, leaving an app that emits events nothing is allowed to subscribe to. */
317
+ hook_events: z.array(HookEventContributionSchema).default([]),
246
318
  composer_controls: z.array(z.record(z.string(), z.unknown())).default([]),
247
319
  settings_tabs: z.array(z.record(z.string(), z.unknown())).default([]),
248
320
  slash_commands: z.array(z.record(z.string(), z.unknown())).default([]),
@@ -251,6 +323,33 @@ export const ContributesSchema = z.object({
251
323
  * `Contributes.widgets` field, without which the CLI's zod parse would strip
252
324
  * every widget an app authored here declares. */
253
325
  widgets: z.array(WidgetContributionSchema).default([]),
326
+ /** App-registered sidebar sections (header + live list) and buttons (single nav
327
+ * rows). Loosely typed here — the shell owns the spec vocabulary — matching how
328
+ * `composer_controls`/`settings_tabs` are declared. Mirrors the Rust-side
329
+ * `Contributes.sidebar_sections` / `Contributes.sidebar_buttons`. */
330
+ sidebar_sections: z.array(z.record(z.string(), z.unknown())).default([]),
331
+ sidebar_buttons: z.array(z.record(z.string(), z.unknown())).default([]),
332
+ /** App-registered workspace dock panels (a tab in the desktop's bottom/right
333
+ * dock). Loosely typed for the same reason as the surfaces above — the shell
334
+ * owns the `panel` render-mode vocabulary and the `spec` payload. Mirrors the
335
+ * Rust-side `Contributes.dock_panels`; without it the CLI's zod parse would
336
+ * strip the dock panel an app declares here. */
337
+ dock_panels: z.array(z.record(z.string(), z.unknown())).default([]),
338
+ /** Language servers the plugin declares, keyed by server name — the mirror of
339
+ * Claude Code's `.lsp.json` / `lspServers`, so a config written for either host
340
+ * loads in the other. Mirrors the Rust-side `Contributes.lsp_servers`; without
341
+ * it the CLI's zod parse would strip every language server a plugin declares,
342
+ * before the manifest is signed.
343
+ *
344
+ * The ENTRY is deliberately a loose record and not a 13-field `z.object()`
345
+ * mirroring `LspServerContribution`. Claude Code owns this field vocabulary,
346
+ * not Ryu: a typed object here would strip a field from a newer Claude release
347
+ * on its way through `ryu pack` — the same silent-deletion bug this field
348
+ * exists to fix, one level down. Core is the layer that types it, because Core
349
+ * is the layer that acts on it. */
350
+ lsp_servers: z
351
+ .record(z.string(), z.record(z.string(), z.unknown()))
352
+ .default({}),
254
353
  });
255
354
 
256
355
  export type Contributes = z.infer<typeof ContributesSchema>;
@@ -382,7 +481,7 @@ export type Surface = z.infer<typeof SurfaceSchema>;
382
481
  // ── PluginManifest ───────────────────────────────────────────────────────────
383
482
 
384
483
  /**
385
- * Full schema for a `plugin.json` Plugin manifest. Mirrors `PluginManifest` in
484
+ * Full schema for a `manifest.json` Plugin manifest. Mirrors `PluginManifest` in
386
485
  * `apps/core/src/plugin_manifest/mod.rs`.
387
486
  *
388
487
  * Validation rules (matching Core's `PluginManifestLoader`):
@@ -530,6 +629,27 @@ export const PluginManifestSchema = z.object({
530
629
  license: z.string().optional(),
531
630
  /** Square logo/icon URL for the listing card + detail header. */
532
631
  iconUrl: z.string().optional(),
632
+ /**
633
+ * Icon-primitive id for the listing card (Ryu extension): an Iconify/icons0
634
+ * `prefix:name`, a bare Hugeicons name, or a URL, resolved by the shared `Icon`
635
+ * primitive. A monochrome GLYPH masked with the current text colour — distinct
636
+ * from `iconUrl` (a raster logo). Falls back to `iconUrl` when omitted.
637
+ */
638
+ icon: z.string().optional(),
639
+ /**
640
+ * Dithered-gradient background for the card's icon square (Ryu extension),
641
+ * mirroring dither-kit's `DitherGradient` props. `from`/`to` are a palette-colour
642
+ * name (`green`, `blue`, `purple`, `pink`, `orange`, `red`, `grey`) or a hue
643
+ * number (0–360); `direction` is where `to` ends up. Renders behind the glyph in
644
+ * place of a flat `iconBackground`; the render layer validates + falls back.
645
+ */
646
+ iconDither: z
647
+ .object({
648
+ from: z.union([z.string(), z.number()]),
649
+ to: z.union([z.string(), z.number()]).optional(),
650
+ direction: z.enum(["up", "down", "left", "right"]).optional(),
651
+ })
652
+ .optional(),
533
653
  /** Ordered App-Store-style screenshot gallery URLs (Ryu extension). */
534
654
  screenshots: z.array(z.string()).optional(),
535
655
  /** Privacy policy URL surfaced on detail (Ryu extension). */
@@ -561,7 +681,7 @@ export type PluginManifest = z.infer<typeof PluginManifestSchema>;
561
681
  // `PluginManifestSchema` above models the SDK's simpler authoring shape
562
682
  // (runnables = identity metadata only). Until those shapes are reconciled
563
683
  // (follow-up), use the zod schema for SDK authoring and these helpers when you
564
- // need Core-strict validation of a full `plugin.json`.
684
+ // need Core-strict validation of a full `manifest.json`.
565
685
 
566
686
  /**
567
687
  * Validate a plugin id with Core's strict reverse-domain, path-traversal-safe
@@ -572,7 +692,7 @@ export function validatePluginId(id: string): void {
572
692
  }
573
693
 
574
694
  /**
575
- * Validate a full `plugin.json` string against Core's authoritative rules
695
+ * Validate a full `manifest.json` string against Core's authoritative rules
576
696
  * (id, semver, per-kind runnable config contracts). Returns the normalized
577
697
  * manifest JSON string, or throws.
578
698
  */
@@ -581,7 +701,7 @@ export function validateManifestStrict(manifestJson: string): string {
581
701
  }
582
702
 
583
703
  /**
584
- * The Core-derived JSON Schema for a `plugin.json`, as a parsed object. Stays in
704
+ * The Core-derived JSON Schema for a `manifest.json`, as a parsed object. Stays in
585
705
  * lockstep with the Rust types because it is emitted from them.
586
706
  */
587
707
  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
+ });
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * A "Ryu App" bundles one or more tools whose results render an interactive
5
5
  * widget inline in chat (the ChatGPT-Apps-style surface). `defineApp` assembles a
6
- * complete `plugin.json` `PluginManifest` from a declarative description, deriving
6
+ * complete `manifest.json` `PluginManifest` from a declarative description, deriving
7
7
  * the render-vs-companion split exactly the way Core's in-process provider does
8
8
  * (`apps/core/src/sidecar/mcp/apps/mod.rs` `tools()`):
9
9
  *
@@ -37,6 +37,28 @@ const DEFAULT_APP_WIDGET_MIME = "text/html+skybridge";
37
37
  /** The default widget display mode (mirrors Core `default_widget_display_mode`). */
38
38
  const DEFAULT_APP_DISPLAY_MODE = "inline";
39
39
 
40
+ /**
41
+ * The grant Core requires before it will promote a tool's result into an inline
42
+ * chat widget (mirrors Core's `WIDGET_RENDER_GRANT`).
43
+ */
44
+ export const WIDGET_RENDER_GRANT = "widget:render";
45
+
46
+ /**
47
+ * `grants` plus {@link WIDGET_RENDER_GRANT} when the app actually contributes a
48
+ * widget. Order-preserving and idempotent, so an author who already declared it
49
+ * gets no duplicate.
50
+ */
51
+ function withWidgetRenderGrant(
52
+ grants: readonly string[],
53
+ widgets: readonly WidgetContribution[]
54
+ ): string[] {
55
+ const out = [...grants];
56
+ if (widgets.length > 0 && !out.includes(WIDGET_RENDER_GRANT)) {
57
+ out.push(WIDGET_RENDER_GRANT);
58
+ }
59
+ return out;
60
+ }
61
+
40
62
  /** One tool a Ryu App declares. */
41
63
  export interface AppToolSpec {
42
64
  /**
@@ -123,7 +145,7 @@ export function appToolId(server: string, name: string): string {
123
145
  }
124
146
 
125
147
  /**
126
- * Assemble a `plugin.json` manifest for a Ryu App. The result matches Core's
148
+ * Assemble a `manifest.json` manifest for a Ryu App. The result matches Core's
127
149
  * `PluginManifest` serde shape (validated through `PluginManifestSchema`) and can
128
150
  * be written to disk, packed with `ryu pack`, or published with `ryu publish`.
129
151
  *
@@ -191,9 +213,21 @@ export function defineApp(options: DefineAppOptions): PluginManifest {
191
213
 
192
214
  const contributes: Contributes = {
193
215
  turn_hooks: [],
216
+ // This builder synthesises an app from its runnables; an app that emits
217
+ // events declares them in a hand-authored `manifest.json`, same as
218
+ // `lsp_servers` below.
219
+ hook_events: [],
194
220
  composer_controls: [],
195
221
  settings_tabs: [],
196
222
  slash_commands: [],
223
+ sidebar_sections: [],
224
+ sidebar_buttons: [],
225
+ dock_panels: [],
226
+ // Empty for the same reason as every sibling family above: this builder
227
+ // synthesises `widgets` from the app's own runnables and nothing else, and
228
+ // takes no `contributes` passthrough. An app that wants to declare language
229
+ // servers writes them in a hand-authored `manifest.json`.
230
+ lsp_servers: {},
197
231
  widgets,
198
232
  };
199
233
 
@@ -202,7 +236,21 @@ export function defineApp(options: DefineAppOptions): PluginManifest {
202
236
  name: options.title,
203
237
  version: options.version,
204
238
  runnables,
205
- permission_grants: options.grants ?? [],
239
+ // An app that synthesises widgets MUST hold `widget:render`, so this
240
+ // builder declares it rather than leaving the author to discover it.
241
+ //
242
+ // Core gates widget promotion on declared-AND-enabled-AND-granted, and a
243
+ // missing grant fails as `DeniedNoGrant` — which is an `info!` log and
244
+ // nothing else. The widget silently renders as plain text, with no error
245
+ // in the UI and nothing pointing at the manifest. Every app scaffolded
246
+ // through `defineApp` hit that, because the only fix was a grant string
247
+ // the templates never mention and the builder never added; the one
248
+ // working example on disk hand-writes it.
249
+ //
250
+ // Added only when there is a widget to render, and unioned rather than
251
+ // overwritten so an author's own `grants` list survives and re-declaring
252
+ // it is not an error.
253
+ permission_grants: withWidgetRenderGrant(options.grants ?? [], widgets),
206
254
  activation_events: options.activationEvents ?? ["*"],
207
255
  contributes,
208
256
  // `targets: []` means EVERY surface, so an app that declares none is
@@ -227,7 +275,9 @@ export function defineApp(options: DefineAppOptions): PluginManifest {
227
275
  const first = result.error.issues[0];
228
276
  const field = first?.path.join(".") ?? "unknown";
229
277
  const message = first?.message ?? "validation failed";
230
- throw new Error(`plugin.json validation failed at '${field}': ${message}`);
278
+ throw new Error(
279
+ `manifest.json validation failed at '${field}': ${message}`
280
+ );
231
281
  }
232
282
  return result.data;
233
283
  }
@@ -77,11 +77,7 @@ describe("createPrimitives — transport routing mirrors rpc.ts", () => {
77
77
  let seen: { url: string; init: RequestInit } | undefined;
78
78
  const fetchImpl = ((url: string, init: RequestInit) => {
79
79
  seen = { url, init };
80
- return Promise.resolve(
81
- new Response(JSON.stringify({ text: " hello world " }), {
82
- headers: { "content-type": "application/json" },
83
- })
84
- );
80
+ return Promise.resolve(Response.json({ text: " hello world " }));
85
81
  }) as unknown as typeof fetch;
86
82
 
87
83
  const transport = httpPrimitiveTransport({
@@ -97,14 +97,17 @@ function dataUrlToBytes(dataUrl: string): {
97
97
  }
98
98
  return { bytes, mediaType };
99
99
  }
100
- return { bytes: new TextEncoder().encode(decodeURIComponent(payload)), mediaType };
100
+ return {
101
+ bytes: new TextEncoder().encode(decodeURIComponent(payload)),
102
+ mediaType,
103
+ };
101
104
  }
102
105
 
103
106
  /** Encode raw bytes as a `data:<mediaType>;base64,...` URL. */
104
107
  function bytesToDataUrl(bytes: Uint8Array, mediaType: string): string {
105
108
  let binary = "";
106
109
  // Chunk to stay well under the argument-count ceiling of String.fromCharCode.
107
- const chunk = 0x8000;
110
+ const chunk = 0x80_00;
108
111
  for (let i = 0; i < bytes.length; i += chunk) {
109
112
  binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
110
113
  }
@@ -236,9 +239,7 @@ export function httpPrimitiveTransport(
236
239
  data?: Array<{ url?: string; b64_json?: string }>;
237
240
  };
238
241
  return (parsed.data ?? []).map((item) =>
239
- item.url
240
- ? item.url
241
- : `data:image/png;base64,${item.b64_json ?? ""}`
242
+ item.url ? item.url : `data:image/png;base64,${item.b64_json ?? ""}`
242
243
  );
243
244
  };
244
245