@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.
- 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-XTUK5I6I.js} +110 -2
- package/dist/cli.cjs +163 -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 +167 -9
- package/dist/index.d.cts +30 -12
- package/dist/index.d.ts +30 -12
- package/dist/index.js +57 -8
- package/dist/manifest.cjs +112 -2
- package/dist/manifest.d.cts +131 -10
- package/dist/manifest.d.ts +131 -10
- package/dist/manifest.js +5 -1
- package/package.json +4 -4
- 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 +1284 -24
- 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 +189 -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 +58 -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 +33 -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,122 @@ 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>;
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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 {
|
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
|
+
});
|