@ryuhq/sdk 0.1.3 → 0.1.5

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/src/cli.ts CHANGED
@@ -5,19 +5,36 @@
5
5
  * Usage:
6
6
  * bunx ryu pack <dir>
7
7
  * bunx ryu publish <dir>
8
+ * bunx ryu agent-plugin <dir>
8
9
  *
9
10
  * Commands:
10
11
  * pack <dir> Validate the manifest.json in <dir> and emit a publish-ready
11
- * Plugin bundle at <dir>/dist/plugin.bundle.json.
12
+ * Plugin bundle at <dir>/dist/plugin.bundle.json, plus the
13
+ * Agent Plugins interop pair in <dir> itself.
12
14
  * Exits 0 on success; exits 1 with the failing field on error.
13
15
  * publish <dir> Validate the manifest.json and POST it to the Ryu Marketplace
14
16
  * publish endpoint with the author's auth token. The item is
15
17
  * stored as `pending` until a moderator approves it.
18
+ * agent-plugin <dir>
19
+ * Emit only the Agent Plugins v1 interop pair (plugin.json and,
20
+ * when servers exist, mcp.json) derived from the manifest.json.
16
21
  */
17
22
 
18
23
  import { createHash } from "node:crypto";
19
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
+ import {
25
+ existsSync,
26
+ mkdirSync,
27
+ readFileSync,
28
+ rmSync,
29
+ writeFileSync,
30
+ } from "node:fs";
20
31
  import { join, resolve } from "node:path";
32
+ import {
33
+ AGENT_PLUGIN_MANIFEST_FILE,
34
+ AGENT_PLUGIN_MCP_FILE,
35
+ isAgentPluginManifest,
36
+ toAgentPlugin,
37
+ } from "./agent-plugin.ts";
21
38
  import { commandDev } from "./cli/dev.ts";
22
39
  import { PluginManifestSchema } from "./manifest.ts";
23
40
 
@@ -41,6 +58,8 @@ function printUsage(): void {
41
58
  " bunx ryu pack <dir> Validate and bundle a manifest.json Plugin",
42
59
  " bunx ryu publish <dir> Validate and publish a manifest.json Plugin to the Ryu Marketplace",
43
60
  " bunx ryu dev <entry> Run a Runnable locally with an interactive chat loop",
61
+ " bunx ryu agent-plugin <dir>",
62
+ " Emit the Agent Plugins v1 interop pair (plugin.json + mcp.json)",
44
63
  "",
45
64
  ].join("\n")
46
65
  );
@@ -65,12 +84,30 @@ const MANIFEST_FILE_NAMES = [
65
84
  "ryu.json",
66
85
  ] as const;
67
86
 
87
+ // Resolve the NATIVE manifest path in `dir`, skipping an exported Agent Plugins
88
+ // `plugin.json`. Since `ryu pack` now writes a spec `plugin.json` into the plugin
89
+ // root, and `plugin.json` is also a legacy alias for our own manifest, a plain
90
+ // first-match would resolve to the spec file in any directory that has no
91
+ // `manifest.json` — and then fail validation for missing `id`. The `$schema`
92
+ // discriminator separates them (see `isAgentPluginManifest`).
93
+ 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;
103
+ }
104
+ });
105
+ }
106
+
68
107
  // Read + parse + validate the manifest in `dir`. Exits with the failing
69
108
  // field on any error. Shared by pack and publish so both validate identically.
70
109
  function loadManifest(dir: string): LoadedManifest {
71
- const manifestPath = MANIFEST_FILE_NAMES.map((name) => join(dir, name)).find(
72
- (candidate) => existsSync(candidate)
73
- );
110
+ const manifestPath = resolveNativeManifestPath(dir);
74
111
  if (!manifestPath) {
75
112
  exitError(`manifest.json not found in: ${dir}`);
76
113
  }
@@ -96,7 +133,7 @@ function loadManifest(dir: string): LoadedManifest {
96
133
  const message = first?.message ?? "validation failed";
97
134
  exitError(`manifest.json validation failed at '${field}': ${message}`);
98
135
  }
99
- return inlineCodeFiles(result.data, dir);
136
+ return inlineOutputStyleFiles(inlineCodeFiles(result.data, dir), dir);
100
137
  }
101
138
 
102
139
  /** Directories a `code_file` may name — mirrors Rust's `CODE_FILE_DIRS`. */
@@ -172,6 +209,84 @@ function inlineCodeFiles(
172
209
  return out;
173
210
  }
174
211
 
