@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.
- package/README.md +19 -6
- package/dist/agent-plugin.cjs +46 -0
- package/dist/agent-plugin.d.cts +42 -32
- package/dist/agent-plugin.d.ts +42 -32
- package/dist/agent-plugin.js +1 -1
- package/dist/agent.cjs +9 -0
- package/dist/agent.d.cts +377 -1
- package/dist/agent.d.ts +377 -1
- package/dist/agent.js +4 -2
- package/dist/app-Bkw7LlCK.d.ts +129 -0
- package/dist/app-DNaGmLVf.d.cts +129 -0
- package/dist/builder.cjs +1095 -0
- package/dist/builder.d.cts +212 -0
- package/dist/builder.d.ts +212 -0
- package/dist/builder.js +28 -0
- package/dist/chunk-A3RGEPDG.js +250 -0
- package/dist/chunk-FZSFZOIN.js +200 -0
- package/dist/{chunk-ODFEUVPW.js → chunk-HACAGK65.js} +1 -18
- package/dist/{chunk-AO2KJRDD.js → chunk-IEUQ3CDG.js} +125 -2
- package/dist/chunk-IKEDLLFY.js +19 -0
- package/dist/chunk-IOLP5FFE.js +354 -0
- package/dist/{chunk-G6FLVEC4.js → chunk-JX6DDRXV.js} +46 -0
- package/dist/chunk-T5676WL2.js +240 -0
- package/dist/chunk-TLDPEGC7.js +21 -0
- package/dist/chunk-TXSHHZF2.js +0 -0
- package/dist/{chunk-MTUBUPIV.js → chunk-ULSVL7EC.js} +8 -227
- package/dist/chunk-VLIRNNAE.js +154 -0
- package/dist/chunk-W3KPP4WN.js +135 -0
- package/dist/cli.cjs +258 -14
- package/dist/cli.js +96 -16
- package/dist/client-D5U6ssPc.d.cts +84 -0
- package/dist/client-D5U6ssPc.d.ts +84 -0
- package/dist/index.cjs +519 -5
- package/dist/index.d.cts +16 -632
- package/dist/index.d.ts +16 -632
- package/dist/index.js +63 -695
- package/dist/manifest.cjs +129 -3
- package/dist/manifest.d.cts +105 -2
- package/dist/manifest.d.ts +105 -2
- package/dist/manifest.js +7 -1
- package/dist/mcp/client.cjs +180 -0
- package/dist/mcp/client.d.cts +49 -0
- package/dist/mcp/client.d.ts +49 -0
- package/dist/mcp/client.js +10 -0
- package/dist/mcp/server.cjs +370 -0
- package/dist/mcp/server.d.cts +126 -0
- package/dist/mcp/server.d.ts +126 -0
- package/dist/mcp/server.js +9 -0
- package/dist/mcp.cjs +376 -0
- package/dist/mcp.d.cts +2 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +17 -0
- package/dist/model.cjs +141 -0
- package/dist/model.d.cts +33 -0
- package/dist/model.d.ts +33 -0
- package/dist/model.js +18 -0
- package/dist/plugin.cjs +46 -0
- package/dist/plugin.d.cts +215 -0
- package/dist/plugin.d.ts +215 -0
- package/dist/plugin.js +8 -0
- package/dist/runnable.cjs +1230 -0
- package/dist/runnable.d.cts +271 -0
- package/dist/runnable.d.ts +271 -0
- package/dist/runnable.js +28 -0
- package/dist/{index-B6SkaAjJ.d.ts → tool-DSx2bFx8.d.ts} +35 -458
- package/dist/{index-BvAB5eMk.d.cts → tool-u-VR0fLF.d.cts} +35 -458
- package/package.json +38 -2
- package/src/agent/loop.test.ts +4 -4
- package/src/agent/tools.ts +3 -3
- package/src/agent-plugin.test.ts +58 -0
- package/src/agent-plugin.ts +108 -35
- package/src/cli/dev.test.ts +26 -47
- package/src/cli/dev.ts +10 -2
- package/src/cli.ts +98 -15
- package/src/exports-lockstep.test.ts +92 -0
- package/src/generated/plugin-manifest.ts +243 -27
- package/src/index.ts +35 -0
- package/src/manifest-schema.test.ts +30 -1
- package/src/manifest.fixtures.test.ts +13 -3
- package/src/manifest.test.ts +96 -10
- package/src/manifest.ts +334 -187
- package/src/mcp/index.ts +18 -0
- package/src/model/index.ts +22 -0
- package/src/plugin/ryu-plugin.ts +82 -0
- package/src/runnable/app.test.ts +2 -0
- package/src/runnable/app.ts +5 -2
- package/src/runnable/index.ts +2 -0
- package/src/runnable/primitives.ts +57 -0
- package/src/runnable/tool.ts +1 -1
- package/src/runnable/turn-hook.ts +4 -1
package/src/agent-plugin.test.ts
CHANGED
|
@@ -183,6 +183,64 @@ describe("toAgentPlugin", () => {
|
|
|
183
183
|
});
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
+
test("exports Streamable HTTP, legacy SSE, and inferred remote servers", () => {
|
|
187
|
+
const { mcp, notes } = toAgentPlugin({
|
|
188
|
+
id: "@ryu/remote",
|
|
189
|
+
name: "Remote",
|
|
190
|
+
mcp_servers: {
|
|
191
|
+
hosted: {
|
|
192
|
+
type: "streamable-http",
|
|
193
|
+
url: "https://mcp.example.com/mcp",
|
|
194
|
+
headers: { Authorization: "Bearer static" },
|
|
195
|
+
},
|
|
196
|
+
legacy: {
|
|
197
|
+
type: "sse",
|
|
198
|
+
url: "https://legacy.example.com/sse",
|
|
199
|
+
},
|
|
200
|
+
inferred: { url: "https://inferred.example.com/mcp" },
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
expect(mcp?.mcpServers.hosted).toEqual({
|
|
204
|
+
type: "streamable-http",
|
|
205
|
+
url: "https://mcp.example.com/mcp",
|
|
206
|
+
headers: { Authorization: "Bearer static" },
|
|
207
|
+
});
|
|
208
|
+
expect(mcp?.mcpServers.legacy).toEqual({
|
|
209
|
+
type: "sse",
|
|
210
|
+
url: "https://legacy.example.com/sse",
|
|
211
|
+
});
|
|
212
|
+
expect(mcp?.mcpServers.inferred).toEqual({
|
|
213
|
+
type: "streamable-http",
|
|
214
|
+
url: "https://inferred.example.com/mcp",
|
|
215
|
+
});
|
|
216
|
+
expect(notes).toEqual([]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("omits malformed remote URLs while preserving valid HTTP endpoints", () => {
|
|
220
|
+
const { mcp, notes } = toAgentPlugin({
|
|
221
|
+
id: "@ryu/remote",
|
|
222
|
+
name: "Remote",
|
|
223
|
+
mcp_servers: {
|
|
224
|
+
insecure: {
|
|
225
|
+
type: "streamable-http",
|
|
226
|
+
url: "http://remote.example.com/mcp",
|
|
227
|
+
},
|
|
228
|
+
bad: { type: "streamable-http", url: "file:///tmp/mcp" },
|
|
229
|
+
local: { type: "sse", url: "http://127.0.0.1:8787/sse" },
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
expect(mcp?.mcpServers.insecure).toEqual({
|
|
233
|
+
type: "streamable-http",
|
|
234
|
+
url: "http://remote.example.com/mcp",
|
|
235
|
+
});
|
|
236
|
+
expect(mcp?.mcpServers.local).toEqual({
|
|
237
|
+
type: "sse",
|
|
238
|
+
url: "http://127.0.0.1:8787/sse",
|
|
239
|
+
});
|
|
240
|
+
expect(notes).toHaveLength(1);
|
|
241
|
+
expect(notes[0]).toContain("http or https");
|
|
242
|
+
});
|
|
243
|
+
|
|
186
244
|
test("omits a disabled server but records it", () => {
|
|
187
245
|
const { plugin, mcp, notes } = toAgentPlugin({
|
|
188
246
|
id: "@ryu/a",
|
package/src/agent-plugin.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* The Agent Plugins Specification (https://agent-plugins.org/, TSC: Amazon,
|
|
5
5
|
* Cursor, Microsoft, OpenAI, Vercel) defines a small portable floor: a plugin is
|
|
6
6
|
* a directory with `plugin.json`, Agent Skills under `skills/<slug>/SKILL.md`,
|
|
7
|
-
* and MCP servers in `mcp.json`.
|
|
7
|
+
* and MCP servers in `mcp.json`. Native stdio, Streamable HTTP, and legacy SSE
|
|
8
|
+
* servers all have a direct portable representation; richer Ryu-only fields stay
|
|
9
|
+
* in the extension namespace or are reported as notes.
|
|
8
10
|
*
|
|
9
11
|
* ## Why this is a SECOND file, not a migration
|
|
10
12
|
*
|
|
@@ -88,24 +90,24 @@ export function isAgentPluginManifest(value: unknown): boolean {
|
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
/** Author object — the only three fields the spec permits (§5.4). */
|
|
91
|
-
export
|
|
92
|
-
name?: string;
|
|
93
|
+
export interface AgentPluginAuthor {
|
|
93
94
|
email?: string;
|
|
95
|
+
name?: string;
|
|
94
96
|
url?: string;
|
|
95
|
-
}
|
|
97
|
+
}
|
|
96
98
|
|
|
97
99
|
/** Ryu data carried under {@link AGENT_PLUGIN_EXTENSION_NS}. */
|
|
98
|
-
export
|
|
99
|
-
/** The real scoped plugin id (`@ryu/advisor`) — unrecoverable from spec `name`. */
|
|
100
|
-
id: string;
|
|
100
|
+
export interface RyuExtensionData {
|
|
101
101
|
/** Human display name; spec `name` is a slug, not a display string. */
|
|
102
102
|
displayName: string;
|
|
103
|
+
/** The real scoped plugin id (`@ryu/advisor`) — unrecoverable from spec `name`. */
|
|
104
|
+
id: string;
|
|
103
105
|
/** Per-MCP-server fields the spec's closed server variants do not allow. */
|
|
104
106
|
mcp?: Record<string, RyuMcpServerExtras>;
|
|
105
|
-
}
|
|
107
|
+
}
|
|
106
108
|
|
|
107
109
|
/** Native MCP fields stripped out of the exported `mcp.json`. */
|
|
108
|
-
export
|
|
110
|
+
export interface RyuMcpServerExtras {
|
|
109
111
|
/** Env var that overrides `command` with an absolute path at spawn. */
|
|
110
112
|
command_env?: string;
|
|
111
113
|
/** Human description for our MCP listing endpoint. */
|
|
@@ -117,41 +119,51 @@ export type RyuMcpServerExtras = {
|
|
|
117
119
|
* deliberately do not.
|
|
118
120
|
*/
|
|
119
121
|
enabled?: false;
|
|
120
|
-
}
|
|
122
|
+
}
|
|
121
123
|
|
|
122
124
|
/** A conformant `plugin.json` (§5.2). */
|
|
123
|
-
export
|
|
125
|
+
export interface AgentPluginJson {
|
|
124
126
|
$schema: string;
|
|
125
|
-
name: string;
|
|
126
|
-
version?: string;
|
|
127
|
-
description?: string;
|
|
128
127
|
author?: AgentPluginAuthor;
|
|
128
|
+
description?: string;
|
|
129
|
+
extensions: Record<string, unknown>;
|
|
129
130
|
homepage?: string;
|
|
130
|
-
repository?: string;
|
|
131
|
-
license?: string;
|
|
132
131
|
keywords?: string[];
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
license?: string;
|
|
133
|
+
name: string;
|
|
134
|
+
repository?: string;
|
|
135
|
+
version?: string;
|
|
136
|
+
}
|
|
135
137
|
|
|
136
|
-
/** A stdio server entry (§7.2.1)
|
|
137
|
-
export
|
|
138
|
-
type: "stdio";
|
|
139
|
-
command: string;
|
|
138
|
+
/** A stdio server entry (§7.2.1). */
|
|
139
|
+
export interface AgentPluginStdioServer {
|
|
140
140
|
args?: string[];
|
|
141
|
-
|
|
141
|
+
command: string;
|
|
142
142
|
cwd?: string;
|
|
143
|
-
|
|
143
|
+
env?: Record<string, string>;
|
|
144
|
+
type: "stdio";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A remote server entry (§7.2.1). */
|
|
148
|
+
export interface AgentPluginRemoteServer {
|
|
149
|
+
headers?: Record<string, string>;
|
|
150
|
+
type: "streamable-http" | "sse";
|
|
151
|
+
url: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Any server variant a conformant Agent Plugins client can consume. */
|
|
155
|
+
export type AgentPluginServer =
|
|
156
|
+
| AgentPluginStdioServer
|
|
157
|
+
| AgentPluginRemoteServer;
|
|
144
158
|
|
|
145
159
|
/** A conformant `mcp.json` (§7.2.1). */
|
|
146
|
-
export
|
|
160
|
+
export interface AgentPluginMcpJson {
|
|
147
161
|
$schema: string;
|
|
148
|
-
mcpServers: Record<string,
|
|
149
|
-
}
|
|
162
|
+
mcpServers: Record<string, AgentPluginServer>;
|
|
163
|
+
}
|
|
150
164
|
|
|
151
165
|
/** What {@link toAgentPlugin} produces, plus what it had to leave behind. */
|
|
152
|
-
export
|
|
153
|
-
/** The `plugin.json` contents. */
|
|
154
|
-
plugin: AgentPluginJson;
|
|
166
|
+
export interface AgentPluginExport {
|
|
155
167
|
/** The `mcp.json` contents, or null when the plugin exports no server. */
|
|
156
168
|
mcp: AgentPluginMcpJson | null;
|
|
157
169
|
/**
|
|
@@ -159,7 +171,9 @@ export type AgentPluginExport = {
|
|
|
159
171
|
* is visible at the call site instead of silent.
|
|
160
172
|
*/
|
|
161
173
|
notes: string[];
|
|
162
|
-
|
|
174
|
+
/** The `plugin.json` contents. */
|
|
175
|
+
plugin: AgentPluginJson;
|
|
176
|
+
}
|
|
163
177
|
|
|
164
178
|
const SPEC_NAME_MAX = 64;
|
|
165
179
|
const ILLEGAL_NAME_CHARS = /[^a-z0-9.-]+/g;
|
|
@@ -258,8 +272,24 @@ function toSpecEnv(value: unknown): Record<string, string> | undefined {
|
|
|
258
272
|
return Object.keys(env).length > 0 ? env : undefined;
|
|
259
273
|
}
|
|
260
274
|
|
|
275
|
+
function validateRemoteUrl(name: string, raw: string): string | undefined {
|
|
276
|
+
let parsed: URL;
|
|
277
|
+
try {
|
|
278
|
+
parsed = new URL(raw);
|
|
279
|
+
} catch {
|
|
280
|
+
return `mcp server '${name}' remote url ${JSON.stringify(raw)} is invalid and was omitted`;
|
|
281
|
+
}
|
|
282
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
283
|
+
return `mcp server '${name}' remote url must use http or https and was omitted`;
|
|
284
|
+
}
|
|
285
|
+
if (parsed.username || parsed.password || parsed.hash) {
|
|
286
|
+
return `mcp server '${name}' remote url must not contain credentials or a fragment and was omitted`;
|
|
287
|
+
}
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
261
291
|
/**
|
|
262
|
-
* Convert one native MCP server declaration to a spec
|
|
292
|
+
* Convert one native MCP server declaration to a spec entry, or return the
|
|
263
293
|
* reason it cannot be exported.
|
|
264
294
|
*
|
|
265
295
|
* Strictness is not optional here and differs from the manifest: an unknown field
|
|
@@ -270,7 +300,7 @@ function toSpecEnv(value: unknown): Record<string, string> | undefined {
|
|
|
270
300
|
function toSpecServer(
|
|
271
301
|
name: string,
|
|
272
302
|
decl: Record<string, unknown>
|
|
273
|
-
): { server?:
|
|
303
|
+
): { server?: AgentPluginServer; extras: RyuMcpServerExtras; note?: string } {
|
|
274
304
|
const extras: RyuMcpServerExtras = {};
|
|
275
305
|
const commandEnv = asString(decl.command_env);
|
|
276
306
|
const description = asString(decl.description);
|
|
@@ -289,6 +319,48 @@ function toSpecServer(
|
|
|
289
319
|
};
|
|
290
320
|
}
|
|
291
321
|
|
|
322
|
+
const declaredType = asString(decl.type);
|
|
323
|
+
const remoteUrl = asString(decl.url);
|
|
324
|
+
const remoteType =
|
|
325
|
+
declaredType === "sse"
|
|
326
|
+
? "sse"
|
|
327
|
+
: declaredType === "streamable-http" ||
|
|
328
|
+
declaredType === "streamable_http" ||
|
|
329
|
+
declaredType === "http" ||
|
|
330
|
+
(!declaredType && remoteUrl)
|
|
331
|
+
? "streamable-http"
|
|
332
|
+
: undefined;
|
|
333
|
+
if (remoteType) {
|
|
334
|
+
if (!remoteUrl) {
|
|
335
|
+
return {
|
|
336
|
+
extras,
|
|
337
|
+
note: `mcp server '${name}' has remote type '${remoteType}' but no url and was omitted`,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const urlNote = validateRemoteUrl(name, remoteUrl);
|
|
341
|
+
if (urlNote) {
|
|
342
|
+
return { extras, note: urlNote };
|
|
343
|
+
}
|
|
344
|
+
const server: AgentPluginRemoteServer = {
|
|
345
|
+
type: remoteType,
|
|
346
|
+
url: remoteUrl,
|
|
347
|
+
};
|
|
348
|
+
const headers = toSpecEnv(decl.headers);
|
|
349
|
+
if (headers) {
|
|
350
|
+
server.headers = headers;
|
|
351
|
+
}
|
|
352
|
+
const note = decl.auth
|
|
353
|
+
? `mcp server '${name}' uses Ryu OAuth metadata that is not portable; static headers were exported`
|
|
354
|
+
: undefined;
|
|
355
|
+
return { server, extras, note };
|
|
356
|
+
}
|
|
357
|
+
if (declaredType && declaredType !== "stdio") {
|
|
358
|
+
return {
|
|
359
|
+
extras,
|
|
360
|
+
note: `mcp server '${name}' has unsupported transport '${declaredType}' and was omitted`,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
292
364
|
const command = asString(decl.command);
|
|
293
365
|
if (!command) {
|
|
294
366
|
return {
|
|
@@ -359,7 +431,8 @@ export function toAgentPlugin(
|
|
|
359
431
|
}
|
|
360
432
|
// `tagline` is our one-line pitch; it is the better `description` when no long
|
|
361
433
|
// description exists, and the spec has no second summary field to put it in.
|
|
362
|
-
const description =
|
|
434
|
+
const description =
|
|
435
|
+
asString(manifest.description) ?? asString(manifest.tagline);
|
|
363
436
|
if (description) {
|
|
364
437
|
plugin.description = description;
|
|
365
438
|
}
|
|
@@ -385,7 +458,7 @@ export function toAgentPlugin(
|
|
|
385
458
|
}
|
|
386
459
|
|
|
387
460
|
const declared = manifest.mcp_servers;
|
|
388
|
-
const servers: Record<string,
|
|
461
|
+
const servers: Record<string, AgentPluginServer> = {};
|
|
389
462
|
const mcpExtras: Record<string, RyuMcpServerExtras> = {};
|
|
390
463
|
if (declared && typeof declared === "object" && !Array.isArray(declared)) {
|
|
391
464
|
for (const [name, raw] of Object.entries(
|
package/src/cli/dev.test.ts
CHANGED
|
@@ -3,57 +3,27 @@
|
|
|
3
3
|
* sample Runnable, asserting that text, tool-call, and tool-result events
|
|
4
4
|
* stream to stdout correctly.
|
|
5
5
|
*
|
|
6
|
-
* The mock gateway is an in-process
|
|
7
|
-
*
|
|
6
|
+
* The mock gateway is an in-process request handler so the test never needs a
|
|
7
|
+
* real gateway or a listening socket.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { describe, expect, it } from "bun:test";
|
|
11
11
|
import { unlinkSync, writeFileSync } from "node:fs";
|
|
12
|
-
import {
|
|
13
|
-
import type {
|
|
14
|
-
import { ModelClient } from "../model/client.ts";
|
|
15
|
-
import type { DevEvent, Runnable } from "./dev.ts";
|
|
12
|
+
import type { ChatMessage, ModelClient } from "../model/client.ts";
|
|
13
|
+
import type { DevEvent, GatewayFetch, Runnable } from "./dev.ts";
|
|
16
14
|
import { loadRunnable, probeGateway, runTurn } from "./dev.ts";
|
|
17
15
|
|
|
18
16
|
// ── Mock gateway ──────────────────────────────────────────────────────────────
|
|
19
17
|
|
|
20
|
-
/**
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
let mockBaseUrl: string;
|
|
30
|
-
|
|
31
|
-
beforeAll(() => {
|
|
32
|
-
mockServer = serve({
|
|
33
|
-
port: 0, // OS-assigned port
|
|
34
|
-
fetch(req) {
|
|
35
|
-
const url = new URL(req.url);
|
|
36
|
-
|
|
37
|
-
if (url.pathname === "/health") {
|
|
38
|
-
return new Response("ok", { status: 200 });
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (url.pathname === "/v1/chat/completions") {
|
|
42
|
-
return new Response(MOCK_SSE_BODY, {
|
|
43
|
-
status: 200,
|
|
44
|
-
headers: { "Content-Type": "text/event-stream" },
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return new Response("not found", { status: 404 });
|
|
49
|
-
},
|
|
50
|
-
});
|
|
51
|
-
mockBaseUrl = `http://127.0.0.1:${mockServer.port}`;
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
afterAll(() => {
|
|
55
|
-
mockServer.stop();
|
|
56
|
-
});
|
|
18
|
+
/** Stable gateway URL used by the injected health request handler. */
|
|
19
|
+
const MOCK_GATEWAY_URL = "http://mock-gateway.test";
|
|
20
|
+
const mockFetch: GatewayFetch = async (input) => {
|
|
21
|
+
const url = new URL(String(input));
|
|
22
|
+
if (url.pathname === "/health") {
|
|
23
|
+
return new Response("ok", { status: 200 });
|
|
24
|
+
}
|
|
25
|
+
return new Response("not found", { status: 404 });
|
|
26
|
+
};
|
|
57
27
|
|
|
58
28
|
// ── Sample Runnable ───────────────────────────────────────────────────────────
|
|
59
29
|
|
|
@@ -98,7 +68,7 @@ const sampleRunnable: Runnable = {
|
|
|
98
68
|
|
|
99
69
|
describe("probeGateway", () => {
|
|
100
70
|
it("returns true when the gateway /health responds", async () => {
|
|
101
|
-
const reachable = await probeGateway(
|
|
71
|
+
const reachable = await probeGateway(MOCK_GATEWAY_URL, mockFetch);
|
|
102
72
|
expect(reachable).toBe(true);
|
|
103
73
|
});
|
|
104
74
|
|
|
@@ -130,7 +100,16 @@ describe("loadRunnable", () => {
|
|
|
130
100
|
|
|
131
101
|
describe("runTurn — full turn streams to stdout", () => {
|
|
132
102
|
it("collects text + tool events from a full turn", async () => {
|
|
133
|
-
const model =
|
|
103
|
+
const model = {
|
|
104
|
+
async *stream(): AsyncGenerator<{
|
|
105
|
+
content: string;
|
|
106
|
+
finishReason: string | null;
|
|
107
|
+
}> {
|
|
108
|
+
for (const content of ["Hello", ", world", "!"]) {
|
|
109
|
+
yield { content, finishReason: null };
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
} as unknown as ModelClient;
|
|
134
113
|
|
|
135
114
|
const events: DevEvent[] = [];
|
|
136
115
|
|
|
@@ -158,7 +137,7 @@ describe("runTurn — full turn streams to stdout", () => {
|
|
|
158
137
|
const toolCallEvents = events.filter((e) => e.type === "tool_call");
|
|
159
138
|
const toolResultEvents = events.filter((e) => e.type === "tool_result");
|
|
160
139
|
|
|
161
|
-
// Three text chunks from the mock
|
|
140
|
+
// Three text chunks from the mock model stream.
|
|
162
141
|
expect(textEvents).toHaveLength(3);
|
|
163
142
|
const fullText = textEvents
|
|
164
143
|
.map((e) => (e as { content: string }).content)
|
package/src/cli/dev.ts
CHANGED
|
@@ -93,15 +93,23 @@ export interface Runnable {
|
|
|
93
93
|
|
|
94
94
|
// ── Gateway reachability check ────────────────────────────────────────────────
|
|
95
95
|
|
|
96
|
+
export type GatewayFetch = (
|
|
97
|
+
input: string | URL | Request,
|
|
98
|
+
init?: RequestInit
|
|
99
|
+
) => Promise<Response>;
|
|
100
|
+
|
|
96
101
|
/**
|
|
97
102
|
* Probe the gateway at `baseUrl` with a HEAD request.
|
|
98
103
|
*
|
|
99
104
|
* Returns `true` when any HTTP response is received (even 4xx — the gateway
|
|
100
105
|
* is reachable), `false` when the request fails with a network error.
|
|
101
106
|
*/
|
|
102
|
-
export async function probeGateway(
|
|
107
|
+
export async function probeGateway(
|
|
108
|
+
baseUrl: string,
|
|
109
|
+
fetchImpl: GatewayFetch = fetch
|
|
110
|
+
): Promise<boolean> {
|
|
103
111
|
try {
|
|
104
|
-
await
|
|
112
|
+
await fetchImpl(`${baseUrl}/health`, {
|
|
105
113
|
method: "HEAD",
|
|
106
114
|
signal: AbortSignal.timeout(3000),
|
|
107
115
|
});
|
package/src/cli.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
6
|
* bunx ryu pack <dir>
|
|
7
|
-
* bunx ryu publish <
|
|
7
|
+
* bunx ryu github-publish <repository-or-package-url>
|
|
8
|
+
* bunx ryu publish <dir> (legacy migration escape hatch)
|
|
8
9
|
* bunx ryu agent-plugin <dir>
|
|
9
10
|
*
|
|
10
11
|
* Commands:
|
|
@@ -12,9 +13,10 @@
|
|
|
12
13
|
* Plugin bundle at <dir>/dist/plugin.bundle.json, plus the
|
|
13
14
|
* Agent Plugins interop pair in <dir> itself.
|
|
14
15
|
* Exits 0 on success; exits 1 with the failing field on error.
|
|
15
|
-
* publish <
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* github-publish <url>
|
|
17
|
+
* Submit a seller-owned GitHub repository/package URL to the
|
|
18
|
+
* GitHub-backed marketplace bridge.
|
|
19
|
+
* publish <dir> Deprecated server-artifact publishing; disabled by default.
|
|
18
20
|
* agent-plugin <dir>
|
|
19
21
|
* Emit only the Agent Plugins v1 interop pair (plugin.json and,
|
|
20
22
|
* when servers exist, mcp.json) derived from the manifest.json.
|
|
@@ -56,7 +58,9 @@ function printUsage(): void {
|
|
|
56
58
|
"",
|
|
57
59
|
"Usage:",
|
|
58
60
|
" bunx ryu pack <dir> Validate and bundle a manifest.json Plugin",
|
|
59
|
-
" bunx ryu publish <
|
|
61
|
+
" bunx ryu github-publish <url>",
|
|
62
|
+
" Submit a GitHub repository/package URL to the Marketplace",
|
|
63
|
+
" bunx ryu publish <dir> Deprecated server-artifact publishing (migration only)",
|
|
60
64
|
" bunx ryu dev <entry> Run a Runnable locally with an interactive chat loop",
|
|
61
65
|
" bunx ryu agent-plugin <dir>",
|
|
62
66
|
" Emit the Agent Plugins v1 interop pair (plugin.json + mcp.json)",
|
|
@@ -91,17 +95,21 @@ const MANIFEST_FILE_NAMES = [
|
|
|
91
95
|
// `manifest.json` — and then fail validation for missing `id`. The `$schema`
|
|
92
96
|
// discriminator separates them (see `isAgentPluginManifest`).
|
|
93
97
|
function resolveNativeManifestPath(dir: string): string | undefined {
|
|
94
|
-
return MANIFEST_FILE_NAMES.map((name) => join(dir, name)).find(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
98
|
+
return MANIFEST_FILE_NAMES.map((name) => join(dir, name)).find(
|
|
99
|
+
(candidate) => {
|
|
100
|
+
if (!existsSync(candidate)) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return !isAgentPluginManifest(
|
|
105
|
+
JSON.parse(readFileSync(candidate, "utf8"))
|
|
106
|
+
);
|
|
107
|
+
} catch {
|
|
108
|
+
// Unparseable: let the caller surface the JSON error against this path.
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
103
111
|
}
|
|
104
|
-
|
|
112
|
+
);
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
// Read + parse + validate the manifest in `dir`. Exits with the failing
|
|
@@ -572,8 +580,16 @@ async function commandPublish(rawDir: string): Promise<void> {
|
|
|
572
580
|
...(manifest.description ? { description: manifest.description } : {}),
|
|
573
581
|
...(manifest.tagline ? { tagline: manifest.tagline } : {}),
|
|
574
582
|
...(developer ? { developer } : {}),
|
|
583
|
+
...(manifest.author ? { author: manifest.author } : {}),
|
|
584
|
+
...(manifest.repository ? { repository: manifest.repository } : {}),
|
|
585
|
+
...(manifest.external ? { external: true } : {}),
|
|
575
586
|
...(manifest.category ? { category: manifest.category } : {}),
|
|
587
|
+
...(manifest.license ? { license: manifest.license } : {}),
|
|
588
|
+
...(manifest.keywords?.length ? { keywords: manifest.keywords } : {}),
|
|
576
589
|
...(manifest.iconUrl ? { iconUrl: manifest.iconUrl } : {}),
|
|
590
|
+
...(manifest.icon ? { icon: manifest.icon } : {}),
|
|
591
|
+
...(manifest.iconDither ? { iconDither: manifest.iconDither } : {}),
|
|
592
|
+
...(manifest.banner ? { banner: manifest.banner } : {}),
|
|
577
593
|
...(manifest.screenshots?.length
|
|
578
594
|
? { screenshots: manifest.screenshots }
|
|
579
595
|
: {}),
|
|
@@ -648,6 +664,58 @@ async function commandPublish(rawDir: string): Promise<void> {
|
|
|
648
664
|
);
|
|
649
665
|
}
|
|
650
666
|
|
|
667
|
+
async function commandGithubPublish(repositoryUrl: string): Promise<void> {
|
|
668
|
+
const input = repositoryUrl.trim();
|
|
669
|
+
if (!input) {
|
|
670
|
+
exitError(
|
|
671
|
+
"github-publish requires a GitHub repository or package URL, for example https://github.com/acme/my-app"
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
const token = authToken();
|
|
675
|
+
const url = `${publishBaseUrl()}/api/marketplace/github/publish`;
|
|
676
|
+
const proof = process.env.RYU_GITHUB_INSTALLATION_PROOF?.trim();
|
|
677
|
+
const body = {
|
|
678
|
+
url: input,
|
|
679
|
+
...(proof ? { installationProof: proof } : {}),
|
|
680
|
+
};
|
|
681
|
+
let response: Response;
|
|
682
|
+
try {
|
|
683
|
+
response = await fetch(url, {
|
|
684
|
+
method: "POST",
|
|
685
|
+
headers: {
|
|
686
|
+
authorization: `Bearer ${token}`,
|
|
687
|
+
"content-type": "application/json",
|
|
688
|
+
},
|
|
689
|
+
body: JSON.stringify(body),
|
|
690
|
+
});
|
|
691
|
+
} catch (error) {
|
|
692
|
+
exitError(`could not reach ${url}: ${String(error)}`);
|
|
693
|
+
}
|
|
694
|
+
const text = await response.text();
|
|
695
|
+
if (!response.ok) {
|
|
696
|
+
try {
|
|
697
|
+
const error = JSON.parse(text) as {
|
|
698
|
+
code?: string;
|
|
699
|
+
installationUrl?: string;
|
|
700
|
+
error?: string;
|
|
701
|
+
};
|
|
702
|
+
if (error.installationUrl) {
|
|
703
|
+
exitError(
|
|
704
|
+
`${error.error ?? "GitHub App installation required"}. Open ${error.installationUrl}, then set RYU_GITHUB_INSTALLATION_PROOF to the callback proof and retry.`
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
exitError(
|
|
708
|
+
`github-publish failed (${response.status}): ${error.error ?? text}`
|
|
709
|
+
);
|
|
710
|
+
} catch {
|
|
711
|
+
exitError(`github-publish failed (${response.status}): ${text}`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
process.stdout.write(
|
|
715
|
+
`GitHub-backed listing submitted for moderation:\n${text}\n`
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
651
719
|
// ── main ──────────────────────────────────────────────────────────────────────
|
|
652
720
|
|
|
653
721
|
const [, , command, ...args] = process.argv;
|
|
@@ -670,9 +738,24 @@ if (command === "pack") {
|
|
|
670
738
|
if (!dir) {
|
|
671
739
|
exitError("publish requires a directory argument: bunx ryu publish <dir>");
|
|
672
740
|
}
|
|
741
|
+
if (process.env.RYU_ENABLE_LEGACY_MARKETPLACE_WRITES !== "true") {
|
|
742
|
+
exitError(
|
|
743
|
+
"ryu publish is deprecated because marketplace package contents now live in GitHub Releases; use bunx ryu github-publish <repository-or-package-url>"
|
|
744
|
+
);
|
|
745
|
+
}
|
|
673
746
|
commandPublish(dir).catch((err: unknown) => {
|
|
674
747
|
exitError(String(err));
|
|
675
748
|
});
|
|
749
|
+
} else if (command === "github-publish") {
|
|
750
|
+
const repositoryUrl = args[0];
|
|
751
|
+
if (!repositoryUrl) {
|
|
752
|
+
exitError(
|
|
753
|
+
"github-publish requires a GitHub repository or package URL, for example https://github.com/acme/my-app"
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
commandGithubPublish(repositoryUrl).catch((err: unknown) => {
|
|
757
|
+
exitError(String(err));
|
|
758
|
+
});
|
|
676
759
|
} else if (command === "agent-plugin") {
|
|
677
760
|
const dir = args[0];
|
|
678
761
|
if (!dir) {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package exports ↔ tsup entries lockstep guard.
|
|
3
|
+
*
|
|
4
|
+
* The SDK's public surface is split across `package.json` `exports` subpaths
|
|
5
|
+
* (`@ryuhq/sdk/model`, `@ryuhq/sdk/mcp/server`, …) and `tsup.config.ts`
|
|
6
|
+
* entrypoints that compile each one. They must stay a bijection: a subpath
|
|
7
|
+
* with no entry never builds, and an entry with no subpath is unreachable.
|
|
8
|
+
* This is the cheap structural guard that keeps the two from drifting again
|
|
9
|
+
* (a past drift left docs importing `@ryuhq/sdk/model` / `@ryuhq/sdk/mcp/server`
|
|
10
|
+
* that resolved to nothing).
|
|
11
|
+
*
|
|
12
|
+
* Deterministic, filesystem-only, no network.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from "bun:test";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import tsupConfig from "../tsup.config.ts";
|
|
19
|
+
|
|
20
|
+
const PKG_PATH = join(import.meta.dir, "../package.json");
|
|
21
|
+
const pkg = JSON.parse(readFileSync(PKG_PATH, "utf8")) as {
|
|
22
|
+
exports: Record<
|
|
23
|
+
string,
|
|
24
|
+
{ types?: string; import?: string; require?: string }
|
|
25
|
+
>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// tsup's `defineConfig` type is the `Options | Options[] | fn` union, which the
|
|
29
|
+
// spread doesn't narrow; the entry map is the one shape this package uses.
|
|
30
|
+
const entryKeys = Object.keys(
|
|
31
|
+
(tsupConfig as { entry?: Record<string, string> }).entry ?? {}
|
|
32
|
+
);
|
|
33
|
+
const exportKeys = Object.keys(pkg.exports);
|
|
34
|
+
|
|
35
|
+
describe("package exports ↔ tsup entries lockstep", () => {
|
|
36
|
+
test("every tsup entry has a matching exports subpath", () => {
|
|
37
|
+
// `.` in the exports map corresponds to the `index` entry.
|
|
38
|
+
const expected = entryKeys.map((key) =>
|
|
39
|
+
key === "index" ? "." : `./${key}`
|
|
40
|
+
);
|
|
41
|
+
for (const key of expected) {
|
|
42
|
+
expect(exportKeys).toContain(key);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("every exports subpath has a matching tsup entry", () => {
|
|
47
|
+
const subpaths = exportKeys
|
|
48
|
+
.filter((key) => key !== ".")
|
|
49
|
+
.map((key) => key.replace(/^\.\//, ""));
|
|
50
|
+
for (const key of subpaths) {
|
|
51
|
+
expect(entryKeys).toContain(key);
|
|
52
|
+
}
|
|
53
|
+
expect(entryKeys).toContain("index");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("every subpath the docs/examples import resolves in the exports map", () => {
|
|
57
|
+
// Each of these is referenced by real consumers (fumadocs cookbooks,
|
|
58
|
+
// examples/, the desktop host, create-ryu-app) and must never 404.
|
|
59
|
+
const documented = [
|
|
60
|
+
"manifest",
|
|
61
|
+
"agent",
|
|
62
|
+
"model",
|
|
63
|
+
"mcp",
|
|
64
|
+
"mcp/server",
|
|
65
|
+
"mcp/client",
|
|
66
|
+
"plugin",
|
|
67
|
+
"runnable",
|
|
68
|
+
"builder",
|
|
69
|
+
];
|
|
70
|
+
for (const sub of documented) {
|
|
71
|
+
expect(
|
|
72
|
+
pkg.exports[`./${sub}`],
|
|
73
|
+
`@ryuhq/sdk/${sub} must be exported`
|
|
74
|
+
).toBeTruthy();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("each subpath points at its own dist files (no collisions)", () => {
|
|
79
|
+
const distFiles = new Set<string>();
|
|
80
|
+
for (const entry of Object.values(pkg.exports)) {
|
|
81
|
+
for (const kind of ["types", "import", "require"] as const) {
|
|
82
|
+
const path = entry?.[kind];
|
|
83
|
+
if (path) {
|
|
84
|
+
expect(distFiles.has(path), `dist file collision: ${path}`).toBe(
|
|
85
|
+
false
|
|
86
|
+
);
|
|
87
|
+
distFiles.add(path);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|