@ryuhq/sdk 0.1.14 → 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
@@ -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",
@@ -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`. Nothing else is portable.
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 type AgentPluginAuthor = {
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 type RyuExtensionData = {
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 type RyuMcpServerExtras = {
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 type AgentPluginJson = {
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
- extensions: Record<string, unknown>;
134
- };
132
+ license?: string;
133
+ name: string;
134
+ repository?: string;
135
+ version?: string;
136
+ }
135
137
 
136
- /** A stdio server entry (§7.2.1) — the only variant we export. */
137
- export type AgentPluginStdioServer = {
138
- type: "stdio";
139
- command: string;
138
+ /** A stdio server entry (§7.2.1). */
139
+ export interface AgentPluginStdioServer {
140
140
  args?: string[];
141
- env?: Record<string, string>;
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 type AgentPluginMcpJson = {
160
+ export interface AgentPluginMcpJson {
147
161
  $schema: string;
148
- mcpServers: Record<string, AgentPluginStdioServer>;
149
- };
162
+ mcpServers: Record<string, AgentPluginServer>;
163
+ }
150
164
 
151
165
  /** What {@link toAgentPlugin} produces, plus what it had to leave behind. */
152
- export type AgentPluginExport = {
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 stdio entry, or return the
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?: AgentPluginStdioServer; extras: RyuMcpServerExtras; note?: string } {
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 = asString(manifest.description) ?? asString(manifest.tagline);
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, AgentPluginStdioServer> = {};
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(
@@ -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 HTTP server (Bun.serve) that returns a
7
- * pre-canned SSE response so the test never needs a real gateway running.
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 { afterAll, beforeAll, describe, expect, it } from "bun:test";
10
+ import { describe, expect, it } from "bun:test";
11
11
  import { unlinkSync, writeFileSync } from "node:fs";
12
- import { serve } from "bun";
13
- import type { ChatMessage } from "../model/client.ts";
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
- /** Pre-canned SSE body the mock gateway returns for any chat completions POST. */
21
- const MOCK_SSE_BODY = [
22
- 'data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}',
23
- 'data: {"choices":[{"delta":{"content":", world"},"finish_reason":null}]}',
24
- 'data: {"choices":[{"delta":{"content":"!"},"finish_reason":"stop"}]}',
25
- "data: [DONE]",
26
- ].join("\n");
27
-
28
- let mockServer: ReturnType<typeof serve>;
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(mockBaseUrl);
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 = new ModelClient("test-model", { baseUrl: mockBaseUrl });
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 SSE body.
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(baseUrl: string): Promise<boolean> {
107
+ export async function probeGateway(
108
+ baseUrl: string,
109
+ fetchImpl: GatewayFetch = fetch
110
+ ): Promise<boolean> {
103
111
  try {
104
- await fetch(`${baseUrl}/health`, {
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 <dir>
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 <dir> Validate the manifest.json and POST it to the Ryu Marketplace
16
- * publish endpoint with the author's auth token. The item is
17
- * stored as `pending` until a moderator approves it.
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 <dir> Validate and publish a manifest.json Plugin to the Ryu Marketplace",
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((candidate) => {
95
- if (!existsSync(candidate)) {
96
- return false;
97
- }
98
- try {
99
- return !isAgentPluginManifest(JSON.parse(readFileSync(candidate, "utf8")));
100
- } catch {
101
- // Unparseable: let the caller surface the JSON error against this path.
102
- return true;
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
+ });