212
+ /**
213
+ * Largest output-style file `pack` will inline, in bytes — mirrors Rust's
214
+ * `MAX_OUTPUT_STYLE_BYTES`. Enforced here and not left to Core because the two
215
+ * bound different moments: Core rejects an oversized style at install, by which
216
+ * point the author has already signed and published a bundle nobody can install.
217
+ */
218
+ const MAX_OUTPUT_STYLE_BYTES = 64 * 1024;
219
+
220
+ /** The one directory an `output_styles[].file` may name — mirrors Rust's `OUTPUT_STYLE_DIR`. */
221
+ const OUTPUT_STYLE_DIR = "output-styles";
222
+ /**
223
+ * Mirrors `validate_output_style_path` CHARACTER FOR CHARACTER, unlike
224
+ * `CODE_FILE_PATH` above, which is only morally equivalent to its Rust twin. The
225
+ * two allowlists must accept the same set or `pack` signs a bundle Core rejects at
226
+ * install — the same after-the-fact failure the byte cap below exists to prevent.
227
+ * Hence the leading class excludes `.` but keeps `-` (Rust rejects only a leading
228
+ * dot), and `..` is checked separately rather than folded in, because a dot is
229
+ * legal mid-name and only the doubled form is traversal.
230
+ */
231
+ const OUTPUT_STYLE_PATH = /^output-styles\/[A-Za-z0-9_-][A-Za-z0-9._-]*\.md$/;
232
+
233
+ /**
234
+ * Replace every `output_styles[].file` with the file's contents — the same source
235
+ * form → wire form move `inlineCodeFiles` makes, for the same signing reason.
236
+ *
237
+ * A separate function with a separate allowlist rather than a parameterised version
238
+ * of `readCodeFile`, mirroring why Rust keeps `CODE_FILE_DIRS`, `PI_EXTENSION_DIR`
239
+ * and `OUTPUT_STYLE_DIR` as three constants instead of one: the allowlists ARE the
240
+ * gate, and a merged one is a single edit away from letting a style name a
241
+ * `hooks/*.js`, or a turn hook name a `.md` that nothing sandboxes.
242
+ *
243
+ * The inlined `source` carries the file VERBATIM, frontmatter included — Core's
244
+ * single `parse_output_style_md` reads a plugin style and a user's own
245
+ * `output-styles/*.md` the same way, and mirroring `name`/`description` up into
246
+ * manifest keys would create a second place a style's metadata could disagree with
247
+ * itself.
248
+ */
249
+ function inlineOutputStyleFiles(
250
+ manifest: LoadedManifest,
251
+ dir: string
252
+ ): LoadedManifest {
253
+ const out = manifest as LoadedManifest & {
254
+ contributes?: { output_styles?: Record<string, unknown>[] };
255
+ };
256
+
257
+ for (const style of out.contributes?.output_styles ?? []) {
258
+ const rel = style.file;
259
+ if (typeof rel !== "string") {
260
+ continue;
261
+ }
262
+ const label = `output style '${String(style.id)}'`;
263
+ if (!OUTPUT_STYLE_PATH.test(rel) || rel.includes("..")) {
264
+ exitError(
265
+ `${label}: file '${rel}' must be exactly '${OUTPUT_STYLE_DIR}/<name>.md' with no traversal`
266
+ );
267
+ }
268
+ let body: string;
269
+ try {
270
+ body = readFileSync(join(dir, rel), "utf8");
271
+ } catch (err) {
272
+ exitError(`${label}: could not read file '${rel}': ${String(err)}`);
273
+ }
274
+ if (!body.trim()) {
275
+ exitError(`${label}: file '${rel}' is empty`);
276
+ }
277
+ const bytes = Buffer.byteLength(body, "utf8");
278
+ if (bytes > MAX_OUTPUT_STYLE_BYTES) {
279
+ exitError(
280
+ `${label}: file '${rel}' is ${bytes} bytes, over the ${MAX_OUTPUT_STYLE_BYTES}-byte limit`
281
+ );
282
+ }
283
+ style.source = body;
284
+ style.file = undefined;
285
+ }
286
+
287
+ return out;
288
+ }
289
+
175
290
  // ── pack command ──────────────────────────────────────────────────────────────
176
291
 
177
292
  // Resolve the plugin's sandboxed-UI entry module — the source `ryu pack` bundles
@@ -260,6 +375,72 @@ async function bundleUiEntry(dir: string, uiEntry: string): Promise<string> {
260
375
  return await output.text();
261
376
  }
262
377
 
