@ryuhq/sdk 0.2.0 → 0.2.3

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 (67) hide show
  1. package/README.md +51 -2
  2. package/dist/action.cjs +839 -0
  3. package/dist/action.d.cts +88 -0
  4. package/dist/action.d.ts +88 -0
  5. package/dist/action.js +8 -0
  6. package/dist/agent-plugin.d.cts +1 -1
  7. package/dist/agent-plugin.d.ts +1 -1
  8. package/dist/agent.cjs +7 -0
  9. package/dist/agent.d.cts +1 -1
  10. package/dist/agent.d.ts +1 -1
  11. package/dist/agent.js +2 -2
  12. package/dist/{app-DNaGmLVf.d.cts → app-B0Z9Ew_R.d.cts} +18 -6
  13. package/dist/{app-Bkw7LlCK.d.ts → app-C-BDJwfG.d.ts} +18 -6
  14. package/dist/builder.cjs +110 -11
  15. package/dist/builder.d.cts +7 -2
  16. package/dist/builder.d.ts +7 -2
  17. package/dist/builder.js +4 -3
  18. package/dist/chunk-4TPUZDTI.js +94 -0
  19. package/dist/{chunk-T5676WL2.js → chunk-BC3A7HMO.js} +1 -77
  20. package/dist/{chunk-IOLP5FFE.js → chunk-HLKJZAFK.js} +9 -2
  21. package/dist/{chunk-IEUQ3CDG.js → chunk-NZKVOSC2.js} +71 -5
  22. package/dist/chunk-QYFUNJOH.js +83 -0
  23. package/dist/{chunk-W3KPP4WN.js → chunk-SN2QBJUF.js} +20 -7
  24. package/dist/{chunk-ULSVL7EC.js → chunk-Z57QDDJR.js} +1 -1
  25. package/dist/{chunk-A3RGEPDG.js → chunk-ZTJWBRUL.js} +32 -0
  26. package/dist/cli.cjs +93 -8
  27. package/dist/cli.js +33 -8
  28. package/dist/index.cjs +297 -87
  29. package/dist/index.d.cts +10 -9
  30. package/dist/index.d.ts +10 -9
  31. package/dist/index.js +34 -19
  32. package/dist/manifest.cjs +76 -6
  33. package/dist/manifest.d.cts +157 -5
  34. package/dist/manifest.d.ts +157 -5
  35. package/dist/manifest.js +9 -1
  36. package/dist/mcp/server.d.cts +2 -1
  37. package/dist/mcp/server.d.ts +2 -1
  38. package/dist/runnable.cjs +279 -71
  39. package/dist/runnable.d.cts +6 -3
  40. package/dist/runnable.d.ts +6 -3
  41. package/dist/runnable.js +11 -5
  42. package/dist/{tool-DSx2bFx8.d.ts → tool-AjkdFvhE.d.ts} +54 -4
  43. package/dist/{tool-u-VR0fLF.d.cts → tool-CgzW92O_.d.cts} +54 -4
  44. package/package.json +11 -2
  45. package/src/agent-plugin.ts +1 -1
  46. package/src/builder.ts +9 -0
  47. package/src/cli-security.test.ts +109 -0
  48. package/src/cli.ts +43 -10
  49. package/src/contracts-lockstep.test.ts +16 -2
  50. package/src/exports-lockstep.test.ts +1 -0
  51. package/src/generated/plugin-manifest.ts +82 -5
  52. package/src/index.ts +18 -0
  53. package/src/manifest-schema.test.ts +1 -1
  54. package/src/manifest.fixtures.test.ts +9 -3
  55. package/src/manifest.test.ts +69 -0
  56. package/src/manifest.ts +137 -18
  57. package/src/mcp/server.ts +2 -1
  58. package/src/runnable/action.test.ts +128 -0
  59. package/src/runnable/action.ts +202 -0
  60. package/src/runnable/app.ts +45 -12
  61. package/src/runnable/index.ts +18 -3
  62. package/src/runnable/primitives.test.ts +34 -0
  63. package/src/runnable/primitives.ts +59 -0
  64. package/src/runnable/runnable-types.ts +3 -0
  65. package/src/runnable/tool.ts +35 -4
  66. package/src/runnable/turn-hook.ts +4 -2
  67. package/src/slash-command.test.ts +69 -0
@@ -17,7 +17,8 @@
17
17
  *
