@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.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * A "Ryu App" bundles one or more tools whose results render an interactive
5
5
  * widget inline in chat (the ChatGPT-Apps-style surface). `defineApp` assembles a
6
- * complete `plugin.json` `PluginManifest` from a declarative description, deriving
6
+ * complete `manifest.json` `PluginManifest` from a declarative description, deriving
7
7
  * the render-vs-companion split exactly the way Core's in-process provider does
8
8
  * (`apps/core/src/sidecar/mcp/apps/mod.rs` `tools()`):
9
9
  *
@@ -37,6 +37,28 @@ const DEFAULT_APP_WIDGET_MIME = "text/html+skybridge";
37
37
  /** The default widget display mode (mirrors Core `default_widget_display_mode`). */
38
38
  const DEFAULT_APP_DISPLAY_MODE = "inline";
39
39
 
40
+ /**
41
+ * The grant Core requires before it will promote a tool's result into an inline
42
+ * chat widget (mirrors Core's `WIDGET_RENDER_GRANT`).
43
+ */
44
+ export const WIDGET_RENDER_GRANT = "widget:render";
45
+
46
+ /**
47
+ * `grants` plus {@link WIDGET_RENDER_GRANT} when the app actually contributes a
48
+ * widget. Order-preserving and idempotent, so an author who already declared it
49
+ * gets no duplicate.
50
+ */
51
+ function withWidgetRenderGrant(
52
+ grants: readonly string[],
53
+ widgets: readonly WidgetContribution[]
54
+ ): string[] {
55
+ const out = [...grants];
56
+ if (widgets.length > 0 && !out.includes(WIDGET_RENDER_GRANT)) {
57
+ out.push(WIDGET_RENDER_GRANT);
58
+ }
59
+ return out;
60
+ }
61
+
40
62
  /** One tool a Ryu App declares. */