378
+ // ── Agent Plugins interop pair ───────────────────────────────────────────────
379
+ //
380
+ // Written into the plugin ROOT, not `dist/`, because the spec addresses a plugin
381
+ // as a directory: `plugin.json` at the root next to `skills/` and `mcp.json`
382
+ // (§4.2). Emitting into `dist/` would produce a manifest with no skills beside it.
383
+ //
384
+ // `manifest.json` remains the source of truth — the pair is derived on every pack,
385
+ // so it cannot drift. A stale `mcp.json` from a previous pack is removed when the
386
+ // manifest no longer declares a server, so an old file can never keep advertising
387
+ // a server we dropped.
388
+ // Reads the manifest RAW rather than taking the validated `LoadedManifest`: the
389
+ // SDK's zod schema models the narrower authoring shape and strips the fields Core
390
+ // adds — including `mcp_servers`, which is exactly what `mcp.json` is derived
391
+ // from. Projecting from the stripped object would silently emit a plugin with no
392
+ // MCP servers.
393
+ function emitAgentPlugin(dir: string): void {
394
+ const manifestPath = resolveNativeManifestPath(dir);
395
+ if (!manifestPath) {
396
+ exitError(`manifest.json not found in: ${dir}`);
397
+ }
398
+ let manifest: Record<string, unknown>;
399
+ try {
400
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<
401
+ string,
402
+ unknown
403
+ >;
404
+ } catch (err) {
405
+ exitError(`could not read ${manifestPath}: ${String(err)}`);
406
+ }
407
+
408
+ let plugin: ReturnType<typeof toAgentPlugin>["plugin"];
409
+ let mcp: ReturnType<typeof toAgentPlugin>["mcp"];
410
+ let notes: string[];
411
+ try {
412
+ ({ plugin, mcp, notes } = toAgentPlugin(manifest));
413
+ } catch (err) {
414
+ exitError(`could not derive ${AGENT_PLUGIN_MANIFEST_FILE}: ${String(err)}`);
415
+ }
416
+
417
+ const pluginPath = join(dir, AGENT_PLUGIN_MANIFEST_FILE);
418
+ writeFileSync(pluginPath, `${JSON.stringify(plugin, null, 2)}\n`, "utf8");
419
+
420
+ const mcpPath = join(dir, AGENT_PLUGIN_MCP_FILE);
421
+ if (mcp) {
422
+ writeFileSync(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`, "utf8");
423
+ } else if (existsSync(mcpPath)) {
424
+ rmSync(mcpPath);
425
+ }
426
+
427
+ const emitted = mcp
428
+ ? `${AGENT_PLUGIN_MANIFEST_FILE} + ${AGENT_PLUGIN_MCP_FILE}`
429
+ : AGENT_PLUGIN_MANIFEST_FILE;
430
+ process.stdout.write(`agent-plugin: ${emitted} → ${dir}\n`);
431
+ for (const note of notes) {
432
+ process.stdout.write(`agent-plugin: note: ${note}\n`);
433
+ }
434
+ }
435
+
436
+ function commandAgentPlugin(rawDir: string): void {
437
+ const dir = resolve(rawDir);
438
+ // Validate through the normal path first, so `agent-plugin` never emits an
439
+ // interop pair for a manifest `pack`/`publish` would reject.
440
+ loadManifest(dir);
441
+ emitAgentPlugin(dir);
442
+ }
443
+
263
444
  async function commandPack(rawDir: string): Promise<void> {
264
445
  const dir = resolve(rawDir);
265
446
  const manifest = loadManifest(dir);
@@ -292,6 +473,10 @@ async function commandPack(rawDir: string): Promise<void> {
292
473
  : manifestWithHash;
293
474
  writeFileSync(outPath, JSON.stringify(bundle, null, 2), "utf8");
294
475
 
476
+ // Re-derive the Agent Plugins interop pair on every pack so a published plugin
477
+ // directory is also a conformant Agent Plugin and the two can never drift.
478
+ emitAgentPlugin(dir);
479
+
295
480
  const codeNote = uiCode ? ` (+${uiCode.length}B ui_code)` : "";
296
481
  process.stdout.write(
297
482
  `packed ${manifest.id}@${manifest.version}${codeNote} → ${outPath}\n`
@@ -461,6 +646,14 @@ if (command === "pack") {
461
646
  commandPublish(dir).catch((err: unknown) => {
462
647
  exitError(String(err));
463
648
  });
649
+ } else if (command === "agent-plugin") {
650
+ const dir = args[0];
651
+ if (!dir) {
652
+ exitError(
653
+ "agent-plugin requires a directory argument: bunx ryu agent-plugin <dir>"
654
+ );
655
+ }
656
+ commandAgentPlugin(dir);
464
657
  } else if (command === "dev") {
465
658
  const entry = args[0];
466
659
  if (!entry) {