18
18
  * The set is read from Core's tree so a new shipped plugin is covered the moment it
19
19
  * lands, without touching this file. It spans BOTH homes: the packaged manifests
20
- * live in `apps-store/<x>/manifest.json` and `plugins-store/<x>/manifest.json` (Core
20
+ * live in `apps-store/<x>/manifest.json` and
21
+ * `plugins-store/{plugins,lsp,external_plugins}/<x>/manifest.json` (Core
21
22
  * `include_str!`s them from there), and only the ~13 Core-only ones remain under
22
23
  * `apps/core/src/plugin_manifest/fixtures/`. Reading just the fixtures dir would
23
24
  * still pass — on 13 files instead of 71 — so both roots are walked deliberately.
@@ -39,7 +40,12 @@ import {
39
40
  const REPO_ROOT = join(import.meta.dir, "../../..");
40
41
  const FIXTURES_DIR = join(REPO_ROOT, "apps/core/src/plugin_manifest/fixtures");
41
42
  /** The package roots whose `manifest.json` Core compiles in directly. */
42
- const PACKAGE_ROOTS = ["apps-store", "plugins-store"];
43
+ const PACKAGE_ROOTS = [
44
+ "apps-store",
45
+ "plugins-store/plugins",
46
+ "plugins-store/lsp",
47
+ "plugins-store/external_plugins",
48
+ ];
43
49
 
44
50
  interface RawManifest {
45
51
  companion?: { label?: unknown };
@@ -419,7 +425,7 @@ describe("PluginManifestSchema preserves turn_hook.match", () => {
419
425
  // `tool-firewall` is a packaged plugin, so its manifest lives in its
420
426
  // package directory — there is no fixture copy any more.
421
427
  readFileSync(
422
- join(REPO_ROOT, "plugins-store/tool-firewall/manifest.json"),
428
+ join(REPO_ROOT, "plugins-store/plugins/tool-firewall/manifest.json"),
423
429
  "utf8"
424
430
  )
425
431
  );
@@ -26,6 +26,7 @@ import { join } from "node:path";
26
26
  import { agent, app, PluginBuilder, skill, tool, workflow } from "./builder.ts";
27
27
  import { PluginManifestSchema } from "./manifest.ts";
28
28
  import { defineApp } from "./runnable/app.ts";
29
+ import { defineTool } from "./runnable/tool.ts";
29
30
 
30
31
  // ── builder unit tests ────────────────────────────────────────────────────────
31
32
 
@@ -363,6 +364,49 @@ describe("defineApp", () => {
363
364
  );
364
365
  });
365
366
 
367
+ it("embeds an executable ToolRunnable in the widget tool", () => {
368
+ const renderTool = defineTool({
369
+ id: "support.render",
370
+ name: "Support answer",
371
+ schema: {
372
+ type: "object",
373
+ properties: { message: { type: "string" } },
374
+ required: ["message"],
375
+ },
376
+ run: async (input) => ({
377
+ content: [{ type: "text", text: input.message }],
378
+ structuredContent: { answer: input.message },
379
+ }),
380
+ });
381
+ const manifest = defineApp({
382
+ id: "com.example.support",
383
+ title: "Support",
384
+ version: "1.0.0",
385
+ slug: "support",
386
+ uiEntry: "src/widget.html",
387
+ tools: [
388
+ {
389
+ name: "render",
390
+ description: "Answer a support question",
391
+ inputSchema: renderTool.schema as unknown as Record<string, unknown>,
392
+ runnable: renderTool,
393
+ },
394
+ ],
395
+ });
396
+ const render = manifest.runnables[0];
397
+
398
+ expect(render?.config).toMatchObject({
399
+ backend: "inline_deno",
400
+ code: renderTool.code,
401
+ input_schema: renderTool.schema,
402
+ widget: true,
403
+ });
404
+ expect(manifest.permission_grants).toEqual([
405
+ "widget:render",
406
+ "tool:execute",
407
+ ]);
408
+ });
409
+
366
410
  it("round-trips through PluginManifestSchema without stripping widgets", () => {
367
411
  // The load-bearing check: `contributes.widgets` is only preserved because it
368
412
  // was added to `ContributesSchema`. A JSON round-trip proves the field
@@ -805,6 +849,31 @@ describe("contributes.message_actions", () => {
805
849
  });
806
850
  });
807
851
 
852
+ describe("contributes.selection_actions", () => {
853
+ it("preserves host-owned selection dispatch args through the pack-path parse", () => {
854
+ const action = {
855
+ args: { dispatch: "side-chat.selection", intent: "explain" },
856
+ id: "side-chats.explain-selection",
857
+ kind: "button",
858
+ label: "Explain",
859
+ order: 110,
860
+ };
861
+ const parsed = PluginManifestSchema.safeParse({
862
+ id: "com.example.side-chats",
863
+ name: "Side Chats",
864
+ version: "1.0.0",
865
+ runnables: [],
866
+ contributes: { selection_actions: [action] },
867
+ });
868
+
869
+ expect(parsed.success).toBe(true);
870
+ if (!parsed.success) {
871
+ return;
872
+ }
873
+ expect(parsed.data.contributes?.selection_actions).toEqual([action]);
874
+ });
875
+ });
876
+
808
877
  describe("mcp_servers OAuth", () => {
809
878
  const manifest = {
810
879
  id: "com.example.mail",
package/src/manifest.ts CHANGED
@@ -346,28 +346,47 @@ export type WidgetContribution = z.infer<typeof WidgetContributionSchema>;
346
346
 
347
347
  /** Metadata-only chat affordance. The host owns rendering and dispatch; the
348
348
  * manifest carries identifiers and copy only. */
349
- export const ChatWidgetTemplateSchema = z.object({
350
- id: z.string().regex(/^[a-z0-9][a-z0-9._:-]*$/),
351
- title: z.string().min(1),
352
- description: z.string().optional(),
353
- triggers: z.array(z.string()).default([]),
354
- examples: z.array(z.string()).default([]),
355
- backing: z
356
- .object({
357
- tool_id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/).optional(),
358
- view_id: z.string().regex(/^[a-z0-9][a-z0-9._:-]*$/).optional(),
349
+ export const ChatWidgetTemplateSchema = z
350
+ .object({
351
+ id: z.string().regex(/^[a-z0-9][a-z0-9._:-]*$/),
352
+ title: z.string().min(1),
353
+ description: z.string().optional(),
354
+ triggers: z.array(z.string()).default([]),
355
+ examples: z.array(z.string()).default([]),
356
+ backing: z.object({
357
+ tool_id: z
358
+ .string()
359
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._:/-]*$/)
360
+ .optional(),
361
+ view_id: z
362
+ .string()
363
+ .regex(/^[a-z0-9][a-z0-9._:-]*$/)
364
+ .optional(),
359
365
  }),
360
- display_mode: z.string().min(1),
361
- safe_action_ids: z.array(z.string().regex(/^[a-z0-9][a-z0-9._-]*$/)).default([]),
362
- availability: z.string().default("available"),
366
+ display_mode: z.string().min(1),
367
+ safe_action_ids: z
368
+ .array(z.string().regex(/^[a-z0-9][a-z0-9._-]*$/))
369
+ .default([]),
370
+ availability: z.string().default("available"),
363
371
  })
364
372
  .superRefine((value, ctx) => {
365
- const count = Number(Boolean(value.backing.tool_id)) + Number(Boolean(value.backing.view_id));
373
+ const count =
374
+ Number(Boolean(value.backing.tool_id)) +
375
+ Number(Boolean(value.backing.view_id));
366
376
  if (count !== 1 && value.availability === "available") {
367
- ctx.addIssue({ code: "custom", path: ["backing"], message: "available templates need exactly one backing tool_id or view_id" });
377
+ ctx.addIssue({
378
+ code: "custom",
379
+ path: ["backing"],
380
+ message:
381
+ "available templates need exactly one backing tool_id or view_id",
382
+ });
368
383
  }
369
384
  if (count > 1) {
370
- ctx.addIssue({ code: "custom", path: ["backing"], message: "backing must declare at most one of tool_id or view_id" });
385
+ ctx.addIssue({
386
+ code: "custom",
387
+ path: ["backing"],
388
+ message: "backing must declare at most one of tool_id or view_id",
389
+ });
371
390
  }
372
391
  });
373
392
 
@@ -406,10 +425,70 @@ export const ToolAppConfigSchema = z.object({
406
425
 
407
426
  export type ToolAppConfig = z.infer<typeof ToolAppConfigSchema>;
408
427
 
428
+ /** One selectable value for a plugin/app slash-command argument. */
429
+ export const SlashCommandOptionSchema = z
430
+ .object({
431
+ description: z.string().optional(),
432
+ label: z.string().min(1),
433
+ value: z.string().min(1),
434
+ })
435
+ .passthrough();
436
+
437
+ export type SlashCommandOption = z.infer<typeof SlashCommandOptionSchema>;
438
+
439
+ /** A registered free-form option shown alongside an argument's choices. */
440
+ export const SlashCommandCustomOptionSchema = z.union([
441
+ z.literal(true),
442
+ z
443
+ .object({
444
+ description: z.string().optional(),
445
+ label: z.string().min(1).optional(),
446
+ })
447
+ .passthrough(),
448
+ ]);
449
+
450
+ export type SlashCommandCustomOption = z.infer<
451
+ typeof SlashCommandCustomOptionSchema
452
+ >;
453
+
454
+ /** One sequential argument in a plugin/app slash command. */
455
+ export const SlashCommandArgumentSchema = z
456
+ .object({
457
+ allow_custom: z.boolean().optional(),
458
+ custom: SlashCommandCustomOptionSchema.optional(),
459
+ description: z.string().optional(),
460
+ name: z.string().min(1),
461
+ options: z.array(SlashCommandOptionSchema).optional(),
462
+ })
463
+ .passthrough();
464
+
465
+ export type SlashCommandArgument = z.infer<typeof SlashCommandArgumentSchema>;
466
+
467
+ /**
468
+ * A command registered by a plugin or Ryu App. `args` is the preferred key;
469
+ * `parameters` is accepted as a readable alias for hand-authored manifests.
470
+ * Each argument's options are plugin-owned, so the shell never needs a closed
471
+ * enum for app-specific values.
472
+ */
473
+ export const SlashCommandContributionSchema = z
474
+ .object({
475
+ args: z.array(SlashCommandArgumentSchema).optional(),
476
+ body: z.string().optional(),
477
+ command: z.string().min(1),
478
+ description: z.string().optional(),
479
+ id: z.string().optional(),
480
+ parameters: z.array(SlashCommandArgumentSchema).optional(),
481
+ })
482
+ .passthrough();
483
+
484
+ export type SlashCommandContribution = z.infer<
485
+ typeof SlashCommandContributionSchema
486
+ >;
487
+
409
488
  /**
410
489
  * The `contributes` block. Mirrors `Contributes` in
411
490
  * `apps/core/src/plugin_manifest/mod.rs`. The declarative UI surfaces
412
- * (`composer_controls` / `chat_features` / `settings_tabs` / `slash_commands`) are passed verbatim
491
+ * (`composer_controls` / `chat_features` / `settings_tabs`) are passed verbatim
413
492
  * to the desktop renderer, so they are typed loosely here (records).
414
493
  */
415
494
  export const ContributesSchema = z.object({
@@ -426,7 +505,7 @@ export const ContributesSchema = z.object({
426
505
  * feature declaration before signing. */
427
506
  chat_features: z.array(z.record(z.string(), z.unknown())).default([]),
428
507
  settings_tabs: z.array(z.record(z.string(), z.unknown())).default([]),
429
- slash_commands: z.array(z.record(z.string(), z.unknown())).default([]),
508
+ slash_commands: z.array(SlashCommandContributionSchema).default([]),
430
509
  /** App widgets (Ryu Apps). Each binds a render tool id to its
431
510
  * `ui://widget/<slug>.html` template. Mirrors the Rust-side
432
511
  * `Contributes.widgets` field, without which the CLI's zod parse would strip
@@ -496,6 +575,9 @@ export const ContributesSchema = z.object({
496
575
  /** Per-message actions contributed by an enabled plugin. Kept as loose records
497
576
  * so renderer-specific `kind`/`args` payloads survive `ryu pack` unchanged. */
498
577
  message_actions: z.array(z.record(z.string(), z.unknown())).default([]),
578
+ /** Buttons contributed to the floating text-selection toolbar. Kept as loose
579
+ * records so host-owned dispatch args survive `ryu pack` unchanged. */
580
+ selection_actions: z.array(z.record(z.string(), z.unknown())).default([]),
499
581
  });
500
582
 
501
583
  export type Contributes = z.infer<typeof ContributesSchema>;
@@ -765,6 +847,10 @@ export const PluginManifestSchema = z
765
847
  "version must be a valid semver string (e.g. 1.0.0)"
766
848
  ),
767
849
 
850
+ /** Core-owned release maturity metadata. The Rust contract validates the
851
+ * richer shape; the SDK authoring parser must preserve it for pack/publish. */
852
+ stability: z.unknown().optional(),
853
+
768
854
  /**
769
855
  * Lower-case hex `sha256(utf8_bytes(ui_code))` binding the plugin's bundled
770
856
  * sandboxed-UI code to this manifest. `ryu pack` / `ryu publish` compute it and
@@ -785,9 +871,34 @@ export const PluginManifestSchema = z
785
871
  */
786
872
  permission_grants: z.array(z.string()).default([]),
787
873
 
874
+ /** Deny-by-default sandbox permissions. Core remains authoritative; this
875
+ * schema mirrors the fields so `ryu pack` cannot strip them. */
876
+ permissions: z
877
+ .object({
878
+ fs: z
879
+ .object({
880
+ read: z.array(z.string()).default([]),
881
+ write: z.array(z.string()).default([]),
882
+ })
883
+ .optional(),
884
+ child_process: z.boolean().optional(),
885
+ run: z.array(z.string()).default([]),
886
+ network: z.union([z.boolean(), z.array(z.string())]).optional(),
887
+ tool: z.array(z.string()).default([]),
888
+ })
889
+ .optional(),
890
+
891
+ /** Core-owned permission presentation levels. Preserved verbatim here and
892
+ * validated by Core's authoritative manifest contract. */
893
+ permission_levels: z.unknown().optional(),
894
+
788
895
  /** Remote or stdio MCP servers registered by this plugin. */
789
896
  mcp_servers: z.record(z.string(), McpServerDeclSchema).optional(),
790
897
 
898
+ /** Core-owned sidecar declarations. Their full process/HTTP schema stays in
899
+ * the Rust contract; this authoring layer must never strip them. */
900
+ sidecars: z.unknown().optional(),
901
+
791
902
  /**
792
903
  * Optional Companion surface (an in-desktop overlay or sidebar panel).
793
904
  * Absent when the plugin has no Companion surface.
@@ -828,6 +939,10 @@ export const PluginManifestSchema = z
828
939
  */
829
940
  targets: z.array(SurfaceSchema).default([]),
830
941
 
942
+ /** Rich per-surface declarations (`support`, UI, contributed commands, …).
943
+ * Core owns and validates the nested vocabulary. */
944
+ surfaces: z.unknown().optional(),
945
+
831
946
  /**
832
947
  * Host version floors — the semver requirement each surface must satisfy for
833
948
  * this plugin to install. Mirrors Core's `EnginesReq`
@@ -954,6 +1069,10 @@ export const PluginManifestSchema = z
954
1069
  */
955
1070
  setup: z.union([SetupStepSchema, z.array(SetupStepSchema)]).optional(),
956
1071
  })
1072
+ // Core's Rust-derived schema is the full wire authority. Keep this deliberately
1073
+ // forward-compatible so a newly-added Core field survives SDK pack/publish even
1074
+ // before the simpler authoring schema grows first-class validation for it.
1075
+ .passthrough()
957
1076
  .superRefine((manifest, context) => {
958
1077
  const hasOAuthServer = Object.values(manifest.mcp_servers ?? {}).some(
959
1078
  (server) => server.auth?.type === "oauth"
package/src/mcp/server.ts CHANGED
@@ -28,7 +28,8 @@ import { callTool, listTools, MCP_PROTOCOL_VERSION } from "./client.ts";
28
28
  /** JSON Schema fragment — enough to describe a tool's input arguments. */
29
29
  export interface JsonSchema {
30
30
  description?: string;
31
- properties?: Record<string, JsonSchema>;
31
+ /** Property schemas may use any JSON Schema dialect or nested shape. */
32
+ properties?: Record<string, unknown>;
32
33
  required?: string[];
33
34
  type?: string;
34
35
  [key: string]: unknown;
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { PluginManifestSchema } from "../manifest.ts";
3
+ import { defineAction } from "./action.ts";
4
+ import type { GatewayClient, RunnableContext } from "./runnable-types.ts";
5
+
6
+ const gateway: GatewayClient = {
7
+ chat: async () => ({ content: "ok", finishReason: "stop" }),
8
+ async *stream() {
9
+ yield { content: "ok", finishReason: null };
10
+ },
11
+ };
12
+
13
+ const context: RunnableContext = { gateway };
14
+
15
+ const ticketAction = defineAction({
16
+ id: "action-create-ticket",
17
+ name: "Create Ticket",
18
+ description: "Create a support ticket for a customer.",
19
+ schema: {
20
+ type: "object",
21
+ properties: {
22
+ customer: { type: "string" },
23
+ summary: { type: "string" },
24
+ },
25
+ required: ["customer", "summary"],
26
+ },
27
+ outputSchema: {
28
+ type: "object",
29
+ properties: {
30
+ id: { type: "string" },
31
+ },
32
+ required: ["id"],
33
+ },
34
+ effect: "mutate",
35
+ needsApproval: true,
36
+ run: async ({ customer, summary }) => ({
37
+ id: `${customer}:${summary}`,
38
+ }),
39
+ });
40
+
41
+ describe("defineAction", () => {
42
+ it("returns a governed action with the same Runnable execution contract", async () => {
43
+ expect(ticketAction.kind).toBe("tool");
44
+ expect(ticketAction.action).toBe(true);
45
+ expect(ticketAction.description).toBe(
46
+ "Create a support ticket for a customer."
47
+ );
48
+ expect(ticketAction.schema.required).toEqual(["customer", "summary"]);
49
+ expect(ticketAction.outputSchema?.required).toEqual(["id"]);
50
+ expect(ticketAction.annotations).toEqual({
51
+ destructiveHint: true,
52
+ readOnlyHint: false,
53
+ });
54
+ expect(ticketAction.needsApproval).toBe(true);
55
+ expect(
56
+ await ticketAction.run({ customer: "acme", summary: "Login" }, context)
57
+ ).toEqual({ id: "acme:Login" });
58
+ });
59
+
60
+ it("rejects an action whose declared effect conflicts with its annotations", () => {
61
+ expect(() =>
62
+ defineAction({
63
+ id: "action-conflict",
64
+ name: "Conflicting Action",
65
+ description: "An invalid action.",
66
+ schema: { type: "object", properties: {} },
67
+ effect: "read",
68
+ annotations: { destructiveHint: true },
69
+ run: async () => null,
70
+ })
71
+ ).toThrow(/destructiveHint/);
72
+ });
73
+
74
+ it("lowers to a Core-compatible governed inline tool manifest", () => {
75
+ const manifest = ticketAction.toManifest({
76
+ id: "com.example.support",
77
+ version: "1.0.0",
78
+ grants: ["storage:kv"],
79
+ });
80
+
81
+ expect(() => PluginManifestSchema.parse(manifest)).not.toThrow();
82
+ expect(manifest.permission_grants).toEqual(["storage:kv", "tool:execute"]);
83
+ expect(manifest.runnables).toHaveLength(1);
84
+ expect(manifest.runnables[0]).toMatchObject({
85
+ id: "action-create-ticket",
86
+ name: "Create Ticket",
87
+ kind: "tool",
88
+ });
89
+ expect(manifest.runnables[0]?.config).toMatchObject({
90
+ slug: "action-create-ticket",
91
+ backend: "inline_deno",
92
+ action: true,
93
+ description: "Create a support ticket for a customer.",
94
+ input_schema: ticketAction.schema,
95
+ output_schema: ticketAction.outputSchema,
96
+ annotations: ticketAction.annotations,
97
+ needs_approval: true,
98
+ });
99
+ });
100
+
101
+ it("adapts the same implementation to the SDK MCP server", async () => {
102
+ const mcpTool = ticketAction.toMcpTool(context);
103
+
104
+ expect(mcpTool.name).toBe("action-create-ticket");
105
+ expect(mcpTool.description).toBe("Create a support ticket for a customer.");
106
+ expect(mcpTool.inputSchema).toEqual(ticketAction.schema);
107
+ expect(await mcpTool.run({ customer: "acme", summary: "MCP" })).toEqual({
108
+ id: "acme:MCP",
109
+ });
110
+ });
111
+
112
+ it("allows explicit read-only actions without approval", () => {
113
+ const action = defineAction({
114
+ id: "action-find-ticket",
115
+ name: "Find Ticket",
116
+ description: "Find a ticket by id.",
117
+ schema: { type: "object", properties: { id: { type: "string" } } },
118
+ effect: "read",
119
+ run: async ({ id }) => ({ id }),
120
+ });
121
+
122
+ expect(action.annotations).toEqual({
123
+ readOnlyHint: true,
124
+ destructiveHint: false,
125
+ });
126
+ expect(action.needsApproval).toBe(false);
127
+ });
128
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Canonical Ryu Action authoring API.
3
+ *
4
+ * An Action is the semantic contract for a business operation: one description,
5
+ * input schema, output schema, implementation, and effect declaration. It lowers
6
+ * to Ryu's existing governed `inline_deno` Tool backend, so this adds a coherent
7
+ * authoring seam without introducing a second Core execution runtime.
8
+ */
9
+
10
+ import {
11
+ type PluginManifest,
12
+ PluginManifestSchema,
13
+ type RunnableMeta,
14
+ type Surface,
15
+ } from "../manifest.ts";
16
+ import type { SdkRunnable } from "../mcp/server.ts";
17
+ import type { RunnableContext } from "./runnable-types.ts";
18
+ import {
19
+ defineTool,
20
+ type InlineToolManifestOptions,
21
+ inlineToolRunnable,
22
+ type ToolRunnable,
23
+ type ToolSchema,
24
+ } from "./tool.ts";
25
+
26
+ /** The two effects Core can enforce from tool annotations. */
27
+ export type ActionEffect = "mutate" | "read";
28
+
29
+ /** MCP-compatible effect hints plus any future provider-neutral boolean hints. */
30
+ export interface ActionAnnotations {
31
+ destructiveHint?: boolean;
32
+ idempotentHint?: boolean;
33
+ openWorldHint?: boolean;
34
+ readOnlyHint?: boolean;
35
+ [key: string]: boolean | undefined;
36
+ }
37
+
38
+ /** Options for defining a canonical Action. */
39
+ export interface ActionOptions<
40
+ TInput extends Record<string, unknown>,
41
+ TOutput,
42
+ > {
43
+ /** Additional MCP-compatible effect hints. */
44
+ annotations?: ActionAnnotations;
45
+ /** Description the agent reads when deciding whether to call the action. */
46
+ description: string;
47
+ /** Explicit effect used by read-only and approval enforcement. */
48
+ effect: ActionEffect;
49
+ /** Stable action id, also used as the generated Core tool slug. */
50
+ id: string;
51
+ /** Human-readable action name. */
52
+ name: string;
53
+ /** Require human approval even when global smart mode would not classify it. */
54
+ needsApproval?: boolean;
55
+ /** JSON Schema for the structured action result. */
56
+ outputSchema?: Record<string, unknown>;
57
+ /** The one implementation used by local callers and the packaged tool body. */
58
+ run(input: TInput, ctx: RunnableContext): Promise<TOutput>;
59
+ /** JSON Schema for all action inputs. */
60
+ schema: ToolSchema;
61
+ }
62
+
63
+ /** Options for lowering one Action into a standalone Ryu plugin manifest. */
64
+ export interface ActionManifestOptions {
65
+ /** Plugin activation events; defaults to eager activation. */
66
+ activationEvents?: readonly string[];
67
+ /** Extra Gateway grants required by the action body. */
68
+ grants?: readonly string[];
69
+ /** Reverse-domain plugin id (for example `com.acme.support`). */
70
+ id: string;
71
+ /** Display name; defaults to the Action name. */
72
+ name?: string;
73
+ /** Host surfaces this plugin targets. */
74
+ targets?: readonly Surface[];
75
+ /** Plugin semver. */
76
+ version: string;
77
+ }
78
+
79
+ /** A ToolRunnable with Action semantics and manifest/MCP adapters. */
80
+ export interface ActionRunnable<
81
+ TInput extends Record<string, unknown> = Record<string, unknown>,
82
+ TOutput = unknown,
83
+ > extends ToolRunnable<TInput, TOutput> {
84
+ /** Discriminates this semantic contract from an ordinary ToolRunnable. */
85
+ readonly action: true;
86
+ /** Effect hints lowered into Core's existing tool metadata. */
87
+ readonly annotations: ActionAnnotations;
88
+ /** Required action description. */
89
+ readonly description: string;
90
+ /** Declared effect. */
91
+ readonly effect: ActionEffect;
92
+ /** Whether Core must queue approval before execution. */
93
+ readonly needsApproval: boolean;
94
+ /** Structured result schema, when the action returns one. */
95
+ readonly outputSchema?: Record<string, unknown>;
96
+ /** Lower this Action to a validated, installable plugin manifest. */
97
+ toManifest(options: ActionManifestOptions): PluginManifest;
98
+ /** Adapt this Action to the SDK MCP server using the same implementation. */
99
+ toMcpTool(context: RunnableContext): SdkRunnable;
100
+ }
101
+
102
+ function deriveAnnotations(
103
+ effect: ActionEffect,
104
+ annotations: ActionAnnotations | undefined
105
+ ): ActionAnnotations {
106
+ const resolved = {
107
+ ...annotations,
108
+ readOnlyHint: annotations?.readOnlyHint ?? effect === "read",
109
+ destructiveHint: annotations?.destructiveHint ?? effect === "mutate",
110
+ };
111
+
112
+ if (effect === "read" && resolved.destructiveHint) {
113
+ throw new Error(
114
+ "[ryu-sdk] read actions cannot set annotations.destructiveHint=true"
115
+ );
116
+ }
117
+ if (effect === "mutate" && resolved.readOnlyHint) {
118
+ throw new Error(
119
+ "[ryu-sdk] mutate actions cannot set annotations.readOnlyHint=true"
120
+ );
121
+ }
122
+
123
+ return resolved;
124
+ }
125
+
126
+ function actionManifestEntry(action: ActionRunnable): RunnableMeta {
127
+ const options: InlineToolManifestOptions = {
128
+ action: true,
129
+ annotations: action.annotations,
130
+ description: action.description,
131
+ needsApproval: action.needsApproval,
132
+ ...(action.outputSchema ? { outputSchema: action.outputSchema } : {}),
133
+ };
134
+ return inlineToolRunnable(action, options);
135
+ }
136
+
137
+ function actionToManifest(
138
+ action: ActionRunnable,
139
+ options: ActionManifestOptions
140
+ ): PluginManifest {
141
+ const grants = [...new Set([...(options.grants ?? []), "tool:execute"])];
142
+ const raw = {
143
+ id: options.id,
144
+ name: options.name ?? action.name,
145
+ version: options.version,
146
+ runnables: [actionManifestEntry(action)],
147
+ permission_grants: grants,
148
+ activation_events: [...(options.activationEvents ?? ["*"])],
149
+ targets: [...(options.targets ?? [])],
150
+ };
151
+ const result = PluginManifestSchema.safeParse(raw);
152
+ if (!result.success) {
153
+ const first = result.error.issues[0];
154
+ const field = first?.path.join(".") ?? "unknown";
155
+ const message = first?.message ?? "validation failed";
156
+ throw new Error(
157
+ `[ryu-sdk] action manifest validation failed at '${field}': ${message}`
158
+ );
159
+ }
160
+ return result.data;
161
+ }
162
+
163
+ /**
164
+ * Define one business operation that can be run locally, packaged as a Core
165
+ * tool, or registered on an SDK MCP server without rewriting its implementation.
166
+ */
167
+ export function defineAction<
168
+ TInput extends Record<string, unknown> = Record<string, unknown>,
169
+ TOutput = unknown,
170
+ >(options: ActionOptions<TInput, TOutput>): ActionRunnable<TInput, TOutput> {
171
+ const annotations = deriveAnnotations(options.effect, options.annotations);
172
+ const tool = defineTool({
173
+ description: options.description,
174
+ id: options.id,
175
+ name: options.name,
176
+ run: options.run,
177
+ schema: options.schema,
178
+ });
179
+
180
+ const action: ActionRunnable<TInput, TOutput> = {
181
+ ...tool,
182
+ action: true,
183
+ annotations,
184
+ description: options.description,
185
+ effect: options.effect,
186
+ needsApproval: options.needsApproval ?? false,
187
+ ...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
188
+ toManifest(manifestOptions) {
189
+ return actionToManifest(action, manifestOptions);
190
+ },
191
+ toMcpTool(context) {
192
+ return {
193
+ name: action.id,
194
+ description: action.description,
195
+ inputSchema: action.schema,
196
+ run: (input) => action.run(input as TInput, context),
197
+ };
198
+ },
199
+ };
200
+
201
+ return action;
202
+ }