@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
@@ -14,11 +14,13 @@
14
14
  * - A **companion** tool (`accessible:true`) is a call target a mounted widget
15
15
  * may invoke: it carries `widget_accessible:true` and gets no widget template.
16
16
  *
17
- * v1 boundary: this is **declarative pass-through only** there is no `run`
18
- * handler. Third-party tool code execution needs the plugin runtime (out of
19
- * scope); the widget renders from `window.openai.toolInput`/`toolOutput` and Core
20
- * echoes the validated arguments as `structuredContent`. `ryu pack` bundles the
21
- * `uiEntry` source into the manifest's `ui_code`.
17
+ * A tool may also attach a `ToolRunnable`. When present, its self-contained run
18
+ * body is emitted as Core's grant-gated `inline_deno` backend, so the widget and
19
+ * the behavior ship in one bundle. Without a runnable, the tool remains the
20
+ * declarative pass-through form: the widget renders from
21
+ * `window.openai.toolInput`/`toolOutput` and Core echoes the validated arguments
22
+ * as `structuredContent`. `ryu pack` bundles the `uiEntry` source into the
23
+ * manifest's `ui_code`.
22
24
  */
23
25
 
24
26
  import type {
@@ -26,11 +28,13 @@ import type {
26
28
  PluginManifest,
27
29
  Requires,
28
30
  RunnableMeta,
31
+ SlashCommandContribution,
29
32
  Surface,
30
33
  ToolAppConfig,
31
34
  WidgetContribution,
32
35
  } from "../manifest.ts";
33
36
  import { PluginManifestSchema } from "../manifest.ts";
37
+ import { inlineToolRunnable, type ToolRunnable } from "./tool.ts";
34
38
 
35
39
  /** The default widget MIME dialect (mirrors Core `default_widget_mime`). */
36
40
  const DEFAULT_APP_WIDGET_MIME = "text/html+skybridge";
@@ -59,6 +63,21 @@ function withWidgetRenderGrant(
59
63
  return out;
60
64
  }
61
65
 
66
+ /** Add the grant required for any executable inline tool body. */
67
+ function withToolExecuteGrant(
68
+ grants: readonly string[],
69
+ tools: readonly AppToolSpec[]
70
+ ): string[] {
71
+ const out = [...grants];
72
+ if (
73
+ tools.some((tool) => tool.runnable !== undefined) &&
74
+ !out.includes("tool:execute")
75
+ ) {
76
+ out.push("tool:execute");
77
+ }
78
+ return out;
79
+ }
80
+
62
81
  /** One tool a Ryu App declares. */
63
82
  export interface AppToolSpec {
64
83
  /**
@@ -76,6 +95,13 @@ export interface AppToolSpec {
76
95
  invoking?: string;
77
96
  /** Tool name (unqualified). The wire id is `<server>.<name>`. */
78
97
  name: string;
98
+ /**
99
+ * Optional executable body for this tool. Core receives it as an
100
+ * `inline_deno` runnable and runs it in the deny-by-default tool sandbox.
101
+ * The body must be self-contained; use the injected `host` capability surface
102
+ * for governed model calls and other platform primitives.
103
+ */
104
+ runnable?: Pick<ToolRunnable, "code" | "id" | "name" | "schema">;
79
105
  }
80
106
 
81
107
  /**
@@ -116,6 +142,8 @@ export interface DefineAppOptions {
116
142
  requires?: DefineAppRequires;
117
143
  /** MCP server namespace for the tool ids. Defaults to `slug`. */
118
144
  server?: string;
145
+ /** Slash commands this app exposes in the chat composer. */
146
+ slashCommands?: SlashCommandContribution[];
119
147
  /**
120
148
  * App slug — used to build the widget uri (`ui://widget/<slug>.html`) and, when
121
149
  * `server` is omitted, the MCP server namespace that qualifies each tool id.
@@ -182,8 +210,12 @@ export function defineApp(options: DefineAppOptions): PluginManifest {
182
210
  for (const spec of options.tools) {
183
211
  const isRender = spec.accessible !== true;
184
212
  const id = appToolId(server, spec.name);
213
+ const executable = spec.runnable
214
+ ? inlineToolRunnable(spec.runnable)
215
+ : undefined;
185
216
 
186
- const config: ToolAppConfig = {
217
+ const config: ToolAppConfig & Record<string, unknown> = {
218
+ ...(executable?.config ?? {}),
187
219
  slug: id,
188
220
  description: spec.description,
189
221
  widget: isRender,
@@ -220,7 +252,7 @@ export function defineApp(options: DefineAppOptions): PluginManifest {
220
252
  hook_events: [],
221
253
  composer_controls: [],
222
254
  settings_tabs: [],
223
- slash_commands: [],
255
+ slash_commands: options.slashCommands ?? [],
224
256
  sidebar_sections: [],
225
257
  sidebar_buttons: [],
226
258
  dock_panels: [],
@@ -238,6 +270,7 @@ export function defineApp(options: DefineAppOptions): PluginManifest {
238
270
  pi_extensions: [],
239
271
  output_styles: [],
240
272
  message_actions: [],
273
+ selection_actions: [],
241
274
  widgets,
242
275
  };
243
276
 
@@ -252,15 +285,15 @@ export function defineApp(options: DefineAppOptions): PluginManifest {
252
285
  // Core gates widget promotion on declared-AND-enabled-AND-granted, and a
253
286
  // missing grant fails as `DeniedNoGrant` — which is an `info!` log and
254
287
  // nothing else. The widget silently renders as plain text, with no error
255
- // in the UI and nothing pointing at the manifest. Every app scaffolded
256
- // through `defineApp` hit that, because the only fix was a grant string
257
- // the templates never mention and the builder never added; the one
258
- // working example on disk hand-writes it.
288
+ // in the UI and nothing pointing at the manifest.
259
289
  //
260
290
  // Added only when there is a widget to render, and unioned rather than
261
291
  // overwritten so an author's own `grants` list survives and re-declaring
262
292
  // it is not an error.
263
- permission_grants: withWidgetRenderGrant(options.grants ?? [], widgets),
293
+ permission_grants: withToolExecuteGrant(
294
+ withWidgetRenderGrant(options.grants ?? [], widgets),
295
+ options.tools
296
+ ),
264
297
  activation_events: options.activationEvents ?? ["*"],
265
298
  contributes,
266
299
  // `targets: []` means EVERY surface, so an app that declares none is
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Runnable — the single contract unifying Agent, Workflow, Tool, and Skill
3
- * in the Ryu SDK.
2
+ * Runnable — the execution contract unifying Agent, Workflow, Tool, and Skill
3
+ * in the Ryu SDK. `defineAction` is the governed business-operation facade over
4
+ * the Tool kind.
4
5
  *
5
6
  * Design rules (from the M8 spike doc packages/sdk/README.md):
6
7
  * - An agent may invoke a workflow as a named tool.
@@ -12,6 +13,14 @@
12
13
  * import from `@ryuhq/sdk/runnable` as a single entry point.
13
14
  */
14
15
 
16
+ export type {
17
+ ActionAnnotations,
18
+ ActionEffect,
19
+ ActionManifestOptions,
20
+ ActionOptions,
21
+ ActionRunnable,
22
+ } from "./action.ts";
23
+ export { defineAction } from "./action.ts";
15
24
  export type {
16
25
  AgentCard,
17
26
  AgentManifestOptions,
@@ -42,6 +51,7 @@ export type {
42
51
  RealtimeClient,
43
52
  RealtimeSubscription,
44
53
  RyuPrimitives,
54
+ StorageClient,
45
55
  SttClient,
46
56
  TtsClient,
47
57
  } from "./primitives.ts";
@@ -57,7 +67,12 @@ export type {
57
67
  } from "./runnable-types.ts";
58
68
  export type { SkillOptions } from "./skill.ts";
59
69
  export { defineSkill } from "./skill.ts";
60
- export type { JsonSchemaProperty, ToolOptions, ToolSchema } from "./tool.ts";
70
+ export type {
71
+ InlineToolManifestOptions,
72
+ JsonSchemaProperty,
73
+ ToolOptions,
74
+ ToolSchema,
75
+ } from "./tool.ts";
61
76
  export { defineTool } from "./tool.ts";
62
77
  export type { WorkflowOptions, WorkflowStep } from "./workflow.ts";
63
78
  export { defineWorkflow } from "./workflow.ts";
@@ -158,6 +158,35 @@ describe("createPrimitives — transport routing mirrors rpc.ts", () => {
158
158
  expect(ragCall.body).toMatchObject({ op: "retrieve" });
159
159
  });
160
160
 
161
+ it("storage primitives use the grant-gated app-owned KV bridge", async () => {
162
+ const { transport, calls } = recordingTransport();
163
+ const p = createPrimitives(transport);
164
+ await p.storage.get({ namespace: "tickets", key: "t-1" });
165
+ await p.storage.set({ namespace: "tickets", key: "t-1", value: "open" });
166
+ await p.storage.delete({ namespace: "tickets", key: "t-1" });
167
+ await p.storage.keys({ namespace: "tickets" });
168
+ await p.storage.compareAndSet({
169
+ namespace: "tickets",
170
+ key: "t-1",
171
+ expected: null,
172
+ value: "open",
173
+ });
174
+
175
+ expect(calls.map((c) => c.target)).toEqual([
176
+ "storage.get",
177
+ "storage.set",
178
+ "storage.delete",
179
+ "storage.keys",
180
+ "storage.compareAndSet",
181
+ ]);
182
+ expect(calls.every((c) => c.op === "bridge")).toBe(true);
183
+ expect(calls[1]?.body).toEqual({
184
+ namespace: "tickets",
185
+ key: "t-1",
186
+ value: "open",
187
+ });
188
+ });
189
+
161
190
  it("every declared primitive has a binding entry (no silent drift)", () => {
162
191
  const expected = [
163
192
  "engines.complete",
@@ -174,6 +203,11 @@ describe("createPrimitives — transport routing mirrors rpc.ts", () => {
174
203
  "realtime.subscribe",
175
204
  "durable.checkpoint",
176
205
  "durable.resume",
206
+ "storage.get",
207
+ "storage.set",
208
+ "storage.delete",
209
+ "storage.keys",
210
+ "storage.compareAndSet",
177
211
  ];
178
212
  for (const key of expected) {
179
213
  expect(PRIMITIVE_BINDINGS[key]).toBeDefined();
@@ -326,6 +326,31 @@ export const PRIMITIVE_BINDINGS: Record<string, PrimitiveBinding> = {
326
326
  method: "background.stop",
327
327
  grant: "background:control",
328
328
  },
329
+ "storage.get": {
330
+ transport: "bridge",
331
+ method: "storage.get",
332
+ grant: "storage:kv",
333
+ },
334
+ "storage.set": {
335
+ transport: "bridge",
336
+ method: "storage.set",
337
+ grant: "storage:kv",
338
+ },
339
+ "storage.delete": {
340
+ transport: "bridge",
341
+ method: "storage.delete",
342
+ grant: "storage:kv",
343
+ },
344
+ "storage.keys": {
345
+ transport: "bridge",
346
+ method: "storage.keys",
347
+ grant: "storage:kv",
348
+ },
349
+ "storage.compareAndSet": {
350
+ transport: "bridge",
351
+ method: "storage.compareAndSet",
352
+ grant: "storage:kv",
353
+ },
329
354
  // Host-direct media data-path (the host holds the node token; returns data: URLs).
330
355
  "image.generate": {
331
356
  transport: "direct",
@@ -509,6 +534,25 @@ export interface BackgroundClient {
509
534
  }>;
510
535
  }
511
536
 
537
+ /** Durable app-owned key/value state (`storage:kv` via the host bridge). */
538
+ export interface StorageClient {
539
+ /** Atomically replace a value when it still equals `expected`. */
540
+ compareAndSet(input: {
541
+ expected: string | null;
542
+ key: string;
543
+ namespace?: string;
544
+ value: string | null;
545
+ }): Promise<boolean>;
546
+ /** Delete one value. */
547
+ delete(input: { key: string; namespace?: string }): Promise<void>;
548
+ /** Read one value; missing keys return `null`. */
549
+ get(input: { key: string; namespace?: string }): Promise<string | null>;
550
+ /** List keys in an app-owned namespace. */
551
+ keys(input?: { namespace?: string }): Promise<string[]>;
552
+ /** Write one string value. JSON can be encoded by the app when needed. */
553
+ set(input: { key: string; namespace?: string; value: string }): Promise<void>;
554
+ }
555
+
512
556
  /** TTS primitive — speech synthesis (`crates/ryu-tts`). */
513
557
  export interface TtsClient {
514
558
  /**
@@ -561,6 +605,7 @@ export interface RyuPrimitives {
561
605
  memory: MemoryClient;
562
606
  rag: RagClient;
563
607
  realtime: RealtimeClient;
608
+ storage: StorageClient;
564
609
  stt: SttClient;
565
610
  tts: TtsClient;
566
611
  }
@@ -599,6 +644,20 @@ export function createPrimitives(transport: PrimitiveTransport): RyuPrimitives {
599
644
  process_id: string;
600
645
  }>,
601
646
  },
647
+ storage: {
648
+ get: (input) =>
649
+ transport.bridge("storage.get", input) as Promise<string | null>,
650
+ set: (input) =>
651
+ transport.bridge("storage.set", input).then(() => undefined),
652
+ delete: (input) =>
653
+ transport.bridge("storage.delete", input).then(() => undefined),
654
+ keys: (input) =>
655
+ transport
656
+ .bridge("storage.keys", input ?? {})
657
+ .then((value) => (Array.isArray(value) ? (value as string[]) : [])),
658
+ compareAndSet: (input) =>
659
+ transport.bridge("storage.compareAndSet", input) as Promise<boolean>,
660
+ },
602
661
  rag: {
603
662
  retrieve: (input) =>
604
663
  brokerCall(transport, "rag", "retrieve", input) as Promise<RagChunk[]>,
@@ -14,6 +14,7 @@ import type {
14
14
  MemoryClient,
15
15
  RagClient,
16
16
  RealtimeClient,
17
+ StorageClient,
17
18
  SttClient,
18
19
  TtsClient,
19
20
  } from "./primitives.ts";
@@ -81,6 +82,8 @@ export interface RunnableContext {
81
82
  sessionId?: string;
82
83
  /** Signal to abort a long-running run. */
83
84
  signal?: AbortSignal;
85
+ /** Durable app-owned KV state, gated by the manifest's `storage:kv` grant. */
86
+ storage?: StorageClient;
84
87
  /** STT primitive: transcribe (`crates/ryu-stt`). */
85
88
  stt?: SttClient;
86
89
  /** TTS primitive: speak (`crates/ryu-tts`). */
@@ -55,12 +55,16 @@ export interface ToolSchema {
55
55
  required?: string[];
56
56
  /** Type is always "object" for a tool's top-level input schema. */
57
57
  type: "object";
58
+ /** Preserve additional JSON Schema keywords for Core/MCP consumers. */
59
+ [key: string]: unknown;
58
60
  }
59
61
 
60
62
  // ── Options ───────────────────────────────────────────────────────────────────
61
63
 
62
64
  /** Options accepted by `defineTool`. */
63
65
  export interface ToolOptions<TInput extends Record<string, unknown>, TOutput> {
66
+ /** Optional description surfaced to models and tool discovery. */
67
+ description?: string;
64
68
  /** Stable unique identifier (e.g. "tool-web-search"). */
65
69
  id: string;
66
70
  /** Human-readable display name. */
@@ -171,6 +175,8 @@ export interface ToolRunnable<
171
175
  * the sandbox form is the second parameter aliased to `host`.
172
176
  */
173
177
  readonly code: string;
178
+ /** Optional description surfaced to models and tool discovery. */
179
+ readonly description?: string;
174
180
  readonly kind: "tool";
175
181
  /** JSON Schema for this tool's input — compatible with Core's ToolInfo.schema. */
176
182
  readonly schema: ToolSchema;
@@ -211,7 +217,7 @@ export function defineTool<
211
217
  TInput extends Record<string, unknown> = Record<string, unknown>,
212
218
  TOutput = unknown,
213
219
  >(options: ToolOptions<TInput, TOutput>): ToolRunnable<TInput, TOutput> {
214
- const { id, name, schema, run } = options;
220
+ const { description, id, name, schema, run } = options;
215
221
 
216
222
  // Serialize the run body for Core's `inline_deno` backend — the same approach
217
223
  // `defineTurnHook` uses: the sandbox wraps this in an async IIFE where `input`
@@ -223,6 +229,7 @@ export function defineTool<
223
229
  name,
224
230
  kind: "tool",
225
231
  schema,
232
+ ...(description === undefined ? {} : { description }),
226
233
  code,
227
234
  run(input: TInput, ctx: RunnableContext): Promise<TOutput> {
228
235
  validateInput(input, schema, id);
@@ -231,6 +238,20 @@ export function defineTool<
231
238
  } satisfies ToolRunnable<TInput, TOutput>;
232
239
  }
233
240
 
241
+ /** Optional metadata lowered alongside a tool's executable backend. */
242
+ export interface InlineToolManifestOptions {
243
+ /** Marks the entry as a semantic Ryu Action. */
244
+ action?: boolean;
245
+ /** MCP-style effect annotations, for example `readOnlyHint`. */
246
+ annotations?: Record<string, boolean | undefined>;
247
+ /** Description shown in Core's unified tool catalog. */
248
+ description?: string;
249
+ /** Force the human approval gate before the tool runs. */
250
+ needsApproval?: boolean;
251
+ /** JSON Schema for the structured result. */
252
+ outputSchema?: Record<string, unknown>;
253
+ }
254
+
234
255
  /**
235
256
  * Convert a {@link ToolRunnable} into a `manifest.json` `kind:"tool"` runnable that
236
257
  * ships its `run` body as Core's `inline_deno` backend. The emitted config
@@ -242,8 +263,8 @@ export function defineTool<
242
263
  * The plugin must declare the `tool:execute` grant (see `definePlugin`).
243
264
  */
244
265
  export function inlineToolRunnable(
245
- tool: ToolRunnable,
246
- options?: { description?: string }
266
+ tool: Pick<ToolRunnable, "code" | "description" | "id" | "name" | "schema">,
267
+ options?: InlineToolManifestOptions
247
268
  ): RunnableMeta {
248
269
  return {
249
270
  id: tool.id,
@@ -254,7 +275,17 @@ export function inlineToolRunnable(
254
275
  backend: "inline_deno",
255
276
  code: tool.code,
256
277
  input_schema: tool.schema,
257
- ...(options?.description ? { description: options.description } : {}),
278
+ ...(options?.description === undefined
279
+ ? tool.description === undefined
280
+ ? {}
281
+ : { description: tool.description }
282
+ : { description: options.description }),
283
+ ...(options?.outputSchema ? { output_schema: options.outputSchema } : {}),
284
+ ...(options?.annotations ? { annotations: options.annotations } : {}),
285
+ ...(options?.action === undefined ? {} : { action: options.action }),
286
+ ...(options?.needsApproval === undefined
287
+ ? {}
288
+ : { needs_approval: options.needsApproval }),
258
289
  },
259
290
  };
260
291
  }
@@ -18,6 +18,7 @@ import type {
18
18
  HookEventContribution,
19
19
  PluginManifest,
20
20
  RunnableMeta,
21
+ SlashCommandContribution,
21
22
  Surface,
22
23
  TurnHookContribution,
23
24
  } from "../manifest.ts";
@@ -144,8 +145,8 @@ export interface DefinePluginOptions {
144
145
  requires?: DefineAppRequires;
145
146
  /** Declarative settings tabs (model pickers, fields), passed verbatim. */
146
147
  settingsTabs?: Record<string, unknown>[];
147
- /** Declarative slash commands, passed verbatim. */
148
- slashCommands?: Record<string, unknown>[];
148
+ /** Declarative slash commands, including sequential argument choices. */
149
+ slashCommands?: SlashCommandContribution[];
149
150
  /**
150
151
  * Host surfaces this plugin runs on. **Omitted/empty = every surface** (the
151
152
  * backward-compatible default); it never means "hidden".
@@ -190,6 +191,7 @@ export function definePlugin(options: DefinePluginOptions): PluginManifest {
190
191
  pi_extensions: [],
191
192
  output_styles: [],
192
193
  message_actions: [],
194
+ selection_actions: [],
193
195
  };
194
196
  // Ship each inline tool as a `kind:"tool"` runnable (Core's `inline_deno`
195
197
  // backend). Shipping tools requires the `tool:execute` grant; add it once.
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ app,
4
+ defineApp,
5
+ definePlugin,
6
+ SlashCommandContributionSchema,
7
+ } from "./index.ts";
8
+
9
+ const slashCommand = {
10
+ args: [
11
+ {
12
+ custom: { label: "Use another environment" },
13
+ name: "environment",
14
+ options: [
15
+ { label: "Staging", value: "staging" },
16
+ { label: "Production", value: "production" },
17
+ ],
18
+ },
19
+ ],
20
+ command: "/deploy",
21
+ description: "Deploy the current project",
22
+ };
23
+
24
+ describe("slash command contributions", () => {
25
+ test("validate sequential options and custom choices", () => {
26
+ expect(SlashCommandContributionSchema.parse(slashCommand)).toEqual(
27
+ slashCommand
28
+ );
29
+ });
30
+
31
+ test("defineApp publishes slash commands", () => {
32
+ const manifest = defineApp({
33
+ id: "com.example.deploy",
34
+ title: "Deploy",
35
+ version: "1.0.0",
36
+ slug: "deploy",
37
+ uiEntry: "src/deploy.tsx",
38
+ tools: [{ name: "render", description: "Render deploy status" }],
39
+ slashCommands: [slashCommand],
40
+ });
41
+
42
+ expect(manifest.contributes?.slash_commands).toEqual([slashCommand]);
43
+ });
44
+
45
+ test("definePlugin publishes slash commands", () => {
46
+ const manifest = definePlugin({
47
+ id: "com.example.deploy-plugin",
48
+ name: "Deploy",
49
+ version: "1.0.0",
50
+ slashCommands: [slashCommand],
51
+ });
52
+
53
+ expect(manifest.contributes?.slash_commands).toEqual([slashCommand]);
54
+ });
55
+
56
+ test("AppBuilder publishes slash commands", () => {
57
+ const manifest = app()
58
+ .id("com.example.deploy-builder")
59
+ .title("Deploy")
60
+ .version("1.0.0")
61
+ .slug("deploy-builder")
62
+ .uiEntry("src/deploy.tsx")
63
+ .tool({ name: "render", description: "Render deploy status" })
64
+ .slashCommand(slashCommand)
65
+ .build();
66
+
67
+ expect(manifest.contributes?.slash_commands).toEqual([slashCommand]);
68
+ });
69
+ });