@ryuhq/sdk 0.1.4 → 0.1.6

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.
@@ -0,0 +1,222 @@
1
+ // Tests for the Agent Plugins v1.0.0 export projection. The spec is unusually
2
+ // strict about two things and both are asserted here because getting either wrong
3
+ // produces a file that a conformant client REJECTS rather than degrades on:
4
+ //
5
+ // 1. `plugin.json`'s schema is closed (§5.2) — exporting a stray top-level field
6
+ // is at best reported-and-ignored, and the field set is fixed.
7
+ // 2. An unknown field inside an `mcp.json` server entry invalidates THAT ENTRY
8
+ // (§7.2.2 rule 3), so the server export must be an allowlist, not a passthrough.
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import {
12
+ AGENT_PLUGIN_EXTENSION_NS,
13
+ AGENT_PLUGIN_MCP_SCHEMA_URL,
14
+ AGENT_PLUGIN_SCHEMA_URL,
15
+ toAgentPlugin,
16
+ toSpecName,
17
+ } from "./agent-plugin.ts";
18
+
19
+ /** The exact top-level field set §5.2 permits. */
20
+ const PERMITTED_TOP_LEVEL = new Set([
21
+ "$schema",
22
+ "name",
23
+ "version",
24
+ "description",
25
+ "author",
26
+ "homepage",
27
+ "repository",
28
+ "license",
29
+ "keywords",
30
+ "extensions",
31
+ ]);
32
+
33
+ /** Fields §7.2.1 permits on a stdio server entry. */
34
+ const PERMITTED_STDIO = new Set(["type", "command", "args", "env", "cwd"]);
35
+
36
+ /** §5.5 name constraints, expressed as one predicate. */
37
+ function isSpecLegalName(name: string): boolean {
38
+ return (
39
+ name.length >= 1 &&
40
+ name.length <= 64 &&
41
+ /^[a-z0-9][a-z0-9.-]*$/.test(name) &&
42
+ /[a-z0-9]$/.test(name) &&
43
+ !name.includes("--") &&
44
+ !name.includes("..")
45
+ );
46
+ }
47
+
48
+ describe("toSpecName", () => {
49
+ test("projects a scoped id onto a legal dotted name", () => {
50
+ expect(toSpecName("@ryu/advisor")).toBe("ryu.advisor");
51
+ expect(toSpecName("@example/research-assistant")).toBe(
52
+ "example.research-assistant"
53
+ );
54
+ });
55
+
56
+ test("output is spec-legal for every shape our ids take", () => {
57
+ const ids = [
58
+ "@ryu/advisor",
59
+ "@ryu/hook-session-context",
60
+ "@ryu/sample-widget",
61
+ "com.ryu.browser",
62
+ "@Scope/UPPER_Case",
63
+ "@ryu/weird--id..here",
64
+ "@ryu/-leading-and-trailing-",
65
+ `@ryu/${"x".repeat(120)}`,
66
+ ];
67
+ for (const id of ids) {
68
+ expect(isSpecLegalName(toSpecName(id))).toBe(true);
69
+ }
70
+ });
71
+
72
+ test("clamping to 64 chars cannot leave a trailing separator", () => {
73
+ // 63 chars then a hyphen at position 64: the naive slice would end on '-'.
74
+ const id = `@ryu/${"a".repeat(58)}-tail`;
75
+ const name = toSpecName(id);
76
+ expect(name.length).toBeLessThanOrEqual(64);
77
+ expect(isSpecLegalName(name)).toBe(true);
78
+ });
79
+
80
+ test("throws when nothing legal survives", () => {
81
+ expect(() => toSpecName("@/")).toThrow();
82
+ });
83
+ });
84
+
85
+ describe("toAgentPlugin", () => {
86
+ test("emits only permitted top-level fields", () => {
87
+ const { plugin } = toAgentPlugin({
88
+ id: "@ryu/advisor",
89
+ name: "Advisor",
90
+ version: "1.0.0",
91
+ description: "Consult a stronger reviewer model.",
92
+ category: "Research",
93
+ icon: "bulb",
94
+ iconDither: { from: 261, to: 295 },
95
+ surfaces: { core: { support: "full" } },
96
+ engines: { ryu: ">=0.1.0" },
97
+ runnables: [{ id: "t", name: "T", kind: "tool" }],
98
+ permission_grants: ["hook:side-model"],
99
+ contributes: { slash_commands: [] },
100
+ });
101
+ for (const key of Object.keys(plugin)) {
102
+ expect(PERMITTED_TOP_LEVEL.has(key)).toBe(true);
103
+ }
104
+ expect(plugin.$schema).toBe(AGENT_PLUGIN_SCHEMA_URL);
105
+ expect(plugin.name).toBe("ryu.advisor");
106
+ });
107
+
108
+ test("carries the real id and display name in the extension namespace", () => {
109
+ const { plugin } = toAgentPlugin({
110
+ id: "@ryu/advisor",
111
+ name: "Advisor",
112
+ version: "1.0.0",
113
+ });
114
+ expect(plugin.extensions[AGENT_PLUGIN_EXTENSION_NS]).toMatchObject({
115
+ id: "@ryu/advisor",
116
+ displayName: "Advisor",
117
+ });
118
+ });
119
+
120
+ test("falls back to the tagline when there is no description", () => {
121
+ const { plugin } = toAgentPlugin({
122
+ id: "@ryu/advisor",
123
+ name: "Advisor",
124
+ tagline: "A stronger second model reviews your answers",
125
+ });
126
+ expect(plugin.description).toBe(
127
+ "A stronger second model reviews your answers"
128
+ );
129
+ });
130
+
131
+ test("normalizes a bare-string author into the spec object form", () => {
132
+ const { plugin } = toAgentPlugin({
133
+ id: "@ryu/a",
134
+ name: "A",
135
+ author: "Ryu",
136
+ });
137
+ expect(plugin.author).toEqual({ name: "Ryu" });
138
+ });
139
+
140
+ test("drops author fields the spec does not permit", () => {
141
+ // An extra member on `author` makes the WHOLE manifest invalid (§5.4), so
142
+ // this must be a drop, not a passthrough.
143
+ const { plugin } = toAgentPlugin({
144
+ id: "@ryu/a",
145
+ name: "A",
146
+ author: { name: "Ryu", url: "https://example.com", twitter: "@ryu" },
147
+ });
148
+ expect(plugin.author).toEqual({ name: "Ryu", url: "https://example.com" });
149
+ });
150
+
151
+ test("no mcp.json when the manifest declares no servers", () => {
152
+ const { mcp } = toAgentPlugin({ id: "@ryu/a", name: "A" });
153
+ expect(mcp).toBeNull();
154
+ });
155
+
156
+ test("exports a stdio server with only the permitted fields", () => {
157
+ const { plugin, mcp } = toAgentPlugin({
158
+ id: "@ryu/ghost",
159
+ name: "Ghost",
160
+ mcp_servers: {
161
+ ghost: {
162
+ command: "ghost",
163
+ command_env: "RYU_GHOST_BIN",
164
+ args: ["mcp"],
165
+ description: "Ghost — desktop automation.",
166
+ },
167
+ },
168
+ });
169
+ expect(mcp?.$schema).toBe(AGENT_PLUGIN_MCP_SCHEMA_URL);
170
+ const server = mcp?.mcpServers.ghost;
171
+ expect(server).toEqual({ type: "stdio", command: "ghost", args: ["mcp"] });
172
+ for (const key of Object.keys(server ?? {})) {
173
+ expect(PERMITTED_STDIO.has(key)).toBe(true);
174
+ }
175
+ // The stripped native fields survive in the extension namespace.
176
+ expect(plugin.extensions[AGENT_PLUGIN_EXTENSION_NS]).toMatchObject({
177
+ mcp: {
178
+ ghost: {
179
+ command_env: "RYU_GHOST_BIN",
180
+ description: "Ghost — desktop automation.",
181
+ },
182
+ },
183
+ });
184
+ });
185
+
186
+ test("omits a disabled server but records it", () => {
187
+ const { plugin, mcp, notes } = toAgentPlugin({
188
+ id: "@ryu/a",
189
+ name: "A",
190
+ mcp_servers: {
191
+ off: { command: "x", enabled: false },
192
+ on: { command: "y" },
193
+ },
194
+ });
195
+ expect(mcp?.mcpServers.off).toBeUndefined();
196
+ expect(mcp?.mcpServers.on).toBeDefined();
197
+ expect(plugin.extensions[AGENT_PLUGIN_EXTENSION_NS]).toMatchObject({
198
+ mcp: { off: { enabled: false } },
199
+ });
200
+ expect(notes.some((n) => n.includes("off"))).toBe(true);
201
+ });
202
+
203
+ test("omits a command the spec cannot express, with a note", () => {
204
+ const { mcp, notes } = toAgentPlugin({
205
+ id: "@ryu/a",
206
+ name: "A",
207
+ mcp_servers: {
208
+ shellish: { command: "node server.mjs" },
209
+ absolute: { command: "/usr/local/bin/thing" },
210
+ fine: { command: "npx", args: ["-y", "pkg"] },
211
+ },
212
+ });
213
+ expect(mcp?.mcpServers.shellish).toBeUndefined();
214
+ expect(mcp?.mcpServers.absolute).toBeUndefined();
215
+ expect(mcp?.mcpServers.fine).toBeDefined();
216
+ expect(notes).toHaveLength(2);
217
+ });
218
+
219
+ test("throws on a manifest with no id", () => {
220
+ expect(() => toAgentPlugin({ name: "No id" })).toThrow("no id");
221
+ });
222
+ });
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Agent Plugins v1.0.0 export — the interop face of a Ryu `manifest.json`.
3
+ *
4
+ * The Agent Plugins Specification (https://agent-plugins.org/, TSC: Amazon,
5
+ * Cursor, Microsoft, OpenAI, Vercel) defines a small portable floor: a plugin is
6
+ * a directory with `plugin.json`, Agent Skills under `skills/<slug>/SKILL.md`,
7
+ * and MCP servers in `mcp.json`. Nothing else is portable.
8
+ *
9
+ * ## Why this is a SECOND file, not a migration
10
+ *
11
+ * The spec manifest schema is **closed** (§5.2): the only permitted top-level
12
+ * fields are `$schema`, `name`, `version`, `description`, `author`, `homepage`,
13
+ * `repository`, `license`, `keywords`, and `extensions`. Every field that makes a
14
+ * Ryu manifest a Ryu manifest — `id`, `runnables`, `contributes`, `surfaces`,
15
+ * `engines`, `permission_grants`, `mcp_servers`, `companion`, `ui_code_sha256` —
16
+ * is an unknown field there. So `manifest.json` can never *be* a conformant
17
+ * `plugin.json`; it can only be projected into one.
18
+ *
19
+ * `manifest.json` therefore stays the single source of truth and this module
20
+ * DERIVES the interop pair (`plugin.json` + `mcp.json`) from it. Nothing is
21
+ * hand-maintained, so the pair cannot desync — the same reason the packaged
22
+ * manifests are compiled in from their package home instead of copied (AGENTS.md).
23
+ *
24
+ * For the same reason `extensions` carries only what cannot be re-derived by a
25
+ * reader of the spec files: the real scoped id, the display name, and the
26
+ * per-server MCP fields the spec's closed server variants forced us to strip. It
27
+ * is deliberately NOT a copy of the whole native manifest — that would be a second
28
+ * source of truth with a stale-copy failure mode.
29
+ *
30
+ * Both `plugins-store/*` and `apps-store/*` use this one `PluginManifest` shape, so
31
+ * one converter covers both stores.
32
+ */
33
+
34
+ /** Agent Plugins spec version this module targets. */
35
+ export const AGENT_PLUGINS_SPEC_VERSION = "1.0.0";
36
+
37
+ /**
38
+ * Canonical manifest schema identifier (§5.2). MUST be this exact string — a
39
+ * client selects its validation rules from the value and MUST NOT fetch it.
40
+ */
41
+ export const AGENT_PLUGIN_SCHEMA_URL =
42
+ "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
43
+
44
+ /** Canonical `mcp.json` schema identifier (§7.2.1). */
45
+ export const AGENT_PLUGIN_MCP_SCHEMA_URL =
46
+ "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
47
+
48
+ /**
49
+ * Our reverse-domain client extension namespace (§8) — the key in `extensions`
50
+ * AND, when a plugin ships Ryu-only files, the top-level directory name.
51
+ *
52
+ * The spec asks for a domain the client controls, kept stable indefinitely, so
53
+ * this is a one-way door: changing it later orphans every published plugin's Ryu
54
+ * data. Derived from the `@ryuhq` npm scope / `ryuhq.com`.
55
+ */
56
+ export const AGENT_PLUGIN_EXTENSION_NS = "com.ryuhq.ryu";
57
+
58
+ /** Spec file name for the manifest (§5.1). */
59
+ export const AGENT_PLUGIN_MANIFEST_FILE = "plugin.json";
60
+
61
+ /** Spec file name for the MCP configuration (§7.2.1). */
62
+ export const AGENT_PLUGIN_MCP_FILE = "mcp.json";
63
+
64
+ /**
65
+ * Whether a parsed JSON value is an Agent Plugins spec manifest rather than a
66
+ * native Ryu one.
67
+ *
68
+ * This predicate is load-bearing, not cosmetic. `plugin.json` is BOTH the spec's
69
+ * manifest name and a legacy alias for our own `manifest.json` (Core's
70
+ * `MANIFEST_FILE_NAMES` and the CLI's copy of it both still accept it). Once a
71
+ * plugin directory carries an exported spec `plugin.json`, any resolver that
72
+ * blindly takes the first matching name can pick the wrong file and reject the
73
+ * plugin for having no `id`/`runnables`.
74
+ *
75
+ * The discriminator is unambiguous: a spec manifest MUST carry `$schema` with the
76
+ * canonical agent-plugins.org identifier (§5.2), and no native manifest has ever
77
+ * had that field.
78
+ */
79
+ export function isAgentPluginManifest(value: unknown): boolean {
80
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
81
+ return false;
82
+ }
83
+ const schema = (value as Record<string, unknown>).$schema;
84
+ return (
85
+ typeof schema === "string" &&
86
+ schema.startsWith("https://agent-plugins.org/schemas/")
87
+ );
88
+ }
89
+
90
+ /** Author object — the only three fields the spec permits (§5.4). */
91
+ export type AgentPluginAuthor = {
92
+ name?: string;
93
+ email?: string;
94
+ url?: string;
95
+ };
96
+
97
+ /** 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;
101
+ /** Human display name; spec `name` is a slug, not a display string. */
102
+ displayName: string;
103
+ /** Per-MCP-server fields the spec's closed server variants do not allow. */
104
+ mcp?: Record<string, RyuMcpServerExtras>;
105
+ };
106
+
107
+ /** Native MCP fields stripped out of the exported `mcp.json`. */
108
+ export type RyuMcpServerExtras = {
109
+ /** Env var that overrides `command` with an absolute path at spawn. */
110
+ command_env?: string;
111
+ /** Human description for our MCP listing endpoint. */
112
+ description?: string;
113
+ /**
114
+ * Present and `false` when the native manifest disables the server. Such a
115
+ * server is OMITTED from `mcp.json` entirely — the spec has no `enabled` flag,
116
+ * so emitting the entry would make a foreign client spawn something we
117
+ * deliberately do not.
118
+ */
119
+ enabled?: false;
120
+ };
121
+
122
+ /** A conformant `plugin.json` (§5.2). */
123
+ export type AgentPluginJson = {
124
+ $schema: string;
125
+ name: string;
126
+ version?: string;
127
+ description?: string;
128
+ author?: AgentPluginAuthor;
129
+ homepage?: string;
130
+ repository?: string;
131
+ license?: string;
132
+ keywords?: string[];
133
+ extensions: Record<string, unknown>;
134
+ };
135
+
136
+ /** A stdio server entry (§7.2.1) — the only variant we export. */
137
+ export type AgentPluginStdioServer = {
138
+ type: "stdio";
139
+ command: string;
140
+ args?: string[];
141
+ env?: Record<string, string>;
142
+ cwd?: string;
143
+ };
144
+
145
+ /** A conformant `mcp.json` (§7.2.1). */
146
+ export type AgentPluginMcpJson = {
147
+ $schema: string;
148
+ mcpServers: Record<string, AgentPluginStdioServer>;
149
+ };
150
+
151
+ /** What {@link toAgentPlugin} produces, plus what it had to leave behind. */
152
+ export type AgentPluginExport = {
153
+ /** The `plugin.json` contents. */
154
+ plugin: AgentPluginJson;
155
+ /** The `mcp.json` contents, or null when the plugin exports no server. */
156
+ mcp: AgentPluginMcpJson | null;
157
+ /**
158
+ * Human-readable notes about anything dropped or rewritten, so a lossy export
159
+ * is visible at the call site instead of silent.
160
+ */
161
+ notes: string[];
162
+ };
163
+
164
+ const SPEC_NAME_MAX = 64;
165
+ const ILLEGAL_NAME_CHARS = /[^a-z0-9.-]+/g;
166
+ const REPEATED_HYPHENS = /-{2,}/g;
167
+ const REPEATED_DOTS = /\.{2,}/g;
168
+ const LEADING_NON_ALNUM = /^[^a-z0-9]+/;
169
+ const TRAILING_NON_ALNUM = /[^a-z0-9]+$/;
170
+ /** A spec `command` must be ONE executable token (§7.2.1), so no whitespace. */
171
+ const WHITESPACE = /\s/;
172
+
173
+ /**
174
+ * Project a Ryu plugin id onto a spec-legal `name` (§5.5): 1–64 chars of
175
+ * `a-z 0-9 - .`, alphanumeric at both ends, no `--` and no `..`.
176
+ *
177
+ * Our ids are all `@scope/name`, which is illegal there (`@` and `/`), so
178
+ * `@ryu/advisor` becomes `ryu.advisor`. Periods ARE legal, which is what makes the
179
+ * mapping readable rather than a hash. The mapping is lossy by construction (two
180
+ * ids could collide after normalization), so the true id always rides in
181
+ * `extensions` and this value is never treated as an identity on our side.
182
+ */
183
+ export function toSpecName(id: string): string {
184
+ const normalized = id
185
+ .trim()
186
+ .toLowerCase()
187
+ .replace(/^@/, "")
188
+ .replace(/[/_]/g, ".")
189
+ .replace(ILLEGAL_NAME_CHARS, "-")
190
+ .replace(REPEATED_HYPHENS, "-")
191
+ .replace(REPEATED_DOTS, ".")
192
+ .replace(LEADING_NON_ALNUM, "")
193
+ .replace(TRAILING_NON_ALNUM, "")
194
+ .slice(0, SPEC_NAME_MAX)
195
+ // A slice can re-expose a trailing separator; trim again after clamping.
196
+ .replace(TRAILING_NON_ALNUM, "");
197
+ if (!normalized) {
198
+ throw new Error(
199
+ `plugin id ${JSON.stringify(id)} has no spec-legal name projection`
200
+ );
201
+ }
202
+ return normalized;
203
+ }
204
+
205
+ function asString(value: unknown): string | undefined {
206
+ return typeof value === "string" && value.trim() ? value : undefined;
207
+ }
208
+
209
+ function asStringArray(value: unknown): string[] | undefined {
210
+ if (!Array.isArray(value)) {
211
+ return;
212
+ }
213
+ const strings = value.filter((v): v is string => typeof v === "string");
214
+ return strings.length > 0 ? strings : undefined;
215
+ }
216
+
217
+ /**
218
+ * Normalize our `author` (a bare string OR a Claude-style object) into the spec's
219
+ * object form. The spec permits ONLY `name`, `email`, and `url` — any other member
220
+ * makes the whole manifest invalid, so extra keys are dropped rather than passed
221
+ * through.
222
+ */
223
+ function toSpecAuthor(value: unknown): AgentPluginAuthor | undefined {
224
+ const bare = asString(value);
225
+ if (bare) {
226
+ return { name: bare };
227
+ }
228
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
229
+ return;
230
+ }
231
+ const source = value as Record<string, unknown>;
232
+ const author: AgentPluginAuthor = {};
233
+ const name = asString(source.name);
234
+ const email = asString(source.email);
235
+ const url = asString(source.url);
236
+ if (name) {
237
+ author.name = name;
238
+ }
239
+ if (email) {
240
+ author.email = email;
241
+ }
242
+ if (url) {
243
+ author.url = url;
244
+ }
245
+ return Object.keys(author).length > 0 ? author : undefined;
246
+ }
247
+
248
+ function toSpecEnv(value: unknown): Record<string, string> | undefined {
249
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
250
+ return;
251
+ }
252
+ const env: Record<string, string> = {};
253
+ for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
254
+ if (typeof raw === "string") {
255
+ env[key] = raw;
256
+ }
257
+ }
258
+ return Object.keys(env).length > 0 ? env : undefined;
259
+ }
260
+
261
+ /**
262
+ * Convert one native MCP server declaration to a spec stdio entry, or return the
263
+ * reason it cannot be exported.
264
+ *
265
+ * Strictness is not optional here and differs from the manifest: an unknown field
266
+ * in a server entry makes THAT ENTRY invalid (§7.2.2 rule 3), where an unknown
267
+ * top-level manifest field is merely reported and ignored. So the export is an
268
+ * allowlist — `command`, `args`, `env` — and everything else moves to `extensions`.
269
+ */
270
+ function toSpecServer(
271
+ name: string,
272
+ decl: Record<string, unknown>
273
+ ): { server?: AgentPluginStdioServer; extras: RyuMcpServerExtras; note?: string } {
274
+ const extras: RyuMcpServerExtras = {};
275
+ const commandEnv = asString(decl.command_env);
276
+ const description = asString(decl.description);
277
+ if (commandEnv) {
278
+ extras.command_env = commandEnv;
279
+ }
280
+ if (description) {
281
+ extras.description = description;
282
+ }
283
+
284
+ if (decl.enabled === false) {
285
+ extras.enabled = false;
286
+ return {
287
+ extras,
288
+ note: `mcp server '${name}' is disabled in the native manifest and was omitted from ${AGENT_PLUGIN_MCP_FILE}`,
289
+ };
290
+ }
291
+
292
+ const command = asString(decl.command);
293
+ if (!command) {
294
+ return {
295
+ extras,
296
+ note: `mcp server '${name}' has no command and was omitted`,
297
+ };
298
+ }
299
+ // §7.2.1: `command` is a bare executable name or a `./`-relative path — never a
300
+ // shell string and never absolute. Ours are all bare names today; a violation is
301
+ // reported rather than exported as an entry a conformant client would reject.
302
+ if (WHITESPACE.test(command)) {
303
+ return {
304
+ extras,
305
+ note: `mcp server '${name}' command ${JSON.stringify(command)} is not a single executable token and was omitted`,
306
+ };
307
+ }
308
+ if (command.startsWith("/") || command.startsWith("~")) {
309
+ return {
310
+ extras,
311
+ note: `mcp server '${name}' command ${JSON.stringify(command)} is an absolute path (spec allows a bare name or './' relative path) and was omitted`,
312
+ };
313
+ }
314
+
315
+ const server: AgentPluginStdioServer = { type: "stdio", command };
316
+ const args = asStringArray(decl.args);
317
+ if (args) {
318
+ server.args = args;
319
+ }
320
+ const env = toSpecEnv(decl.env);
321
+ if (env) {
322
+ server.env = env;
323
+ }
324
+ return { server, extras };
325
+ }
326
+
327
+ /**
328
+ * Project a Ryu `manifest.json` onto the Agent Plugins interop pair.
329
+ *
330
+ * Takes the RAW parsed manifest (not the SDK's narrower zod type) because the
331
+ * fields that matter for export — notably `mcp_servers` — live in Core's richer
332
+ * model. Throws only when the id cannot be projected onto a spec-legal name;
333
+ * every other lossy step is reported through {@link AgentPluginExport.notes}.
334
+ */
335
+ export function toAgentPlugin(
336
+ manifest: Record<string, unknown>
337
+ ): AgentPluginExport {
338
+ const id = asString(manifest.id);
339
+ if (!id) {
340
+ throw new Error("manifest has no id");
341
+ }
342
+ const notes: string[] = [];
343
+
344
+ const ryu: RyuExtensionData = {
345
+ id,
346
+ displayName: asString(manifest.name) ?? id,
347
+ };
348
+
349
+ // Built in the spec's own field order (§5.2) so the emitted file reads like the
350
+ // spec's examples; `extensions` is attached last for the same reason.
351
+ const plugin = {
352
+ $schema: AGENT_PLUGIN_SCHEMA_URL,
353
+ name: toSpecName(id),
354
+ } as AgentPluginJson;
355
+
356
+ const version = asString(manifest.version);
357
+ if (version) {
358
+ plugin.version = version;
359
+ }
360
+ // `tagline` is our one-line pitch; it is the better `description` when no long
361
+ // description exists, and the spec has no second summary field to put it in.
362
+ const description = asString(manifest.description) ?? asString(manifest.tagline);
363
+ if (description) {
364
+ plugin.description = description;
365
+ }
366
+ const author = toSpecAuthor(manifest.author);
367
+ if (author) {
368
+ plugin.author = author;
369
+ }
370
+ const homepage = asString(manifest.homepage);
371
+ if (homepage) {
372
+ plugin.homepage = homepage;
373
+ }
374
+ const repository = asString(manifest.repository);
375
+ if (repository) {
376
+ plugin.repository = repository;
377
+ }
378
+ const license = asString(manifest.license);
379
+ if (license) {
380
+ plugin.license = license;
381
+ }
382
+ const keywords = asStringArray(manifest.keywords);
383
+ if (keywords) {
384
+ plugin.keywords = keywords;
385
+ }
386
+
387
+ const declared = manifest.mcp_servers;
388
+ const servers: Record<string, AgentPluginStdioServer> = {};
389
+ const mcpExtras: Record<string, RyuMcpServerExtras> = {};
390
+ if (declared && typeof declared === "object" && !Array.isArray(declared)) {
391
+ for (const [name, raw] of Object.entries(
392
+ declared as Record<string, unknown>
393
+ )) {
394
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
395
+ notes.push(`mcp server '${name}' is not an object and was omitted`);
396
+ continue;
397
+ }
398
+ const { server, extras, note } = toSpecServer(
399
+ name,
400
+ raw as Record<string, unknown>
401
+ );
402
+ if (Object.keys(extras).length > 0) {
403
+ mcpExtras[name] = extras;
404
+ }
405
+ if (note) {
406
+ notes.push(note);
407
+ }
408
+ if (server) {
409
+ servers[name] = server;
410
+ }
411
+ }
412
+ }
413
+ if (Object.keys(mcpExtras).length > 0) {
414
+ ryu.mcp = mcpExtras;
415
+ }
416
+ plugin.extensions = { [AGENT_PLUGIN_EXTENSION_NS]: ryu };
417
+
418
+ const mcp =
419
+ Object.keys(servers).length > 0
420
+ ? { $schema: AGENT_PLUGIN_MCP_SCHEMA_URL, mcpServers: servers }
421
+ : null;
422
+
423
+ return { plugin, mcp, notes };
424
+ }