41
63
  export interface AppToolSpec {
42
64
  /**
@@ -123,7 +145,7 @@ export function appToolId(server: string, name: string): string {
123
145
  }
124
146
 
125
147
  /**
126
- * Assemble a `plugin.json` manifest for a Ryu App. The result matches Core's
148
+ * Assemble a `manifest.json` manifest for a Ryu App. The result matches Core's
127
149
  * `PluginManifest` serde shape (validated through `PluginManifestSchema`) and can
128
150
  * be written to disk, packed with `ryu pack`, or published with `ryu publish`.
129
151
  *
@@ -191,9 +213,25 @@ export function defineApp(options: DefineAppOptions): PluginManifest {
191
213
 
192
214
  const contributes: Contributes = {
193
215
  turn_hooks: [],
216
+ // This builder synthesises an app from its runnables; an app that emits
217
+ // events declares them in a hand-authored `manifest.json`, same as
218
+ // `lsp_servers` below.
219
+ hook_events: [],
194
220
  composer_controls: [],
195
221
  settings_tabs: [],
196
222
  slash_commands: [],
223
+ sidebar_sections: [],
224
+ sidebar_buttons: [],
225
+ dock_panels: [],
226
+ // Empty for the same reason as every sibling family above: this builder
227
+ // synthesises `widgets` from the app's own runnables and nothing else, and
228
+ // takes no `contributes` passthrough. An app that wants to declare language
229
+ // servers writes them in a hand-authored `manifest.json`.
230
+ lsp_servers: {},
231
+ // Same reason again: a danger-zone category and a Pi extension are both
232
+ // hand-authored declarations, not something derivable from runnables.
233
+ data_categories: [],
234
+ pi_extensions: [],
197
235
  widgets,
198
236
  };
199
237
 
@@ -202,7 +240,21 @@ export function defineApp(options: DefineAppOptions): PluginManifest {
202
240
  name: options.title,
203
241
  version: options.version,
204
242
  runnables,
205
- permission_grants: options.grants ?? [],
243
+ // An app that synthesises widgets MUST hold `widget:render`, so this
244
+ // builder declares it rather than leaving the author to discover it.
245
+ //
246
+ // Core gates widget promotion on declared-AND-enabled-AND-granted, and a
247
+ // missing grant fails as `DeniedNoGrant` — which is an `info!` log and
248
+ // nothing else. The widget silently renders as plain text, with no error
249
+ // in the UI and nothing pointing at the manifest. Every app scaffolded
250
+ // through `defineApp` hit that, because the only fix was a grant string
251
+ // the templates never mention and the builder never added; the one
252
+ // working example on disk hand-writes it.
253
+ //
254
+ // Added only when there is a widget to render, and unioned rather than
255
+ // overwritten so an author's own `grants` list survives and re-declaring
256
+ // it is not an error.
257
+ permission_grants: withWidgetRenderGrant(options.grants ?? [], widgets),
206
258
  activation_events: options.activationEvents ?? ["*"],
207
259
  contributes,
208
260
  // `targets: []` means EVERY surface, so an app that declares none is
@@ -227,7 +279,9 @@ export function defineApp(options: DefineAppOptions): PluginManifest {
227
279
  const first = result.error.issues[0];
228
280
  const field = first?.path.join(".") ?? "unknown";
229
281
  const message = first?.message ?? "validation failed";
230
- throw new Error(`plugin.json validation failed at '${field}': ${message}`);
282
+ throw new Error(
283
+ `manifest.json validation failed at '${field}': ${message}`
284
+ );
231
285
  }
232
286
  return result.data;
233
287
  }
@@ -77,11 +77,7 @@ describe("createPrimitives — transport routing mirrors rpc.ts", () => {
77
77
  let seen: { url: string; init: RequestInit } | undefined;
78
78
  const fetchImpl = ((url: string, init: RequestInit) => {
79
79
  seen = { url, init };
80
- return Promise.resolve(
81
- new Response(JSON.stringify({ text: " hello world " }), {
82
- headers: { "content-type": "application/json" },
83
- })
84
- );
80
+ return Promise.resolve(Response.json({ text: " hello world " }));
85
81
  }) as unknown as typeof fetch;
86
82
 
87
83
  const transport = httpPrimitiveTransport({
@@ -97,14 +97,17 @@ function dataUrlToBytes(dataUrl: string): {
97
97
  }
98
98
  return { bytes, mediaType };
99
99
  }
100
- return { bytes: new TextEncoder().encode(decodeURIComponent(payload)), mediaType };
100
+ return {
101
+ bytes: new TextEncoder().encode(decodeURIComponent(payload)),
102
+ mediaType,
103
+ };
101
104
  }
102
105
 
103
106
  /** Encode raw bytes as a `data:<mediaType>;base64,...` URL. */
104
107
  function bytesToDataUrl(bytes: Uint8Array, mediaType: string): string {
105
108
  let binary = "";
106
109
  // Chunk to stay well under the argument-count ceiling of String.fromCharCode.
107
- const chunk = 0x8000;
110
+ const chunk = 0x80_00;
108
111
  for (let i = 0; i < bytes.length; i += chunk) {
109
112
  binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
110
113
  }
@@ -236,9 +239,7 @@ export function httpPrimitiveTransport(
236
239
  data?: Array<{ url?: string; b64_json?: string }>;
237
240
  };
238
241
  return (parsed.data ?? []).map((item) =>
239
- item.url
240
- ? item.url
241
- : `data:image/png;base64,${item.b64_json ?? ""}`
242
+ item.url ? item.url : `data:image/png;base64,${item.b64_json ?? ""}`
242
243
  );
243
244
  };
244
245
 
@@ -153,9 +153,6 @@ export interface ToolRunnable<
153
153
  TInput extends Record<string, unknown> = Record<string, unknown>,
154
154
  TOutput = unknown,
155
155
  > extends Runnable<TInput, TOutput> {
156
- readonly kind: "tool";
157
- /** JSON Schema for this tool's input — compatible with Core's ToolInfo.schema. */
158
- readonly schema: ToolSchema;
159
156
  /**
160
157
  * The `run` body serialized for Core's `inline_deno` tool backend — the exact
161
158
  * same technique `defineTurnHook` uses for its `code`. This is what makes a
@@ -174,6 +171,9 @@ export interface ToolRunnable<
174
171
  * the sandbox form is the second parameter aliased to `host`.
175
172
  */
176
173
  readonly code: string;
174
+ readonly kind: "tool";
175
+ /** JSON Schema for this tool's input — compatible with Core's ToolInfo.schema. */
176
+ readonly schema: ToolSchema;
177
177
  }
178
178
 
179
179
  // ── Factory ───────────────────────────────────────────────────────────────────
@@ -232,7 +232,7 @@ export function defineTool<
232
232
  }
233
233
 
