@ryuhq/sdk 0.1.13 → 0.2.0

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 (90) hide show
  1. package/README.md +19 -6
  2. package/dist/agent-plugin.cjs +46 -0
  3. package/dist/agent-plugin.d.cts +42 -32
  4. package/dist/agent-plugin.d.ts +42 -32
  5. package/dist/agent-plugin.js +1 -1
  6. package/dist/agent.cjs +9 -0
  7. package/dist/agent.d.cts +377 -1
  8. package/dist/agent.d.ts +377 -1
  9. package/dist/agent.js +4 -2
  10. package/dist/app-Bkw7LlCK.d.ts +129 -0
  11. package/dist/app-DNaGmLVf.d.cts +129 -0
  12. package/dist/builder.cjs +1095 -0
  13. package/dist/builder.d.cts +212 -0
  14. package/dist/builder.d.ts +212 -0
  15. package/dist/builder.js +28 -0
  16. package/dist/chunk-A3RGEPDG.js +250 -0
  17. package/dist/chunk-FZSFZOIN.js +200 -0
  18. package/dist/{chunk-ODFEUVPW.js → chunk-HACAGK65.js} +1 -18
  19. package/dist/{chunk-AO2KJRDD.js → chunk-IEUQ3CDG.js} +125 -2
  20. package/dist/chunk-IKEDLLFY.js +19 -0
  21. package/dist/chunk-IOLP5FFE.js +354 -0
  22. package/dist/{chunk-G6FLVEC4.js → chunk-JX6DDRXV.js} +46 -0
  23. package/dist/chunk-T5676WL2.js +240 -0
  24. package/dist/chunk-TLDPEGC7.js +21 -0
  25. package/dist/chunk-TXSHHZF2.js +0 -0
  26. package/dist/{chunk-MTUBUPIV.js → chunk-ULSVL7EC.js} +8 -227
  27. package/dist/chunk-VLIRNNAE.js +154 -0
  28. package/dist/chunk-W3KPP4WN.js +135 -0
  29. package/dist/cli.cjs +258 -14
  30. package/dist/cli.js +96 -16
  31. package/dist/client-D5U6ssPc.d.cts +84 -0
  32. package/dist/client-D5U6ssPc.d.ts +84 -0
  33. package/dist/index.cjs +519 -5
  34. package/dist/index.d.cts +16 -632
  35. package/dist/index.d.ts +16 -632
  36. package/dist/index.js +63 -695
  37. package/dist/manifest.cjs +129 -3
  38. package/dist/manifest.d.cts +105 -2
  39. package/dist/manifest.d.ts +105 -2
  40. package/dist/manifest.js +7 -1
  41. package/dist/mcp/client.cjs +180 -0
  42. package/dist/mcp/client.d.cts +49 -0
  43. package/dist/mcp/client.d.ts +49 -0
  44. package/dist/mcp/client.js +10 -0
  45. package/dist/mcp/server.cjs +370 -0
  46. package/dist/mcp/server.d.cts +126 -0
  47. package/dist/mcp/server.d.ts +126 -0
  48. package/dist/mcp/server.js +9 -0
  49. package/dist/mcp.cjs +376 -0
  50. package/dist/mcp.d.cts +2 -0
  51. package/dist/mcp.d.ts +2 -0
  52. package/dist/mcp.js +17 -0
  53. package/dist/model.cjs +141 -0
  54. package/dist/model.d.cts +33 -0
  55. package/dist/model.d.ts +33 -0
  56. package/dist/model.js +18 -0
  57. package/dist/plugin.cjs +46 -0
  58. package/dist/plugin.d.cts +215 -0
  59. package/dist/plugin.d.ts +215 -0
  60. package/dist/plugin.js +8 -0
  61. package/dist/runnable.cjs +1230 -0
  62. package/dist/runnable.d.cts +271 -0
  63. package/dist/runnable.d.ts +271 -0
  64. package/dist/runnable.js +28 -0
  65. package/dist/{index-B6SkaAjJ.d.ts → tool-DSx2bFx8.d.ts} +35 -458
  66. package/dist/{index-BvAB5eMk.d.cts → tool-u-VR0fLF.d.cts} +35 -458
  67. package/package.json +38 -2
  68. package/src/agent/loop.test.ts +4 -4
  69. package/src/agent/tools.ts +3 -3
  70. package/src/agent-plugin.test.ts +58 -0
  71. package/src/agent-plugin.ts +108 -35
  72. package/src/cli/dev.test.ts +26 -47
  73. package/src/cli/dev.ts +10 -2
  74. package/src/cli.ts +98 -15
  75. package/src/exports-lockstep.test.ts +92 -0
  76. package/src/generated/plugin-manifest.ts +243 -27
  77. package/src/index.ts +35 -0
  78. package/src/manifest-schema.test.ts +30 -1
  79. package/src/manifest.fixtures.test.ts +13 -3
  80. package/src/manifest.test.ts +96 -10
  81. package/src/manifest.ts +334 -187
  82. package/src/mcp/index.ts +18 -0
  83. package/src/model/index.ts +22 -0
  84. package/src/plugin/ryu-plugin.ts +82 -0
  85. package/src/runnable/app.test.ts +2 -0
  86. package/src/runnable/app.ts +5 -2
  87. package/src/runnable/index.ts +2 -0
  88. package/src/runnable/primitives.ts +57 -0
  89. package/src/runnable/tool.ts +1 -1
  90. package/src/runnable/turn-hook.ts +4 -1
