@ryuhq/sdk 0.1.14 → 0.2.2
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 +69 -7
- package/dist/action.cjs +839 -0
- package/dist/action.d.cts +88 -0
- package/dist/action.d.ts +88 -0
- package/dist/action.js +8 -0
- package/dist/agent-plugin.cjs +46 -0
- package/dist/agent-plugin.d.cts +43 -33
- package/dist/agent-plugin.d.ts +43 -33
- package/dist/agent-plugin.js +1 -1
- package/dist/agent.cjs +16 -0
- package/dist/agent.d.cts +377 -1
- package/dist/agent.d.ts +377 -1
- package/dist/agent.js +4 -2
- package/dist/app-B0Z9Ew_R.d.cts +141 -0
- package/dist/app-C-BDJwfG.d.ts +141 -0
- package/dist/builder.cjs +1194 -0
- package/dist/builder.d.cts +217 -0
- package/dist/builder.d.ts +217 -0
- package/dist/builder.js +29 -0
- package/dist/chunk-4TPUZDTI.js +94 -0
- package/dist/chunk-BC3A7HMO.js +164 -0
- package/dist/chunk-FZSFZOIN.js +200 -0
- package/dist/{chunk-ODFEUVPW.js → chunk-HACAGK65.js} +1 -18
- package/dist/chunk-HLKJZAFK.js +361 -0
- package/dist/chunk-IKEDLLFY.js +19 -0
- package/dist/{chunk-G6FLVEC4.js → chunk-JX6DDRXV.js} +46 -0
- package/dist/{chunk-AO2KJRDD.js → chunk-NZKVOSC2.js} +192 -3
- package/dist/chunk-QYFUNJOH.js +83 -0
- package/dist/chunk-SN2QBJUF.js +148 -0
- package/dist/chunk-TLDPEGC7.js +21 -0
- package/dist/chunk-TXSHHZF2.js +0 -0
- package/dist/chunk-VLIRNNAE.js +154 -0
- package/dist/{chunk-MTUBUPIV.js → chunk-Z57QDDJR.js} +8 -227
- package/dist/chunk-ZTJWBRUL.js +282 -0
- package/dist/cli.cjs +347 -18
- package/dist/cli.js +128 -23
- package/dist/client-D5U6ssPc.d.cts +84 -0
- package/dist/client-D5U6ssPc.d.ts +84 -0
- package/dist/index.cjs +810 -86
- package/dist/index.d.cts +19 -634
- package/dist/index.d.ts +19 -634
- package/dist/index.js +82 -699
- package/dist/manifest.cjs +200 -4
- package/dist/manifest.d.cts +260 -5
- package/dist/manifest.d.ts +260 -5
- package/dist/manifest.js +15 -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 +127 -0
- package/dist/mcp/server.d.ts +127 -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 +1438 -0
- package/dist/runnable.d.cts +274 -0
- package/dist/runnable.d.ts +274 -0
- package/dist/runnable.js +34 -0
- package/dist/{index-B6SkaAjJ.d.ts → tool-AjkdFvhE.d.ts} +88 -461
- package/dist/{index-BvAB5eMk.d.cts → tool-CgzW92O_.d.cts} +88 -461
- package/package.json +48 -3
- 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 +109 -36
- package/src/builder.ts +9 -0
- package/src/cli/dev.test.ts +26 -47
- package/src/cli/dev.ts +10 -2
- package/src/cli-security.test.ts +109 -0
- package/src/cli.ts +141 -25
- package/src/contracts-lockstep.test.ts +16 -2
- package/src/exports-lockstep.test.ts +93 -0
- package/src/generated/plugin-manifest.ts +322 -29
- package/src/index.ts +53 -0
- package/src/manifest-schema.test.ts +30 -1
- package/src/manifest.fixtures.test.ts +22 -6
- package/src/manifest.test.ts +165 -10
- package/src/manifest.ts +454 -188
- package/src/mcp/index.ts +18 -0
- package/src/mcp/server.ts +2 -1
- package/src/model/index.ts +22 -0
- package/src/plugin/ryu-plugin.ts +82 -0
- package/src/runnable/action.test.ts +128 -0
- package/src/runnable/action.ts +202 -0
- package/src/runnable/app.test.ts +2 -0
- package/src/runnable/app.ts +50 -14
- package/src/runnable/index.ts +20 -3
- package/src/runnable/primitives.test.ts +34 -0
- package/src/runnable/primitives.ts +116 -0
- package/src/runnable/runnable-types.ts +3 -0
- package/src/runnable/tool.ts +36 -5
- package/src/runnable/turn-hook.ts +8 -3
- package/src/slash-command.test.ts +69 -0
package/src/agent/loop.test.ts
CHANGED
|
@@ -163,7 +163,7 @@ describe("runAgentLoop", () => {
|
|
|
163
163
|
agentId: "agent-expense",
|
|
164
164
|
userId: "user-1",
|
|
165
165
|
tools: {
|
|
166
|
-
gmailSearch: ryuTool("
|
|
166
|
+
gmailSearch: ryuTool("composio.GMAIL_SEARCH_EMAILS", {
|
|
167
167
|
description: "Search Gmail",
|
|
168
168
|
parameters: {
|
|
169
169
|
type: "object",
|
|
@@ -176,7 +176,7 @@ describe("runAgentLoop", () => {
|
|
|
176
176
|
|
|
177
177
|
const events = await collect(agent.stream("find expenses"));
|
|
178
178
|
expect(captured.body).not.toBeNull();
|
|
179
|
-
expect(captured.body?.tool).toBe("
|
|
179
|
+
expect(captured.body?.tool).toBe("composio.GMAIL_SEARCH_EMAILS");
|
|
180
180
|
expect(captured.body?.agent_id).toBe("agent-expense");
|
|
181
181
|
expect(captured.body?.user_id).toBe("user-1");
|
|
182
182
|
const result = events.find((e) => e.type === "result");
|
|
@@ -208,7 +208,7 @@ describe("runAgentLoop", () => {
|
|
|
208
208
|
node: { baseUrl: NODE },
|
|
209
209
|
agentId: "agent-expense",
|
|
210
210
|
tools: {
|
|
211
|
-
gmailSearch: ryuTool("
|
|
211
|
+
gmailSearch: ryuTool("composio.GMAIL_SEARCH_EMAILS", {
|
|
212
212
|
parameters: { type: "object", properties: {} },
|
|
213
213
|
}),
|
|
214
214
|
},
|
|
@@ -242,7 +242,7 @@ describe("runAgentLoop", () => {
|
|
|
242
242
|
describe("executeTool", () => {
|
|
243
243
|
it("throws when a remote tool is used without an agentId", async () => {
|
|
244
244
|
const tools = {
|
|
245
|
-
gmailSearch: ryuTool("
|
|
245
|
+
gmailSearch: ryuTool("composio.GMAIL_SEARCH_EMAILS"),
|
|
246
246
|
};
|
|
247
247
|
await expect(
|
|
248
248
|
executeTool("gmailSearch", "{}", tools, {
|
package/src/agent/tools.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - **Local** tools are `ToolRunnable`s from `defineTool` — they run in-process
|
|
7
7
|
* via their `run(input, ctx)` implementation.
|
|
8
8
|
* - **Remote** tools are references to existing Ryu tools (e.g.
|
|
9
|
-
* `
|
|
9
|
+
* `composio.GMAIL_SEARCH_EMAILS`) created with `ryuTool(id)`. Their schema is
|
|
10
10
|
* lazily fetched from Core `GET /api/tools/describe` and they execute through
|
|
11
11
|
* Core `POST /api/mcp/tools/call`, which enforces the agent's allowlist and
|
|
12
12
|
* selects the Composio connected-account entity via `user_id`.
|
|
@@ -26,7 +26,7 @@ import type { ToolFunctionDef } from "./model-call.ts";
|
|
|
26
26
|
export interface RemoteToolRef {
|
|
27
27
|
/** One-line description shown to the model (overrides Core's describe). */
|
|
28
28
|
description?: string;
|
|
29
|
-
/** Fully-qualified Ryu tool id, e.g. `
|
|
29
|
+
/** Fully-qualified Ryu tool id, e.g. `composio.GMAIL_SEARCH_EMAILS`. */
|
|
30
30
|
id: string;
|
|
31
31
|
readonly kind: "remote";
|
|
32
32
|
/**
|
|
@@ -48,7 +48,7 @@ export interface RyuToolOptions {
|
|
|
48
48
|
*
|
|
49
49
|
* @example
|
|
50
50
|
* ```ts
|
|
51
|
-
* ryuTool("
|
|
51
|
+
* ryuTool("composio.GMAIL_SEARCH_EMAILS", {
|
|
52
52
|
* description: "Search the user's Gmail",
|
|
53
53
|
* parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
|
|
54
54
|
* });
|
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
|
*
|
|
@@ -27,7 +29,7 @@
|
|
|
27
29
|
* is deliberately NOT a copy of the whole native manifest — that would be a second
|
|
28
30
|
* source of truth with a stale-copy failure mode.
|
|
29
31
|
*
|
|
30
|
-
* Both `plugins-store/*` and `apps-store/*` use this one `PluginManifest` shape, so
|
|
32
|
+
* Both `plugins-store/{plugins,lsp,external_plugins}/*` and `apps-store/*` use this one `PluginManifest` shape, so
|
|
31
33
|
* one converter covers both stores.
|
|
32
34
|
*/
|
|
33
35
|
|
|
@@ -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/builder.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
CompanionSurface,
|
|
17
17
|
PluginManifest,
|
|
18
18
|
RunnableMeta,
|
|
19
|
+
SlashCommandContribution,
|
|
19
20
|
Surface,
|
|
20
21
|
} from "./manifest.ts";
|
|
21
22
|
import { PluginManifestSchema, RunnableMetaSchema } from "./manifest.ts";
|
|
@@ -325,6 +326,7 @@ export class AppBuilder {
|
|
|
325
326
|
private readonly _dependencies: AppDependency[] = [];
|
|
326
327
|
private readonly _requiredCapabilities: CapabilityReq[] = [];
|
|
327
328
|
private readonly _requiredGrants: string[] = [];
|
|
329
|
+
private readonly _slashCommands: SlashCommandContribution[] = [];
|
|
328
330
|
private readonly _targets: Surface[] = [];
|
|
329
331
|
|
|
330
332
|
/** Set the reverse-domain app id (e.g. `"com.example.checklist"`). */
|
|
@@ -393,6 +395,12 @@ export class AppBuilder {
|
|
|
393
395
|
return this;
|
|
394
396
|
}
|
|
395
397
|
|
|
398
|
+
/** Add a slash command and its optional sequential argument choices. */
|
|
399
|
+
slashCommand(command: SlashCommandContribution): this {
|
|
400
|
+
this._slashCommands.push(command);
|
|
401
|
+
return this;
|
|
402
|
+
}
|
|
403
|
+
|
|
396
404
|
/**
|
|
397
405
|
* Declare a **plugin-to-plugin dependency** (auto-enabled, in dependency order,
|
|
398
406
|
* before this app). `minVersion` is a MINIMUM (`"1.2.0"` = `">=1.2.0"`).
|
|
@@ -448,6 +456,7 @@ export class AppBuilder {
|
|
|
448
456
|
uiEntry: this._uiEntry,
|
|
449
457
|
tools: this._tools,
|
|
450
458
|
grants: this._grants,
|
|
459
|
+
slashCommands: this._slashCommands,
|
|
451
460
|
...(this._server ? { server: this._server } : {}),
|
|
452
461
|
...(this._displayMode ? { displayMode: this._displayMode } : {}),
|
|
453
462
|
...(this._mime ? { mime: this._mime } : {}),
|
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
|
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
symlinkSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const REPO_ROOT = join(import.meta.dir, "../../..");
|
|
16
|
+
const CLI = join(import.meta.dir, "cli.ts");
|
|
17
|
+
const temporaryDirectories: string[] = [];
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const directory of temporaryDirectories.splice(0)) {
|
|
21
|
+
rmSync(directory, { force: true, recursive: true });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function temporaryPackage(): string {
|
|
26
|
+
const directory = mkdtempSync(join(tmpdir(), "ryu-sdk-pack-"));
|
|
27
|
+
temporaryDirectories.push(directory);
|
|
28
|
+
return directory;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function runPack(directory: string) {
|
|
32
|
+
return spawnSync(process.execPath, [CLI, "pack", directory], {
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe.skipIf(process.platform === "win32")(
|
|
38
|
+
"ryu pack package containment",
|
|
39
|
+
() => {
|
|
40
|
+
it("rejects external symlinks for hooks, adapters, and output styles", () => {
|
|
41
|
+
const scenarios = [
|
|
42
|
+
{
|
|
43
|
+
manifest: "plugins-store/plugins/tool-firewall/manifest.json",
|
|
44
|
+
linkedFile: "hooks/pre.js",
|
|
45
|
+
copiedFiles: ["hooks/post.js"],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
manifest:
|
|
49
|
+
"plugins-store/external_plugins/cloudflare-browser-run/manifest.json",
|
|
50
|
+
linkedFile: "adapters/browser.navigate.js",
|
|
51
|
+
copiedFiles: [
|
|
52
|
+
"adapters/browser.screenshot.js",
|
|
53
|
+
"adapters/browser.snapshot.js",
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
manifest: "plugins-store/plugins/output-styles/manifest.json",
|
|
58
|
+
linkedFile: "output-styles/i-have-adhd.md",
|
|
59
|
+
copiedFiles: [
|
|
60
|
+
"output-styles/bro.md",
|
|
61
|
+
"output-styles/eli5.md",
|
|
62
|
+
"output-styles/explanatory.md",
|
|
63
|
+
"output-styles/gen-z.md",
|
|
64
|
+
"output-styles/learning.md",
|
|
65
|
+
"output-styles/no-ai-slop.md",
|
|
66
|
+
"output-styles/no-hype.md",
|
|
67
|
+
"output-styles/plain-technical.md",
|
|
68
|
+
"output-styles/plain-text.md",
|
|
69
|
+
"output-styles/proactive.md",
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
for (const scenario of scenarios) {
|
|
75
|
+
const directory = temporaryPackage();
|
|
76
|
+
const sourceRoot = dirname(join(REPO_ROOT, scenario.manifest));
|
|
77
|
+
writeFileSync(
|
|
78
|
+
join(directory, "manifest.json"),
|
|
79
|
+
readFileSync(join(REPO_ROOT, scenario.manifest))
|
|
80
|
+
);
|
|
81
|
+
for (const file of scenario.copiedFiles) {
|
|
82
|
+
mkdirSync(dirname(join(directory, file)), { recursive: true });
|
|
83
|
+
writeFileSync(
|
|
84
|
+
join(directory, file),
|
|
85
|
+
readFileSync(join(sourceRoot, file))
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
mkdirSync(dirname(join(directory, scenario.linkedFile)), {
|
|
89
|
+
recursive: true,
|
|
90
|
+
});
|
|
91
|
+
writeFileSync(
|
|
92
|
+
join(directory, scenario.linkedFile),
|
|
93
|
+
readFileSync(join(sourceRoot, scenario.linkedFile))
|
|
94
|
+
);
|
|
95
|
+
const workingResult = runPack(directory);
|
|
96
|
+
expect(workingResult.status).toBe(0);
|
|
97
|
+
unlinkSync(join(directory, scenario.linkedFile));
|
|
98
|
+
|
|
99
|
+
const external = join(dirname(directory), `${Date.now()}-outside.txt`);
|
|
100
|
+
writeFileSync(external, "private host data");
|
|
101
|
+
symlinkSync(external, join(directory, scenario.linkedFile));
|
|
102
|
+
|
|
103
|
+
const result = runPack(directory);
|
|
104
|
+
expect(result.status).toBe(1);
|
|
105
|
+
rmSync(external, { force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
);
|