234
234
  /**
235
- * Convert a {@link ToolRunnable} into a `plugin.json` `kind:"tool"` runnable that
235
+ * Convert a {@link ToolRunnable} into a `manifest.json` `kind:"tool"` runnable that
236
236
  * ships its `run` body as Core's `inline_deno` backend. The emitted config
237
237
  * mirrors Core's `ToolConfig` (`apps/core/src/plugin_manifest/schema.rs`):
238
238
  * `{ slug, backend:"inline_deno", code, description?, input_schema }`. Core
@@ -15,6 +15,7 @@
15
15
 
16
16
  import type {
17
17
  Contributes,
18
+ HookEventContribution,
18
19
  PluginManifest,
19
20
  RunnableMeta,
20
21
  Surface,
@@ -90,9 +91,12 @@ export interface DefineTurnHookOptions {
90
91
  * is serialized into the sandbox `code` string and invoked with `ctx`/`host` at
91
92
  * run time.
92
93
  */
94
+ // `code` is optional on `TurnHookContribution` because a hand-authored manifest may
95
+ // carry `code_file` instead. This builder always produces the inline form, so it
96
+ // narrows the return type — callers get a `code` they need not null-check.
93
97
  export function defineTurnHook(
94
98
  options: DefineTurnHookOptions
95
- ): TurnHookContribution {
99
+ ): TurnHookContribution & { code: string } {
96
100
  const source = options.run.toString();
97
101
  // The sandbox wraps `code` in an async IIFE where `ctx`/`host` are in scope
98
102
  // and a bare `return` reports the directive — so call the serialized function
@@ -112,8 +116,24 @@ export interface DefinePluginOptions {
112
116
  composerControls?: Record<string, unknown>[];
113
117
  /** Capability grants the hooks need (e.g. `["hook:side-model", "storage:kv"]`). */
114
118
  grants?: string[];
119
+ /**
120
+ * App events this plugin EMITS — the provider half of the hook system whose
121
+ * consumer half is {@link DefinePluginOptions.turnHooks}. Each `id` must be
122
+ * namespaced to this plugin's own `id`; Core validates that at load and again
123
+ * on every emit.
124
+ */
125
+ hookEvents?: HookEventContribution[];
115
126
  /** Reverse-domain id (e.g. `"com.example.my-plugin"`). */
116
127
  id: string;
128
+ /**
129
+ * Language servers the plugin declares, keyed by server name — the same shape
130
+ * as Claude Code's `lspServers` / `.lsp.json`, passed verbatim. Loose records
131
+ * rather than a typed entry on purpose: Claude Code owns this field
132
+ * vocabulary, so typing it here would strip a field from a newer Claude
133
+ * release on its way through `ryu pack`. Core types it, because Core acts on
134
+ * it.
135
+ */
136
+ lspServers?: Record<string, Record<string, unknown>>;
117
137
  /** Display name. */
118
138
  name: string;
119
139
  /**
@@ -144,19 +164,28 @@ export interface DefinePluginOptions {
144
164
  }
145
165
 
146
166
  /**
147
- * Assemble a `plugin.json` manifest for a turn-hook plugin. The result matches
167
+ * Assemble a `manifest.json` manifest for a turn-hook plugin. The result matches
148
168
  * Core's `PluginManifest` serde shape and can be written to disk or validated via
149
169
  * `validateManifestStrict`.
150
170
  */
151
171
  export function definePlugin(options: DefinePluginOptions): PluginManifest {
152
172
  const contributes: Contributes = {
153
173
  turn_hooks: options.turnHooks ?? [],
174
+ hook_events: options.hookEvents ?? [],
154
175
  composer_controls: options.composerControls ?? [],
155
176
  settings_tabs: options.settingsTabs ?? [],
156
177
  slash_commands: options.slashCommands ?? [],
157
- // A turn-hook plugin contributes no app widgets; the field is required on the
158
- // resolved `Contributes` type (zod default applied), so set it explicitly.
178
+ lsp_servers: options.lspServers ?? {},
179
+ // A turn-hook plugin contributes no app widgets, sidebar entries, dock
180
+ // panels, danger-zone categories or Pi extensions; the fields are required
181
+ // on the resolved `Contributes` type (zod defaults applied), so set them
182
+ // explicitly.
159
183
  widgets: [],
184
+ sidebar_sections: [],
185
+ sidebar_buttons: [],
186
+ dock_panels: [],
187
+ data_categories: [],
188
+ pi_extensions: [],
160
189
  };
161
190
  // Ship each inline tool as a `kind:"tool"` runnable (Core's `inline_deno`
162
191
  // backend). Shipping tools requires the `tool:execute` grant; add it once.