@@ -1,231 +1,15 @@
1
+ import {
2
+ createPrimitives,
3
+ httpPrimitiveTransport
4
+ } from "./chunk-A3RGEPDG.js";
5
+ import {
6
+ defineModel
7
+ } from "./chunk-HACAGK65.js";
1
8
  import {
2
9
  assertAllowedEgressUrl,
3
- defineModel,
4
10
  resolveGatewayToken,
5
11
  resolveGatewayUrl
6
- } from "./chunk-ODFEUVPW.js";
7
-
8
- // src/runnable/primitives.ts
9
- function dataUrlToBytes(dataUrl) {
10
- const match = /^data:([^;,]*)(;base64)?,([\s\S]*)$/.exec(dataUrl);
11
- if (!match) {
12
- throw new Error(
13
- "stt.transcribe expects an `audio` value that is a data: URL (data:<mime>;base64,<data>)"
14
- );
15
- }
16
- const mediaType = match[1] || "application/octet-stream";
17
- const isBase64 = Boolean(match[2]);
18
- const payload = match[3] ?? "";
19
- if (isBase64) {
20
- const binary = atob(payload);
21
- const bytes = new Uint8Array(binary.length);
22
- for (let i = 0; i < binary.length; i++) {
23
- bytes[i] = binary.charCodeAt(i);
24
- }
25
- return { bytes, mediaType };
26
- }
27
- return {
28
- bytes: new TextEncoder().encode(decodeURIComponent(payload)),
29
- mediaType
30
- };
31
- }
32
- function bytesToDataUrl(bytes, mediaType) {
33
- let binary = "";
34
- const chunk = 32768;
35
- for (let i = 0; i < bytes.length; i += chunk) {
36
- binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
37
- }
38
- return `data:${mediaType};base64,${btoa(binary)}`;
39
- }
40
- function httpPrimitiveTransport(options) {
41
- const base = options.nodeUrl.replace(/\/+$/, "");
42
- assertAllowedEgressUrl(base);
43
- const doFetch = options.fetchImpl ?? fetch;
44
- const authHeader = () => options.token ? { authorization: `Bearer ${options.token}` } : {};
45
- const failDetail = async (path, res) => {
46
- const detail = await res.text().catch(() => "");
47
- return new Error(
48
- `Ryu primitive call ${path} failed: ${res.status} ${res.statusText}${detail ? ` \u2014 ${detail}` : ""}`
49
- );
50
- };
51
- const post = async (path, body) => {
52
- const res = await doFetch(`${base}${path}`, {
53
- method: "POST",
54
- headers: { "content-type": "application/json", ...authHeader() },
55
- body: JSON.stringify(body ?? {})
56
- });
57
- if (!res.ok) {
58
- throw await failDetail(path, res);
59
- }
60
- const text = await res.text();
61
- return text ? JSON.parse(text) : void 0;
62
- };
63
- const transcribeDirect = async (body) => {
64
- const input = body ?? {};
65
- if (!input.audio) {
66
- throw new Error(
67
- "stt.transcribe requires an `audio` data: URL (the recorded audio)"
68
- );
69
- }
70
- const { bytes, mediaType } = dataUrlToBytes(input.audio);
71
- const form = new FormData();
72
- form.append(
73
- "file",
74
- new Blob([bytes], { type: mediaType || "audio/wav" }),
75
- input.filename ?? "recording.wav"
76
- );
77
- const res = await doFetch(`${base}/api/voice/transcribe`, {
78
- method: "POST",
79
- headers: authHeader(),
80
- body: form
81
- });
82
- if (!res.ok) {
83
- throw await failDetail("/api/voice/transcribe", res);
84
- }
85
- const parsed = await res.json();
86
- return (parsed.text ?? "").trim();
87
- };
88
- const speakDirect = async (body) => {
89
- const res = await doFetch(`${base}/api/voice/speak`, {
90
- method: "POST",
91
- headers: { "content-type": "application/json", ...authHeader() },
92
- body: JSON.stringify(body ?? {})
93
- });
94
- if (!res.ok) {
95
- throw await failDetail("/api/voice/speak", res);
96
- }
97
- const bytes = new Uint8Array(await res.arrayBuffer());
98
- const mediaType = res.headers.get("content-type") || "audio/wav";
99
- return bytesToDataUrl(bytes, mediaType);
100
- };
101
- const generateImageDirect = async (body) => {
102
- const res = await doFetch(`${base}/api/images/generate`, {
103
- method: "POST",
104
- headers: { "content-type": "application/json", ...authHeader() },
105
- body: JSON.stringify(body ?? {})
106
- });
107
- if (!res.ok) {
108
- throw await failDetail("/api/images/generate", res);
109
- }
110
- const parsed = await res.json();
111
- return (parsed.data ?? []).map(
112
- (item) => item.url ? item.url : `data:image/png;base64,${item.b64_json ?? ""}`
113
- );
114
- };
115
- return {
116
- bridge(method, args) {
117
- if (!options.pluginId) {
118
- return Promise.reject(
119
- new Error(
120
- `bridge primitive "${method}" requires a pluginId (the /api/plugins/:id/host caller identity)`
121
- )
122
- );
123
- }
124
- return post(`/api/plugins/${options.pluginId}/host`, { method, args });
125
- },
126
- direct(path, body) {
127
- if (path === "/api/voice/transcribe") {
128
- return transcribeDirect(body);
129
- }
130
- if (path === "/api/voice/speak") {
131
- return speakDirect(body);
132
- }
133
- if (path === "/api/images/generate") {
134
- return generateImageDirect(body);
135
- }
136
- return post(path, body);
137
- },
138
- capability(cap, body) {
139
- return post(`/api/host/capability/${cap}`, body);
140
- }
141
- };
142
- }
143
- var PRIMITIVE_BINDINGS = {
144
- // Bridge families (existing `PluginHookBridge`).
145
- "engines.complete": {
146
- transport: "bridge",
147
- method: "model.complete",
148
- grant: "hook:side-model"
149
- },
150
- // Host-direct media data-path (the host holds the node token; returns data: URLs).
151
- "image.generate": {
152
- transport: "direct",
153
- path: "/api/images/generate",
154
- grant: "media:generate"
155
- },
156
- "tts.speak": {
157
- transport: "direct",
158
- path: "/api/voice/speak",
159
- grant: "media:generate"
160
- },
161
- "stt.transcribe": {
162
- transport: "direct",
163
- path: "/api/voice/transcribe",
164
- grant: "media:transcribe"
165
- },
166
- // Broker capabilities — no rpc family yet (@requires-grant).
167
- "rag.retrieve": { transport: "broker", capability: "rag" },
168
- "rag.embed": { transport: "broker", capability: "rag" },
169
- "rag.rerank": { transport: "broker", capability: "rag" },
170
- "memory.recall": { transport: "broker", capability: "memory" },
171
- "memory.store": { transport: "broker", capability: "memory" },
172
- "realtime.broadcast": { transport: "broker", capability: "realtime" },
173
- "realtime.subscribe": { transport: "broker", capability: "realtime" },
174
- "durable.checkpoint": { transport: "broker", capability: "durable" },
175
- "durable.resume": { transport: "broker", capability: "durable" },
176
- "engines.embed": { transport: "broker", capability: "engines" }
177
- };
178
- function brokerCall(transport, cap, op, input) {
179
- return transport.capability(cap, { op, input });
180
- }
181
- function createPrimitives(transport) {
182
- return {
183
- rag: {
184
- retrieve: (input) => brokerCall(transport, "rag", "retrieve", input),
185
- embed: (input) => brokerCall(transport, "rag", "embed", input),
186
- rerank: (input) => brokerCall(transport, "rag", "rerank", input)
187
- },
188
- memory: {
189
- recall: (input) => brokerCall(transport, "memory", "recall", input),
190
- store: (input) => brokerCall(transport, "memory", "store", input)
191
- },
192
- realtime: {
193
- broadcast: (input) => brokerCall(transport, "realtime", "broadcast", input).then(
194
- () => void 0
195
- ),
196
- subscribe: (input) => brokerCall(
197
- transport,
198
- "realtime",
199
- "subscribe",
200
- input
201
- )
202
- },
203
- durable: {
204
- checkpoint: (input) => brokerCall(transport, "durable", "checkpoint", input),
205
- resume: (input) => brokerCall(transport, "durable", "resume", input)
206
- },
207
- engines: {
208
- // Bridge family: model.complete → host.sideModel (wire keys are snake_case).
209
- complete: (input) => transport.bridge("model.complete", {
210
- prompt: input.prompt,
211
- system: input.system,
212
- model: input.model,
213
- model_pref_key: input.modelPrefKey,
214
- effort: input.effort
215
- }),
216
- embed: (input) => brokerCall(transport, "engines", "embed", input)
217
- },
218
- tts: {
219
- speak: (input) => transport.direct("/api/voice/speak", input)
220
- },
221
- stt: {
222
- transcribe: (input) => transport.direct("/api/voice/transcribe", input)
223
- },
224
- image: {
225
- generate: (input) => transport.direct("/api/images/generate", input)
226
- }
227
- };
228
- }
12
+ } from "./chunk-IKEDLLFY.js";
229
13
 
