@ryuhq/sdk 0.0.5

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.
Files changed (61) hide show
  1. package/LICENSE +179 -0
  2. package/README.md +31 -0
  3. package/dist/agent.cjs +761 -0
  4. package/dist/agent.d.cts +3 -0
  5. package/dist/agent.d.ts +3 -0
  6. package/dist/agent.js +23 -0
  7. package/dist/chunk-GXHL5CO7.js +353 -0
  8. package/dist/chunk-KPKMMGVC.js +671 -0
  9. package/dist/chunk-ODFEUVPW.js +100 -0
  10. package/dist/cli.cjs +858 -0
  11. package/dist/cli.d.cts +1 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +454 -0
  14. package/dist/index-CEbS1SlS.d.cts +988 -0
  15. package/dist/index-DAxq7Y0R.d.ts +988 -0
  16. package/dist/index.cjs +1900 -0
  17. package/dist/index.d.cts +759 -0
  18. package/dist/index.d.ts +759 -0
  19. package/dist/index.js +771 -0
  20. package/dist/manifest.cjs +399 -0
  21. package/dist/manifest.d.cts +355 -0
  22. package/dist/manifest.d.ts +355 -0
  23. package/dist/manifest.js +38 -0
  24. package/package.json +56 -0
  25. package/src/agent/agent.ts +208 -0
  26. package/src/agent/index.ts +51 -0
  27. package/src/agent/loop.test.ts +261 -0
  28. package/src/agent/loop.ts +259 -0
  29. package/src/agent/model-call.ts +190 -0
  30. package/src/agent/query.ts +40 -0
  31. package/src/agent/tools.ts +295 -0
  32. package/src/builder.ts +473 -0
  33. package/src/cli/dev.test.ts +178 -0
  34. package/src/cli/dev.ts +425 -0
  35. package/src/cli.ts +390 -0
  36. package/src/contracts-lockstep.test.ts +77 -0
  37. package/src/generated/plugin-manifest.ts +1121 -0
  38. package/src/index.ts +141 -0
  39. package/src/manifest.test.ts +610 -0
  40. package/src/manifest.ts +589 -0
  41. package/src/mcp/bridge.test.ts +196 -0
  42. package/src/mcp/client.ts +253 -0
  43. package/src/mcp/fixture-server.ts +23 -0
  44. package/src/mcp/server.ts +351 -0
  45. package/src/model/client.test.ts +107 -0
  46. package/src/model/client.ts +179 -0
  47. package/src/model/gateway.ts +41 -0
  48. package/src/plugin/ryu-plugin.ts +191 -0
  49. package/src/runnable/agent.ts +338 -0
  50. package/src/runnable/app.ts +233 -0
  51. package/src/runnable/index.ts +61 -0
  52. package/src/runnable/primitives-hostapi.test.ts +73 -0
  53. package/src/runnable/primitives.test.ts +286 -0
  54. package/src/runnable/primitives.ts +610 -0
  55. package/src/runnable/runnable-types.ts +113 -0
  56. package/src/runnable/runnable.test.ts +397 -0
  57. package/src/runnable/skill.ts +60 -0
  58. package/src/runnable/tool.ts +260 -0
  59. package/src/runnable/turn-hook.test.ts +81 -0
  60. package/src/runnable/turn-hook.ts +191 -0
  61. package/src/runnable/workflow.ts +76 -0
