@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.
- package/dist/agent.cjs +4 -1
- package/dist/agent.d.cts +1 -1
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +1 -1
- package/dist/{chunk-KPKMMGVC.js → chunk-MTUBUPIV.js} +4 -1
- package/dist/{chunk-GXHL5CO7.js → chunk-SKVIJH5I.js} +84 -2
- package/dist/cli.cjs +138 -10
- package/dist/cli.js +56 -9
- package/dist/{index-DAxq7Y0R.d.ts → index-B6SkaAjJ.d.ts} +4 -4
- package/dist/{index-CEbS1SlS.d.cts → index-BvAB5eMk.d.cts} +4 -4
- package/dist/index.cjs +135 -9
- package/dist/index.d.cts +30 -12
- package/dist/index.d.ts +30 -12
- package/dist/index.js +50 -8
- package/dist/manifest.cjs +85 -2
- package/dist/manifest.d.cts +93 -10
- package/dist/manifest.d.ts +93 -10
- package/dist/manifest.js +3 -1
- package/package.json +2 -2
- package/src/builder.ts +4 -4
- package/src/cli.ts +98 -13
- package/src/contracts-lockstep.test.ts +4 -4
- package/src/generated/plugin-manifest.ts +1148 -22
- package/src/manifest-schema.test.ts +182 -0
- package/src/manifest.fixtures.test.ts +447 -0
- package/src/manifest.test.ts +100 -16
- package/src/manifest.ts +137 -17
- package/src/plugin/ryu-plugin.ts +1 -1
- package/src/runnable/agent.ts +3 -3
- package/src/runnable/app.test.ts +67 -0
- package/src/runnable/app.ts +54 -4
- package/src/runnable/primitives.test.ts +1 -5
- package/src/runnable/primitives.ts +6 -5
- package/src/runnable/tool.ts +4 -4
- package/src/runnable/turn-hook.ts +30 -4
package/src/manifest.test.ts
CHANGED
|
@@ -167,10 +167,10 @@ describe("round-trip: SDK build → JSON → Core schema parse", () => {
|
|
|
167
167
|
}
|
|
168
168
|
});
|
|
169
169
|
|
|
170
|
-
it("emitted
|
|
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("
|
|
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
|
|
192
|
-
const manifestPath = join(tmpDir, "
|
|
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("
|
|
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.
|
|
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: "
|
|
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("
|
|
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
|
-
|
|
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("
|
|
479
|
-
.dependsOn("
|
|
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: "
|
|
486
|
-
{ id: "
|
|
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: "
|
|
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: "
|
|
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("
|
|
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
|
-
|
|
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
|
-
* `
|
|
161
|
-
* sandbox with `ctx` + `host` in scope; it returns a directive.
|
|
162
|
-
*
|
|
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
|
|
165
|
-
/**
|
|
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
|
-
/**
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
|
|
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
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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 {
|
package/src/plugin/ryu-plugin.ts
CHANGED
|
@@ -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 `
|
|
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[];
|
package/src/runnable/agent.ts
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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 `
|
|
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
|
+
});
|
package/src/runnable/app.ts
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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 =
|
|
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
|
|