230
14
  // src/agent/model-call.ts
231
15
  var CHAT_COMPLETIONS_PATH = "/v1/chat/completions";
@@ -659,9 +443,6 @@ function query(input) {
659
443
  }
660
444
 
661
445
  export {
662
- httpPrimitiveTransport,
663
- PRIMITIVE_BINDINGS,
664
- createPrimitives,
665
446
  callModelWithTools,
666
447
  ryuTool,
667
448
  resolveToolDefs,
@@ -0,0 +1,154 @@
1
+ // src/mcp/client.ts
2
+ import { spawn } from "child_process";
3
+ import { createInterface } from "readline";
4
+ var MCP_PROTOCOL_VERSION = "2024-11-05";
5
+ var RPC_TIMEOUT_MS = 6e4;
6
+ var McpConnection = class _McpConnection {
7
+ proc;
8
+ nextId = 1;
9
+ closed = false;
10
+ waiters = [];
11
+ constructor(proc) {
12
+ this.proc = proc;
13
+ }
14
+ /** Spawn the server and complete the MCP `initialize` handshake. */
15
+ static async connect(cmd) {
16
+ const env = { ...process.env, ...cmd.env ?? {} };
17
+ const proc = spawn(cmd.command, cmd.args ?? [], {
18
+ stdio: ["pipe", "pipe", "pipe"],
19
+ env
20
+ });
21
+ if (!(proc.stdin && proc.stdout)) {
22
+ proc.kill();
23
+ throw new Error(`MCP server '${cmd.command}' stdin/stdout unavailable`);
24
+ }
25
+ if (proc.stderr) {
26
+ proc.stderr.on("data", (chunk) => {
27
+ process.stderr.write(`[mcp-server] ${chunk.toString()}`);
28
+ });
29
+ }
30
+ const conn = new _McpConnection(proc);
31
+ const rl = createInterface({
32
+ input: proc.stdout,
33
+ crlfDelay: Number.POSITIVE_INFINITY
34
+ });
35
+ rl.on("line", (rawLine) => {
36
+ const trimmed = rawLine.trim();
37
+ if (!trimmed) {
38
+ return;
39
+ }
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(trimmed);
43
+ } catch {
44
+ return;
45
+ }
46
+ if (parsed.id === void 0 || parsed.id === null) {
47
+ return;
48
+ }
49
+ const idx = conn.waiters.findIndex((w) => w.id === parsed.id);
50
+ if (idx === -1) {
51
+ return;
52
+ }
53
+ const [waiter] = conn.waiters.splice(idx, 1);
54
+ if (!waiter) {
55
+ return;
56
+ }
57
+ clearTimeout(waiter.timer);
58
+ if (parsed.error) {
59
+ waiter.reject(new Error(`MCP error: ${JSON.stringify(parsed.error)}`));
60
+ } else {
61
+ waiter.resolve(parsed.result ?? null);
62
+ }
63
+ });
64
+ rl.on("close", () => {
65
+ for (const waiter of conn.waiters.splice(0)) {
66
+ clearTimeout(waiter.timer);
67
+ waiter.reject(new Error("MCP server closed the connection"));
68
+ }
69
+ });
70
+ await conn.request("initialize", {
71
+ protocolVersion: MCP_PROTOCOL_VERSION,
72
+ capabilities: {},
73
+ clientInfo: { name: "ryu-sdk", version: "0.0.1" }
74
+ });
75
+ conn.notify("notifications/initialized", {});
76
+ return conn;
77
+ }
78
+ /** Send a JSON-RPC request and return the `result` field. */
79
+ request(method, params) {
80
+ const id = this.nextId++;
81
+ const frame = JSON.stringify({
82
+ jsonrpc: "2.0",
83
+ id,
84
+ method,
85
+ params
86
+ });
87
+ this.write(frame);
88
+ return new Promise((resolve, reject) => {
89
+ const timer = setTimeout(
90
+ () => reject(new Error(`MCP request '${method}' timed out`)),
91
+ RPC_TIMEOUT_MS
92
+ );
93
+ this.waiters.push({ id, resolve, reject, timer });
94
+ });
95
+ }
96
+ /** Send a JSON-RPC notification (no response expected). */
97
+ notify(method, params) {
98
+ const frame = JSON.stringify({ jsonrpc: "2.0", method, params });
99
+ this.write(frame);
100
+ }
101
+ write(frame) {
102
+ if (this.closed) {
103
+ return;
104
+ }
105
+ this.proc.stdin?.write(`${frame}
106
+ `);
107
+ }
108
+ /** Graceful shutdown: close stdin, then kill. */
109
+ async shutdown() {
110
+ this.closed = true;
111
+ this.proc.stdin?.end();
112
+ await new Promise((resolve) => {
113
+ this.proc.once("exit", () => resolve());
114
+ this.proc.kill();
115
+ setTimeout(resolve, 500);
116
+ });
117
+ }
118
+ };
119
+ async function listTools(cmd) {
120
+ const conn = await McpConnection.connect(cmd);
121
+ let result;
122
+ try {
123
+ result = await conn.request("tools/list", {});
124
+ } finally {
125
+ await conn.shutdown();
126
+ }
127
+ const tools = result?.tools ?? [];
128
+ return tools.map((t) => {
129
+ const tool = t;
130
+ const name = tool.name;
131
+ if (typeof name !== "string") {
132
+ return null;
133
+ }
134
+ return {
135
+ name,
136
+ description: typeof tool.description === "string" ? tool.description : void 0,
137
+ inputSchema: tool.inputSchema
138
+ };
139
+ }).filter((t) => t !== null);
140
+ }
141
+ async function callTool(cmd, tool, args) {
142
+ const conn = await McpConnection.connect(cmd);
143
+ try {
144
+ return await conn.request("tools/call", { name: tool, arguments: args });
145
+ } finally {
146
+ await conn.shutdown();
147
+ }
148
+ }
149
+
150
+ export {
151
+ MCP_PROTOCOL_VERSION,
152
+ listTools,
153
+ callTool
154
+ };
@@ -0,0 +1,135 @@
1
+ import {
2
+ PluginManifestSchema
3
+ } from "./chunk-IEUQ3CDG.js";
4
+
5
+ // src/runnable/app.ts
6
+ var DEFAULT_APP_WIDGET_MIME = "text/html+skybridge";
7
+ var DEFAULT_APP_DISPLAY_MODE = "inline";
8
+ var WIDGET_RENDER_GRANT = "widget:render";
9
+ function withWidgetRenderGrant(grants, widgets) {
10
+ const out = [...grants];
11
+ if (widgets.length > 0 && !out.includes(WIDGET_RENDER_GRANT)) {
12
+ out.push(WIDGET_RENDER_GRANT);
13
+ }
14
+ return out;
15
+ }
16
+ function appToolId(server, name) {
17
+ return `${server}.${name}`;
18
+ }
19
+ function defineApp(options) {
20
+ const server = options.server ?? options.slug;
21
+ const uri = `ui://widget/${options.slug}.html`;
22
+ const mime = options.mime ?? DEFAULT_APP_WIDGET_MIME;
23
+ const displayMode = options.displayMode ?? DEFAULT_APP_DISPLAY_MODE;
24
+ const hasCompanions = options.tools.some((t) => t.accessible === true);
25
+ const runnables = [];
26
+ const widgets = [];
27
+ for (const spec of options.tools) {
28
+ const isRender = spec.accessible !== true;
29
+ const id = appToolId(server, spec.name);
30
+ const config = {
31
+ slug: id,
32
+ description: spec.description,
33
+ widget: isRender,
34
+ widget_accessible: isRender ? hasCompanions : true,
35
+ ...spec.inputSchema ? { input_schema: spec.inputSchema } : {},
36
+ ...spec.invoking ? { invoking: spec.invoking } : {},
37
+ ...spec.invoked ? { invoked: spec.invoked } : {}
38
+ };
39
+ runnables.push({
40
+ id,
41
+ name: spec.name,
42
+ kind: "tool",
43
+ config
44
+ });
45
+ if (isRender) {
46
+ widgets.push({
47
+ tool_id: id,
48
+ uri,
49
+ ui_entry: options.uiEntry,
50
+ mime,
51
+ default_display_mode: displayMode
52
+ });
53
+ }
54
+ }
55
+ const contributes = {
56
+ turn_hooks: [],
57
+ chat_features: [],
58
+ // This builder synthesises an app from its runnables; an app that emits
59
+ // events declares them in a hand-authored `manifest.json`, same as
60
+ // `lsp_servers` below.
61
+ hook_events: [],
62
+ composer_controls: [],
63
+ settings_tabs: [],
64
+ slash_commands: [],
65
+ sidebar_sections: [],
66
+ sidebar_buttons: [],
67
+ dock_panels: [],
68
+ live_activities: [],
69
+ // Empty for the same reason as every sibling family above: this builder
70
+ // synthesises `widgets` from the app's own runnables and nothing else, and
71
+ // takes no `contributes` passthrough. An app that wants to declare language
72
+ // servers writes them in a hand-authored `manifest.json`.
73
+ lsp_servers: {},
74
+ // Same reason again: a danger-zone category, a Pi extension and an output
75
+ // style are all hand-authored declarations, not something derivable from
76
+ // runnables. An output style in particular points at a Markdown file next
77
+ // to the manifest, which this builder never writes.
78
+ data_categories: [],
79
+ pi_extensions: [],
80
+ output_styles: [],
81
+ message_actions: [],
82
+ widgets
83
+ };
84
+ const raw = {
85
+ id: options.id,
86
+ name: options.title,
87
+ version: options.version,
88
+ runnables,
89
+ // An app that synthesises widgets MUST hold `widget:render`, so this
90
+ // builder declares it rather than leaving the author to discover it.
91
+ //
92
+ // Core gates widget promotion on declared-AND-enabled-AND-granted, and a
93
+ // missing grant fails as `DeniedNoGrant` — which is an `info!` log and
94
+ // nothing else. The widget silently renders as plain text, with no error
95
+ // in the UI and nothing pointing at the manifest. Every app scaffolded
96
+ // through `defineApp` hit that, because the only fix was a grant string
97
+ // the templates never mention and the builder never added; the one
98
+ // working example on disk hand-writes it.
99
+ //
100
+ // Added only when there is a widget to render, and unioned rather than
101
+ // overwritten so an author's own `grants` list survives and re-declaring
102
+ // it is not an error.
103
+ permission_grants: withWidgetRenderGrant(options.grants ?? [], widgets),
104
+ activation_events: options.activationEvents ?? ["*"],
105
+ contributes,
106
+ // `targets: []` means EVERY surface, so an app that declares none is
107
+ // unrestricted — the backward-compatible default.
108
+ targets: options.targets ?? [],
109
+ // `requires` stays ABSENT (not `{apps:[],grants:[]}`) when undeclared, so the
110
+ // emitted manifest carries no key at all — matching Core's
111
+ // `Option<Requires>` + `skip_serializing_if = "Option::is_none"`.
112
+ ...options.requires ? {
113
+ requires: {
114
+ apps: options.requires.apps ?? [],
115
+ capabilities: options.requires.capabilities ?? [],
116
+ grants: options.requires.grants ?? []
117
+ }
118
+ } : {}
119
+ };
120
+ const result = PluginManifestSchema.safeParse(raw);
121
+ if (!result.success) {
122
+ const first = result.error.issues[0];
123
+ const field = first?.path.join(".") ?? "unknown";
124
+ const message = first?.message ?? "validation failed";
125
+ throw new Error(
126
+ `manifest.json validation failed at '${field}': ${message}`
127
+ );
128
+ }
129
+ return result.data;
130
+ }
131
+
132
+ export {
133
+ appToolId,
134
+ defineApp
135
+ };