@@ -0,0 +1,610 @@
1
+ /**
2
+ * Round-trip test: build a Plugin in TS via the SDK, validate it, pack it, and
3
+ * confirm the packed JSON round-trips through `PluginManifestSchema` — proving
4
+ * that the SDK schema and Core schema agree.
5
+ *
6
+ * This test runs entirely in-process (no filesystem side effects beyond a
7
+ * temp directory) and is the authoritative acceptance proof for the acceptance
8
+ * criterion "A round-trip test builds a Plugin in TS, packs it, and Core's
9
+ * loader installs it successfully."
10
+ *
11
+ * The "Core's loader installs it" part is verified here by confirming that the
12
+ * emitted JSON satisfies `PluginManifestSchema` — the same schema Core's
13
+ * `PluginManifestLoader::parse_and_validate` enforces in Rust (the Rust tests in
14
+ * `apps/core/src/plugin_manifest/mod.rs` assert the same fixture parses).
15
+ */
16
+
17
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
18
+ import {
19
+ existsSync,
20
+ mkdirSync,
21
+ readFileSync,
22
+ rmSync,
23
+ writeFileSync,
24
+ } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { agent, app, PluginBuilder, skill, tool, workflow } from "./builder.ts";
27
+ import { PluginManifestSchema } from "./manifest.ts";
28
+ import { defineApp } from "./runnable/app.ts";
29
+
30
+ // ── builder unit tests ────────────────────────────────────────────────────────
31
+
32
+ describe("PluginBuilder", () => {
33
+ it("builds a valid minimal manifest", () => {
34
+ const manifest = new PluginBuilder()
35
+ .id("com.example.minimal")
36
+ .name("Minimal App")
37
+ .version("0.1.0")
38
+ .build();
39
+
40
+ expect(manifest.id).toBe("com.example.minimal");
41
+ expect(manifest.name).toBe("Minimal App");
42
+ expect(manifest.version).toBe("0.1.0");
43
+ expect(manifest.runnables).toEqual([]);
44
+ expect(manifest.permission_grants).toEqual([]);
45
+ expect(manifest.companion).toBeUndefined();
46
+ });
47
+
48
+ it("builds a manifest with all runnable kinds", () => {
49
+ const manifest = new PluginBuilder()
50
+ .id("com.example.full")
51
+ .name("Full App")
52
+ .version("1.2.3")
53
+ .runnable(agent().id("agent-main").name("Main Agent").build())
54
+ .runnable(workflow().id("wf-pipeline").name("Pipeline").build())
55
+ .runnable(tool().id("tool-search").name("Web Search").build())
56
+ .runnable(skill().id("skill-research").name("Research").build())
57
+ .grant("mcp:web_search")
58
+ .grant("mcp:file_read")
59
+ .companion({
60
+ label: "Full App",
61
+ icon: "sparkles",
62
+ shortcut: "ctrl+shift+f",
63
+ })
64
+ .build();
65
+
66
+ expect(manifest.runnables).toHaveLength(4);
67
+ expect(manifest.runnables.map((r) => r.kind)).toEqual([
68
+ "agent",
69
+ "workflow",
70
+ "tool",
71
+ "skill",
72
+ ]);
73
+ expect(manifest.permission_grants).toEqual([
74
+ "mcp:web_search",
75
+ "mcp:file_read",
76
+ ]);
77
+ expect(manifest.companion?.label).toBe("Full App");
78
+ });
79
+
80
+ it("throws on missing id", () => {
81
+ expect(() =>
82
+ new PluginBuilder().name("No ID").version("1.0.0").build()
83
+ ).toThrow(/id/);
84
+ });
85
+
86
+ it("rejects a companion label that impersonates system chrome", () => {
87
+ for (const bad of ["Ryu Settings", "System Tools", "my RYU panel"]) {
88
+ expect(() =>
89
+ new PluginBuilder()
90
+ .id("com.example.evil")
91
+ .name("Evil")
92
+ .version("1.0.0")
93
+ .companion({ label: bad })
94
+ .build()
95
+ ).toThrow(/impersonate system chrome/);
96
+ }
97
+ });
98
+
99
+ it("throws on invalid semver", () => {
100
+ expect(() =>
101
+ new PluginBuilder()
102
+ .id("com.example.bad")
103
+ .name("Bad")
104
+ .version("not-semver")
105
+ .build()
106
+ ).toThrow(/semver/);
107
+ });
108
+
109
+ it("engine/model fields are open strings — no union", () => {
110
+ // This test proves the SDK type system doesn't restrict engines to a
111
+ // hardcoded list. RunnableMeta has no engine/model field at the identity
112
+ // layer (engine is a config concern, not a manifest identity concern), and
113
+ // the PluginManifest schema places no restriction on what values permission
114
+ // grants strings may carry. Any new provider or engine id works without an
115
+ // SDK change.
116
+ const manifest = new PluginBuilder()
117
+ .id("com.example.custom-engine")
118
+ .name("Custom Engine App")
119
+ .version("1.0.0")
120
+ .grant("engine:my-custom-llm-v99")
121
+ .build();
122
+
123
+ expect(manifest.permission_grants).toContain("engine:my-custom-llm-v99");
124
+ });
125
+ });
126
+
127
+ describe("per-kind builders", () => {
128
+ it("agent() factory builds an agent runnable", () => {
129
+ const r = agent().id("a-1").name("Agent One").build();
130
+ expect(r.kind).toBe("agent");
131
+ expect(r.id).toBe("a-1");
132
+ });
133
+
134
+ it("workflow() factory builds a workflow runnable", () => {
135
+ const r = workflow().id("wf-1").name("Workflow One").build();
136
+ expect(r.kind).toBe("workflow");
137
+ });
138
+
139
+ it("tool() factory builds a tool runnable", () => {
140
+ const r = tool().id("t-1").name("Tool One").build();
141
+ expect(r.kind).toBe("tool");
142
+ });
143
+
144
+ it("skill() factory builds a skill runnable", () => {
145
+ const r = skill().id("s-1").name("Skill One").build();
146
+ expect(r.kind).toBe("skill");
147
+ });
148
+
149
+ it("throws when id is empty", () => {
150
+ expect(() => agent().name("No ID").build()).toThrow();
151
+ });
152
+ });
153
+
154
+ // ── round-trip test ───────────────────────────────────────────────────────────
155
+
156
+ describe("round-trip: SDK build → JSON → Core schema parse", () => {
157
+ let tmpDir: string;
158
+
159
+ beforeAll(() => {
160
+ tmpDir = join(import.meta.dir, `../__test-roundtrip-${Date.now()}`);
161
+ mkdirSync(tmpDir, { recursive: true });
162
+ });
163
+
164
+ afterAll(() => {
165
+ if (existsSync(tmpDir)) {
166
+ rmSync(tmpDir, { recursive: true, force: true });
167
+ }
168
+ });
169
+
170
+ it("emitted plugin.json satisfies PluginManifestSchema (Core compat proof)", () => {
171
+ // 1. Build a manifest using the SDK.
172
+ const manifest = new PluginBuilder()
173
+ .id("com.example.research-assistant")
174
+ .name("Research Assistant")
175
+ .version("1.0.0")
176
+ .runnable(agent().id("agent-researcher").name("Researcher").build())
177
+ .runnable(
178
+ workflow().id("wf-summarise").name("Summarise Workflow").build()
179
+ )
180
+ .runnable(tool().id("tool-web-search").name("Web Search").build())
181
+ .runnable(skill().id("skill-research").name("Research Skill").build())
182
+ .grant("mcp:web_search")
183
+ .grant("mcp:file_read")
184
+ .companion({
185
+ label: "Research Assistant",
186
+ icon: "magnifying-glass",
187
+ shortcut: "ctrl+shift+r",
188
+ })
189
+ .build();
190
+
191
+ // 2. Emit to a temp plugin.json (simulating what `ryu pack` writes).
192
+ const manifestPath = join(tmpDir, "plugin.json");
193
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
194
+
195
+ // 3. Read it back and parse through `PluginManifestSchema` — the same
196
+ // validation Core's PluginManifestLoader applies in Rust.
197
+ const raw = readFileSync(manifestPath, "utf8");
198
+ const parsed = JSON.parse(raw) as unknown;
199
+ const result = PluginManifestSchema.safeParse(parsed);
200
+
201
+ expect(result.success).toBe(true);
202
+ if (!result.success) {
203
+ return;
204
+ }
205
+
206
+ const loaded = result.data;
207
+ expect(loaded.id).toBe("com.example.research-assistant");
208
+ expect(loaded.runnables).toHaveLength(4);
209
+ expect(loaded.permission_grants).toEqual([
210
+ "mcp:web_search",
211
+ "mcp:file_read",
212
+ ]);
213
+ expect(loaded.companion?.shortcut).toBe("ctrl+shift+r");
214
+ });
215
+
216
+ it("matches the Core fixture (sample.plugin.json)", () => {
217
+ // The Core Rust test (`sample_fixture_deserializes_into_app_manifest`)
218
+ // asserts the same values — this verifies TS schema parity.
219
+ const fixture = {
220
+ id: "com.example.research-assistant",
221
+ name: "Research Assistant",
222
+ version: "1.0.0",
223
+ runnables: [
224
+ { id: "agent-researcher", name: "Researcher", kind: "agent" },
225
+ { id: "wf-summarise", name: "Summarise Workflow", kind: "workflow" },
226
+ { id: "tool-web-search", name: "Web Search", kind: "tool" },
227
+ { id: "skill-research", name: "Research Skill", kind: "skill" },
228
+ ],
229
+ permission_grants: ["mcp:web_search", "mcp:file_read"],
230
+ companion: {
231
+ label: "Research Assistant",
232
+ icon: "magnifying-glass",
233
+ shortcut: "ctrl+shift+r",
234
+ },
235
+ };
236
+
237
+ const result = PluginManifestSchema.safeParse(fixture);
238
+ expect(result.success).toBe(true);
239
+ if (!result.success) {
240
+ return;
241
+ }
242
+
243
+ expect(result.data.id).toBe("com.example.research-assistant");
244
+ expect(result.data.runnables).toHaveLength(4);
245
+ const kinds = result.data.runnables.map((r) => r.kind);
246
+ expect(kinds).toContain("agent");
247
+ expect(kinds).toContain("workflow");
248
+ expect(kinds).toContain("tool");
249
+ expect(kinds).toContain("skill");
250
+ });
251
+
252
+ it("invalid semver in JSON is rejected", () => {
253
+ const bad = {
254
+ id: "com.example.bad",
255
+ name: "Bad",
256
+ version: "not-a-version",
257
+ runnables: [],
258
+ };
259
+ const result = PluginManifestSchema.safeParse(bad);
260
+ expect(result.success).toBe(false);
261
+ });
262
+
263
+ it("missing id in JSON is rejected", () => {
264
+ const bad = { name: "No ID", version: "1.0.0", runnables: [] };
265
+ const result = PluginManifestSchema.safeParse(bad);
266
+ expect(result.success).toBe(false);
267
+ });
268
+ });
269
+
270
+ // ── Ryu App (defineApp / AppBuilder) ──────────────────────────────────────────
271
+
272
+ describe("defineApp", () => {
273
+ // A fixture app with one render tool + one companion (accessible) tool. This
274
+ // exercises the render-vs-companion derivation that mirrors Core's
275
+ // `apps::tools()`.
276
+ function fixtureApp() {
277
+ return defineApp({
278
+ id: "com.example.checklist",
279
+ title: "Checklist",
280
+ version: "1.0.0",
281
+ slug: "checklist",
282
+ uiEntry: "src/checklist.tsx",
283
+ grants: ["mcp:file_read"],
284
+ tools: [
285
+ {
286
+ name: "render",
287
+ description: "Render a checklist",
288
+ inputSchema: {
289
+ type: "object",
290
+ properties: { title: { type: "string" } },
291
+ },
292
+ invoking: "Building…",
293
+ invoked: "Ready",
294
+ },
295
+ { name: "toggle", description: "Toggle an item", accessible: true },
296
+ ],
297
+ });
298
+ }
299
+
300
+ it("emits one WidgetContribution for the render tool and none for the companion", () => {
301
+ const manifest = fixtureApp();
302
+ const widgets = manifest.contributes?.widgets ?? [];
303
+
304
+ expect(widgets).toHaveLength(1);
305
+ expect(widgets[0]?.tool_id).toBe("checklist__render");
306
+ expect(widgets[0]?.uri).toBe("ui://widget/checklist.html");
307
+ expect(widgets[0]?.ui_entry).toBe("src/checklist.tsx");
308
+ expect(widgets[0]?.mime).toBe("text/html+skybridge");
309
+ expect(widgets[0]?.default_display_mode).toBe("inline");
310
+ });
311
+
312
+ it("builds one kind:'tool' runnable per tool with the widget config flags", () => {
313
+ const manifest = fixtureApp();
314
+ expect(manifest.runnables).toHaveLength(2);
315
+ expect(manifest.runnables.every((r) => r.kind === "tool")).toBe(true);
316
+
317
+ const render = manifest.runnables.find((r) => r.id === "checklist__render");
318
+ expect(render?.config).toMatchObject({
319
+ slug: "checklist__render",
320
+ // The manifest is the only channel for a packed app: description +
321
+ // input_schema must survive so Core can rebuild a driveable tool.
322
+ description: "Render a checklist",
323
+ input_schema: {
324
+ type: "object",
325
+ properties: { title: { type: "string" } },
326
+ },
327
+ widget: true,
328
+ // The render tool's widget may call tools because the app declares a
329
+ // companion (widget_accessible tool).
330
+ widget_accessible: true,
331
+ invoking: "Building…",
332
+ invoked: "Ready",
333
+ });
334
+
335
+ const toggle = manifest.runnables.find((r) => r.id === "checklist__toggle");
336
+ expect(toggle?.config).toMatchObject({
337
+ slug: "checklist__toggle",
338
+ widget: false,
339
+ widget_accessible: true,
340
+ });
341
+ });
342
+
343
+ it("marks a render tool's widget non-accessible when the app has no companion", () => {
344
+ const manifest = defineApp({
345
+ id: "com.example.chart",
346
+ title: "Chart",
347
+ version: "1.0.0",
348
+ slug: "chart-studio",
349
+ server: "chart",
350
+ uiEntry: "src/chart.tsx",
351
+ tools: [{ name: "render", description: "Render a chart" }],
352
+ });
353
+ const render = manifest.runnables.find((r) => r.id === "chart__render");
354
+ expect(render?.config).toMatchObject({
355
+ widget: true,
356
+ widget_accessible: false,
357
+ });
358
+ // `server` override qualifies the tool id and the widget binding.
359
+ expect(manifest.contributes?.widgets[0]?.tool_id).toBe("chart__render");
360
+ // The widget uri still derives from the slug, not the server.
361
+ expect(manifest.contributes?.widgets[0]?.uri).toBe(
362
+ "ui://widget/chart-studio.html"
363
+ );
364
+ });
365
+
366
+ it("round-trips through PluginManifestSchema without stripping widgets", () => {
367
+ // The load-bearing check: `contributes.widgets` is only preserved because it
368
+ // was added to `ContributesSchema`. A JSON round-trip proves the field
369
+ // survives Core-strict zod parse (the same parse the CLI applies).
370
+ const manifest = fixtureApp();
371
+ const json = JSON.stringify(manifest);
372
+ const parsed = PluginManifestSchema.safeParse(JSON.parse(json));
373
+
374
+ expect(parsed.success).toBe(true);
375
+ if (!parsed.success) {
376
+ return;
377
+ }
378
+ expect(parsed.data.contributes?.widgets).toHaveLength(1);
379
+ expect(parsed.data.contributes?.widgets[0]?.tool_id).toBe(
380
+ "checklist__render"
381
+ );
382
+ // description + input_schema survive the strict parse (the only channel for
383
+ // a packed app — no `generated.rs` on the Core side).
384
+ const render = parsed.data.runnables.find(
385
+ (r) => r.id === "checklist__render"
386
+ );
387
+ expect(render?.config?.description).toBe("Render a checklist");
388
+ expect(render?.config?.input_schema).toBeDefined();
389
+ });
390
+
391
+ it("rejects an invalid semver version", () => {
392
+ expect(() =>
393
+ defineApp({
394
+ id: "com.example.bad",
395
+ title: "Bad",
396
+ version: "not-semver",
397
+ slug: "bad",
398
+ uiEntry: "src/bad.tsx",
399
+ tools: [{ name: "render", description: "Render" }],
400
+ })
401
+ ).toThrow(/semver/);
402
+ });
403
+ });
404
+
405
+ describe("AppBuilder", () => {
406
+ it("builds an equivalent manifest to defineApp", () => {
407
+ const manifest = app()
408
+ .id("com.example.checklist")
409
+ .title("Checklist")
410
+ .version("1.0.0")
411
+ .slug("checklist")
412
+ .uiEntry("src/checklist.tsx")
413
+ .grant("mcp:file_read")
414
+ .tool({
415
+ name: "render",
416
+ description: "Render a checklist",
417
+ invoking: "Building…",
418
+ invoked: "Ready",
419
+ })
420
+ .tool({ name: "toggle", description: "Toggle an item", accessible: true })
421
+ .build();
422
+
423
+ expect(manifest.id).toBe("com.example.checklist");
424
+ expect(manifest.runnables).toHaveLength(2);
425
+ expect(manifest.contributes?.widgets).toHaveLength(1);
426
+ expect(manifest.contributes?.widgets[0]?.tool_id).toBe("checklist__render");
427
+ expect(manifest.permission_grants).toEqual(["mcp:file_read"]);
428
+ });
429
+
430
+ it("throws on missing id", () => {
431
+ expect(() =>
432
+ app()
433
+ .title("No ID")
434
+ .version("1.0.0")
435
+ .slug("x")
436
+ .uiEntry("src/x.tsx")
437
+ .tool({ name: "render", description: "Render" })
438
+ .build()
439
+ ).toThrow(/id/);
440
+ });
441
+ });
442
+
443
+ // ── requires / targets (plugin-to-plugin dependencies + surface gating) ───────
444
+ //
445
+ // These pin the SDK schema to Core's `PluginManifest.requires` / `.targets`
446
+ // (`apps/core/src/plugin_manifest/mod.rs`). The load-bearing property is that zod
447
+ // `z.object()` STRIPS unknown keys: without these fields in the schema, `ryu pack`
448
+ // / `ryu publish` (which return `PluginManifestSchema.safeParse(...).data`) would
449
+ // silently delete a plugin's dependencies BEFORE the manifest is signed. So every
450
+ // case below asserts the field SURVIVES the parse, not merely that it parses.
451
+
452
+ describe("requires / targets", () => {
453
+ it("keeps a manifest with NEITHER requires nor targets valid (all 37 shipped plugins)", () => {
454
+ const parsed = PluginManifestSchema.safeParse({
455
+ id: "com.example.legacy",
456
+ name: "Legacy",
457
+ version: "1.0.0",
458
+ runnables: [],
459
+ });
460
+
461
+ expect(parsed.success).toBe(true);
462
+ if (!parsed.success) {
463
+ return;
464
+ }
465
+ // Absent `requires` = NO dependencies (never an empty-object default, so the
466
+ // key stays off the wire exactly like Core's `Option<Requires>`).
467
+ expect(parsed.data.requires).toBeUndefined();
468
+ // Absent `targets` = EVERY surface. It must never be read as "hidden", or
469
+ // every manifest predating the field would vanish from every listing.
470
+ expect(parsed.data.targets).toEqual([]);
471
+ });
472
+
473
+ it("round-trips `requires` through the schema without stripping it", () => {
474
+ const manifest = new PluginBuilder()
475
+ .id("com.example.meetings")
476
+ .name("Meetings")
477
+ .version("1.0.0")
478
+ .dependsOn("com.ryu.spaces", "1.2.0")
479
+ .dependsOn("com.ryu.voice")
480
+ .requiredGrant("spaces:docs")
481
+ .build();
482
+
483
+ // Survives the builder…
484
+ expect(manifest.requires?.apps).toEqual([
485
+ { id: "com.ryu.spaces", min_version: "1.2.0" },
486
+ { id: "com.ryu.voice" },
487
+ ]);
488
+ expect(manifest.requires?.grants).toEqual(["spaces:docs"]);
489
+
490
+ // …and the JSON round-trip the CLI applies before signing.
491
+ const parsed = PluginManifestSchema.safeParse(
492
+ JSON.parse(JSON.stringify(manifest))
493
+ );
494
+ expect(parsed.success).toBe(true);
495
+ if (!parsed.success) {
496
+ return;
497
+ }
498
+ expect(parsed.data.requires?.apps).toHaveLength(2);
499
+ // snake_case on the wire — Core's `AppDependency.min_version` declares no
500
+ // serde rename, so a camelCase `minVersion` here would be silently dropped.
501
+ expect(parsed.data.requires?.apps[0]?.min_version).toBe("1.2.0");
502
+ expect(parsed.data.requires?.apps[1]?.min_version).toBeUndefined();
503
+ expect(parsed.data.requires?.grants).toEqual(["spaces:docs"]);
504
+ });
505
+
506
+ it("defaults the two `requires` members so a partial block parses", () => {
507
+ const parsed = PluginManifestSchema.safeParse({
508
+ id: "com.example.partial",
509
+ name: "Partial",
510
+ version: "1.0.0",
511
+ runnables: [],
512
+ requires: { apps: [{ id: "com.ryu.spaces" }] },
513
+ });
514
+
515
+ expect(parsed.success).toBe(true);
516
+ if (!parsed.success) {
517
+ return;
518
+ }
519
+ expect(parsed.data.requires?.grants).toEqual([]);
520
+ });
521
+
522
+ it("round-trips `targets` through the schema without stripping it", () => {
523
+ const manifest = new PluginBuilder()
524
+ .id("com.example.desktop-only")
525
+ .name("Desktop Only")
526
+ .version("1.0.0")
527
+ .target("desktop")
528
+ .target("island")
529
+ .build();
530
+
531
+ expect(manifest.targets).toEqual(["desktop", "island"]);
532
+
533
+ const parsed = PluginManifestSchema.safeParse(
534
+ JSON.parse(JSON.stringify(manifest))
535
+ );
536
+ expect(parsed.success).toBe(true);
537
+ if (!parsed.success) {
538
+ return;
539
+ }
540
+ expect(parsed.data.targets).toEqual(["desktop", "island"]);
541
+ });
542
+
543
+ it("accepts every one of Core's eight kebab-case surface tokens", () => {
544
+ const parsed = PluginManifestSchema.safeParse({
545
+ id: "com.example.everywhere",
546
+ name: "Everywhere",
547
+ version: "1.0.0",
548
+ runnables: [],
549
+ targets: [
550
+ "gateway",
551
+ "core",
552
+ "desktop",
553
+ "island",
554
+ "mobile",
555
+ "extension",
556
+ "web",
557
+ "cli",
558
+ ],
559
+ });
560
+
561
+ expect(parsed.success).toBe(true);
562
+ if (!parsed.success) {
563
+ return;
564
+ }
565
+ expect(parsed.data.targets).toHaveLength(8);
566
+ });
567
+
568
+ it("rejects a surface token Core's Surface enum does not define", () => {
569
+ const parsed = PluginManifestSchema.safeParse({
570
+ id: "com.example.bad-target",
571
+ name: "Bad Target",
572
+ version: "1.0.0",
573
+ runnables: [],
574
+ // Core's `Surface` is kebab-case and has no `tauri` variant, so serde
575
+ // would reject the whole manifest at load. Catch it at authoring time.
576
+ targets: ["tauri"],
577
+ });
578
+
579
+ expect(parsed.success).toBe(false);
580
+ });
581
+
582
+ it("carries requires + targets through defineApp (Ryu Apps)", () => {
583
+ const manifest = defineApp({
584
+ id: "com.example.dep-app",
585
+ title: "Dep App",
586
+ version: "1.0.0",
587
+ slug: "dep-app",
588
+ uiEntry: "src/dep-app.tsx",
589
+ tools: [{ name: "render", description: "Render" }],
590
+ requires: { apps: [{ id: "com.ryu.spaces", min_version: "1.0.0" }] },
591
+ targets: ["desktop"],
592
+ });
593
+
594
+ expect(manifest.requires?.apps[0]?.id).toBe("com.ryu.spaces");
595
+ expect(manifest.requires?.grants).toEqual([]);
596
+ expect(manifest.targets).toEqual(["desktop"]);
597
+
598
+ // An app that declares neither keeps the no-dependency / all-surface default.
599
+ const plain = defineApp({
600
+ id: "com.example.plain-app",
601
+ title: "Plain App",
602
+ version: "1.0.0",
603
+ slug: "plain-app",
604
+ uiEntry: "src/plain-app.tsx",
605
+ tools: [{ name: "render", description: "Render" }],
606
+ });
607
+ expect(plain.requires).toBeUndefined();
608
+ expect(plain.targets).toEqual([]);
609
+ });
610
+ });