@ryuhq/sdk 0.0.17 → 0.1.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.
@@ -110,6 +110,14 @@ var HookEventContributionSchema = z.object({
110
110
  /** Example of the `ctx.event` payload. Documentation, not a validated schema. */
111
111
  payload_example: z.record(z.string(), z.unknown()).optional()
112
112
  });
113
+ var PiExtensionContributionSchema = z.object({
114
+ /** Stable id for this extension within the plugin (`[a-z0-9][a-z0-9._-]*`). */
115
+ id: z.string().min(1),
116
+ /** Path to the source, relative to the plugin root: `pi-extensions/<name>.ts`. */
117
+ file: z.string().min(1),
118
+ /** Optional one-liner describing what the extension adds to the agent. */
119
+ description: z.string().optional()
120
+ });
113
121
  var DEFAULT_WIDGET_MIME = "text/html+skybridge";
114
122
  var DEFAULT_WIDGET_DISPLAY_MODE = "inline";
115
123
  var WidgetContributionSchema = z.object({
@@ -171,6 +179,14 @@ var ContributesSchema = z.object({
171
179
  * Rust-side `Contributes.dock_panels`; without it the CLI's zod parse would
172
180
  * strip the dock panel an app declares here. */
173
181
  dock_panels: z.array(z.record(z.string(), z.unknown())).default([]),
182
+ /** Deletable data categories the app owns — one "Delete all X" row in Settings
183
+ * → Danger Zone. Mirrors the Rust-side `Contributes.data_categories`; without
184
+ * it the CLI's zod parse would strip the declaration before signing, and the
185
+ * app's danger-zone row would simply never appear on any node that installed
186
+ * the packed bundle. Loosely typed here for the same reason as the surfaces
187
+ * above — Core is the layer that types it, because Core is the layer that has
188
+ * to resolve the id to something that can actually delete the rows. */
189
+ data_categories: z.array(z.record(z.string(), z.unknown())).default([]),
174
190
  /** Language servers the plugin declares, keyed by server name — the mirror of
175
191
  * Claude Code's `.lsp.json` / `lspServers`, so a config written for either host
176
192
  * loads in the other. Mirrors the Rust-side `Contributes.lsp_servers`; without
@@ -183,7 +199,16 @@ var ContributesSchema = z.object({
183
199
  * on its way through `ryu pack` — the same silent-deletion bug this field
184
200
  * exists to fix, one level down. Core is the layer that types it, because Core
185
201
  * is the layer that acts on it. */
186
- lsp_servers: z.record(z.string(), z.record(z.string(), z.unknown())).default({})
202
+ lsp_servers: z.record(z.string(), z.record(z.string(), z.unknown())).default({}),
203
+ /** Pi extensions the plugin ships — TypeScript the managed `ryu` (Pi) agent
204
+ * loads at process start. Mirrors the Rust-side `Contributes.pi_extensions`;
205
+ * without it the CLI's zod parse would strip the declaration before signing,
206
+ * and the packed plugin would ship a `pi-extensions/` folder nothing loads.
207
+ *
208
+ * Typed (not a loose record) because Ryu owns this vocabulary — three fields,
209
+ * all of them Core-interpreted — unlike `lsp_servers`, whose entry shape is
210
+ * Claude Code's to extend. */
211
+ pi_extensions: z.array(PiExtensionContributionSchema).default([])
187
212
  });
188
213
  var SetupStepSchema = z.object({
189
214
  /** Card heading (e.g. the companion app name). */
@@ -420,6 +445,7 @@ export {
420
445
  CompanionSurfaceSchema,
421
446
  TurnHookContributionSchema,
422
447
  HookEventContributionSchema,
448
+ PiExtensionContributionSchema,
423
449
  WidgetContributionSchema,
424
450
  ToolAppConfigSchema,
425
451
  ContributesSchema,
package/dist/cli.cjs CHANGED
@@ -396,6 +396,14 @@ var HookEventContributionSchema = import_zod.z.object({
396
396
  /** Example of the `ctx.event` payload. Documentation, not a validated schema. */
397
397
  payload_example: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).optional()
398
398
  });
399
+ var PiExtensionContributionSchema = import_zod.z.object({
400
+ /** Stable id for this extension within the plugin (`[a-z0-9][a-z0-9._-]*`). */
401
+ id: import_zod.z.string().min(1),
402
+ /** Path to the source, relative to the plugin root: `pi-extensions/<name>.ts`. */
403
+ file: import_zod.z.string().min(1),
404
+ /** Optional one-liner describing what the extension adds to the agent. */
405
+ description: import_zod.z.string().optional()
406
+ });
399
407
  var DEFAULT_WIDGET_MIME = "text/html+skybridge";
400
408
  var DEFAULT_WIDGET_DISPLAY_MODE = "inline";
401
409
  var WidgetContributionSchema = import_zod.z.object({
@@ -457,6 +465,14 @@ var ContributesSchema = import_zod.z.object({
457
465
  * Rust-side `Contributes.dock_panels`; without it the CLI's zod parse would
458
466
  * strip the dock panel an app declares here. */
459
467
  dock_panels: import_zod.z.array(import_zod.z.record(import_zod.z.string(), import_zod.z.unknown())).default([]),
468
+ /** Deletable data categories the app owns — one "Delete all X" row in Settings
469
+ * → Danger Zone. Mirrors the Rust-side `Contributes.data_categories`; without
470
+ * it the CLI's zod parse would strip the declaration before signing, and the
471
+ * app's danger-zone row would simply never appear on any node that installed
472
+ * the packed bundle. Loosely typed here for the same reason as the surfaces
473
+ * above — Core is the layer that types it, because Core is the layer that has
474
+ * to resolve the id to something that can actually delete the rows. */
475
+ data_categories: import_zod.z.array(import_zod.z.record(import_zod.z.string(), import_zod.z.unknown())).default([]),
460
476
  /** Language servers the plugin declares, keyed by server name — the mirror of
461
477
  * Claude Code's `.lsp.json` / `lspServers`, so a config written for either host
462
478
  * loads in the other. Mirrors the Rust-side `Contributes.lsp_servers`; without
@@ -469,7 +485,16 @@ var ContributesSchema = import_zod.z.object({
469
485
  * on its way through `ryu pack` — the same silent-deletion bug this field
470
486
  * exists to fix, one level down. Core is the layer that types it, because Core
471
487
  * is the layer that acts on it. */
472
- lsp_servers: import_zod.z.record(import_zod.z.string(), import_zod.z.record(import_zod.z.string(), import_zod.z.unknown())).default({})
488
+ lsp_servers: import_zod.z.record(import_zod.z.string(), import_zod.z.record(import_zod.z.string(), import_zod.z.unknown())).default({}),
489
+ /** Pi extensions the plugin ships — TypeScript the managed `ryu` (Pi) agent
490
+ * loads at process start. Mirrors the Rust-side `Contributes.pi_extensions`;
491
+ * without it the CLI's zod parse would strip the declaration before signing,
492
+ * and the packed plugin would ship a `pi-extensions/` folder nothing loads.
493
+ *
494
+ * Typed (not a loose record) because Ryu owns this vocabulary — three fields,
495
+ * all of them Core-interpreted — unlike `lsp_servers`, whose entry shape is
496
+ * Claude Code's to extend. */
497
+ pi_extensions: import_zod.z.array(PiExtensionContributionSchema).default([])
473
498
  });
474
499
  var SetupStepSchema = import_zod.z.object({
475
500
  /** Card heading (e.g. the companion app name). */
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
3
  PluginManifestSchema
4
- } from "./chunk-SKVIJH5I.js";
4
+ } from "./chunk-XTUK5I6I.js";
5
5
  import {
6
6
  ModelClient,
7
7
  resolveGatewayUrl
package/dist/index.cjs CHANGED
@@ -940,6 +940,14 @@ var HookEventContributionSchema = import_zod.z.object({
940
940
  /** Example of the `ctx.event` payload. Documentation, not a validated schema. */
941
941
  payload_example: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).optional()
942
942
  });
943
+ var PiExtensionContributionSchema = import_zod.z.object({
944
+ /** Stable id for this extension within the plugin (`[a-z0-9][a-z0-9._-]*`). */
945
+ id: import_zod.z.string().min(1),
946
+ /** Path to the source, relative to the plugin root: `pi-extensions/<name>.ts`. */
947
+ file: import_zod.z.string().min(1),
948
+ /** Optional one-liner describing what the extension adds to the agent. */
949
+ description: import_zod.z.string().optional()
950
+ });
943
951
  var DEFAULT_WIDGET_MIME = "text/html+skybridge";
944
952
  var DEFAULT_WIDGET_DISPLAY_MODE = "inline";
945
953
  var WidgetContributionSchema = import_zod.z.object({
@@ -1001,6 +1009,14 @@ var ContributesSchema = import_zod.z.object({
1001
1009
  * Rust-side `Contributes.dock_panels`; without it the CLI's zod parse would
1002
1010
  * strip the dock panel an app declares here. */
1003
1011
  dock_panels: import_zod.z.array(import_zod.z.record(import_zod.z.string(), import_zod.z.unknown())).default([]),
1012
+ /** Deletable data categories the app owns — one "Delete all X" row in Settings
1013
+ * → Danger Zone. Mirrors the Rust-side `Contributes.data_categories`; without
1014
+ * it the CLI's zod parse would strip the declaration before signing, and the
1015
+ * app's danger-zone row would simply never appear on any node that installed
1016
+ * the packed bundle. Loosely typed here for the same reason as the surfaces
1017
+ * above — Core is the layer that types it, because Core is the layer that has
1018
+ * to resolve the id to something that can actually delete the rows. */
1019
+ data_categories: import_zod.z.array(import_zod.z.record(import_zod.z.string(), import_zod.z.unknown())).default([]),
1004
1020
  /** Language servers the plugin declares, keyed by server name — the mirror of
1005
1021
  * Claude Code's `.lsp.json` / `lspServers`, so a config written for either host
1006
1022
  * loads in the other. Mirrors the Rust-side `Contributes.lsp_servers`; without
@@ -1013,7 +1029,16 @@ var ContributesSchema = import_zod.z.object({
1013
1029
  * on its way through `ryu pack` — the same silent-deletion bug this field
1014
1030
  * exists to fix, one level down. Core is the layer that types it, because Core
1015
1031
  * is the layer that acts on it. */
1016
- lsp_servers: import_zod.z.record(import_zod.z.string(), import_zod.z.record(import_zod.z.string(), import_zod.z.unknown())).default({})
1032
+ lsp_servers: import_zod.z.record(import_zod.z.string(), import_zod.z.record(import_zod.z.string(), import_zod.z.unknown())).default({}),
1033
+ /** Pi extensions the plugin ships — TypeScript the managed `ryu` (Pi) agent
1034
+ * loads at process start. Mirrors the Rust-side `Contributes.pi_extensions`;
1035
+ * without it the CLI's zod parse would strip the declaration before signing,
1036
+ * and the packed plugin would ship a `pi-extensions/` folder nothing loads.
1037
+ *
1038
+ * Typed (not a loose record) because Ryu owns this vocabulary — three fields,
1039
+ * all of them Core-interpreted — unlike `lsp_servers`, whose entry shape is
1040
+ * Claude Code's to extend. */
1041
+ pi_extensions: import_zod.z.array(PiExtensionContributionSchema).default([])
1017
1042
  });
1018
1043
  var SetupStepSchema = import_zod.z.object({
1019
1044
  /** Card heading (e.g. the companion app name). */
@@ -1310,6 +1335,10 @@ function defineApp(options) {
1310
1335
  // takes no `contributes` passthrough. An app that wants to declare language
1311
1336
  // servers writes them in a hand-authored `manifest.json`.
1312
1337
  lsp_servers: {},
1338
+ // Same reason again: a danger-zone category and a Pi extension are both
1339
+ // hand-authored declarations, not something derivable from runnables.
1340
+ data_categories: [],
1341
+ pi_extensions: [],
1313
1342
  widgets
1314
1343
  };
1315
1344
  const raw = {
@@ -1929,13 +1958,16 @@ function definePlugin(options) {
1929
1958
  settings_tabs: options.settingsTabs ?? [],
1930
1959
  slash_commands: options.slashCommands ?? [],
1931
1960
  lsp_servers: options.lspServers ?? {},
1932
- // A turn-hook plugin contributes no app widgets, sidebar entries or dock
1933
- // panels; the fields are required on the resolved `Contributes` type (zod
1934
- // defaults applied), so set them explicitly.
1961
+ // A turn-hook plugin contributes no app widgets, sidebar entries, dock
1962
+ // panels, danger-zone categories or Pi extensions; the fields are required
1963
+ // on the resolved `Contributes` type (zod defaults applied), so set them
1964
+ // explicitly.
1935
1965
  widgets: [],
1936
1966
  sidebar_sections: [],
1937
1967
  sidebar_buttons: [],
1938
- dock_panels: []
1968
+ dock_panels: [],
1969
+ data_categories: [],
1970
+ pi_extensions: []
1939
1971
  };
1940
1972
  const tools = options.tools ?? [];
1941
1973
  const runnables = tools.map((t) => inlineToolRunnable(t));
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  coreManifestJsonSchema,
13
13
  validateManifestStrict,
14
14
  validatePluginId
15
- } from "./chunk-SKVIJH5I.js";
15
+ } from "./chunk-XTUK5I6I.js";
16
16
  import {
17
17
  Agent,
18
18
  PRIMITIVE_BINDINGS,
@@ -98,6 +98,10 @@ function defineApp(options) {
98
98
  // takes no `contributes` passthrough. An app that wants to declare language
99
99
  // servers writes them in a hand-authored `manifest.json`.
100
100
  lsp_servers: {},
101
+ // Same reason again: a danger-zone category and a Pi extension are both
102
+ // hand-authored declarations, not something derivable from runnables.
103
+ data_categories: [],
104
+ pi_extensions: [],
101
105
  widgets
102
106
  };
103
107
  const raw = {
@@ -717,13 +721,16 @@ function definePlugin(options) {
717
721
  settings_tabs: options.settingsTabs ?? [],
718
722
  slash_commands: options.slashCommands ?? [],
719
723
  lsp_servers: options.lspServers ?? {},
720
- // A turn-hook plugin contributes no app widgets, sidebar entries or dock
721
- // panels; the fields are required on the resolved `Contributes` type (zod
722
- // defaults applied), so set them explicitly.
724
+ // A turn-hook plugin contributes no app widgets, sidebar entries, dock
725
+ // panels, danger-zone categories or Pi extensions; the fields are required
726
+ // on the resolved `Contributes` type (zod defaults applied), so set them
727
+ // explicitly.
723
728
  widgets: [],
724
729
  sidebar_sections: [],
725
730
  sidebar_buttons: [],
726
- dock_panels: []
731
+ dock_panels: [],
732
+ data_categories: [],
733
+ pi_extensions: []
727
734
  };
728
735
  const tools = options.tools ?? [];
729
736
  const runnables = tools.map((t) => inlineToolRunnable(t));
package/dist/manifest.cjs CHANGED
@@ -25,6 +25,7 @@ __export(manifest_exports, {
25
25
  CompanionSurfaceSchema: () => CompanionSurfaceSchema,
26
26
  ContributesSchema: () => ContributesSchema,
27
27
  HookEventContributionSchema: () => HookEventContributionSchema,
28
+ PiExtensionContributionSchema: () => PiExtensionContributionSchema,
28
29
  PluginManifestSchema: () => PluginManifestSchema,
29
30
  RequiresSchema: () => RequiresSchema,
30
31
  RunnableKindSchema: () => RunnableKindSchema,
@@ -157,6 +158,14 @@ var HookEventContributionSchema = import_zod.z.object({
157
158
  /** Example of the `ctx.event` payload. Documentation, not a validated schema. */
158
159
  payload_example: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).optional()
159
160
  });
161
+ var PiExtensionContributionSchema = import_zod.z.object({
162
+ /** Stable id for this extension within the plugin (`[a-z0-9][a-z0-9._-]*`). */
163
+ id: import_zod.z.string().min(1),
164
+ /** Path to the source, relative to the plugin root: `pi-extensions/<name>.ts`. */
165
+ file: import_zod.z.string().min(1),
166
+ /** Optional one-liner describing what the extension adds to the agent. */
167
+ description: import_zod.z.string().optional()
168
+ });
160
169
  var DEFAULT_WIDGET_MIME = "text/html+skybridge";
161
170
  var DEFAULT_WIDGET_DISPLAY_MODE = "inline";
162
171
  var WidgetContributionSchema = import_zod.z.object({
@@ -218,6 +227,14 @@ var ContributesSchema = import_zod.z.object({
218
227
  * Rust-side `Contributes.dock_panels`; without it the CLI's zod parse would
219
228
  * strip the dock panel an app declares here. */
220
229
  dock_panels: import_zod.z.array(import_zod.z.record(import_zod.z.string(), import_zod.z.unknown())).default([]),
230
+ /** Deletable data categories the app owns — one "Delete all X" row in Settings
231
+ * → Danger Zone. Mirrors the Rust-side `Contributes.data_categories`; without
232
+ * it the CLI's zod parse would strip the declaration before signing, and the
233
+ * app's danger-zone row would simply never appear on any node that installed
234
+ * the packed bundle. Loosely typed here for the same reason as the surfaces
235
+ * above — Core is the layer that types it, because Core is the layer that has
236
+ * to resolve the id to something that can actually delete the rows. */
237
+ data_categories: import_zod.z.array(import_zod.z.record(import_zod.z.string(), import_zod.z.unknown())).default([]),
221
238
  /** Language servers the plugin declares, keyed by server name — the mirror of
222
239
  * Claude Code's `.lsp.json` / `lspServers`, so a config written for either host
223
240
  * loads in the other. Mirrors the Rust-side `Contributes.lsp_servers`; without
@@ -230,7 +247,16 @@ var ContributesSchema = import_zod.z.object({
230
247
  * on its way through `ryu pack` — the same silent-deletion bug this field
231
248
  * exists to fix, one level down. Core is the layer that types it, because Core
232
249
  * is the layer that acts on it. */
233
- lsp_servers: import_zod.z.record(import_zod.z.string(), import_zod.z.record(import_zod.z.string(), import_zod.z.unknown())).default({})
250
+ lsp_servers: import_zod.z.record(import_zod.z.string(), import_zod.z.record(import_zod.z.string(), import_zod.z.unknown())).default({}),
251
+ /** Pi extensions the plugin ships — TypeScript the managed `ryu` (Pi) agent
252
+ * loads at process start. Mirrors the Rust-side `Contributes.pi_extensions`;
253
+ * without it the CLI's zod parse would strip the declaration before signing,
254
+ * and the packed plugin would ship a `pi-extensions/` folder nothing loads.
255
+ *
256
+ * Typed (not a loose record) because Ryu owns this vocabulary — three fields,
257
+ * all of them Core-interpreted — unlike `lsp_servers`, whose entry shape is
258
+ * Claude Code's to extend. */
259
+ pi_extensions: import_zod.z.array(PiExtensionContributionSchema).default([])
234
260
  });
235
261
  var SetupStepSchema = import_zod.z.object({
236
262
  /** Card heading (e.g. the companion app name). */
@@ -466,6 +492,7 @@ function coreManifestJsonSchema() {
466
492
  CompanionSurfaceSchema,
467
493
  ContributesSchema,
468
494
  HookEventContributionSchema,
495
+ PiExtensionContributionSchema,
469
496
  PluginManifestSchema,
470
497
  RequiresSchema,
471
498
  RunnableKindSchema,
@@ -115,6 +115,32 @@ declare const HookEventContributionSchema: z.ZodObject<{
115
115
  payload_example: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
116
116
  }, z.core.$strip>;
117
117
  type HookEventContribution = z.infer<typeof HookEventContributionSchema>;
118
+ /**
119
+ * One Pi extension the plugin ships — a TypeScript file the managed `ryu` (Pi)
120
+ * agent loads at process start. Mirrors Rust `PiExtensionContribution`.
121
+ *
122
+ * Carries a PATH, never a body: unlike `turn_hooks` there is no inline `code`
123
+ * twin, because nothing downstream reads the source as a string.
124
+ *
125
+ * That makes it a SIDECAR FILE, and `ryu pack` emits a single JSON bundle — so a
126
+ * plugin installed from a packed bundle arrives without its `pi-extensions/`
127
+ * directory and Core resolves the declaration to a visible skip. Same open gap as
128
+ * `skills/**`, which the bundle likewise does not carry. Today the path that works
129
+ * is a plugin whose directory is on disk (a built-in, a satellite checkout, a dev
130
+ * tree). Do not "fix" this by inlining the source into the manifest: a 50 KB
131
+ * TypeScript program escaped into a JSON string is the unauditable form the whole
132
+ * `code_file` extraction exists to prevent.
133
+ *
134
+ * Note this is UNSANDBOXED code: it runs inside the agent process with full host
135
+ * privilege, so Core gates it behind the operator-only `pi:extension` grant for
136
+ * any non-built-in plugin.
137
+ */
138
+ declare const PiExtensionContributionSchema: z.ZodObject<{
139
+ id: z.ZodString;
140
+ file: z.ZodString;
141
+ description: z.ZodOptional<z.ZodString>;
142
+ }, z.core.$strip>;
143
+ type PiExtensionContribution = z.infer<typeof PiExtensionContributionSchema>;
118
144
  /**
119
145
  * One app-widget contribution (Ryu Apps). Binds the render tool that produces the
120
146
  * widget to its `ui://widget/<slug>.html` template. Shape-identical to Core's
@@ -187,7 +213,13 @@ declare const ContributesSchema: z.ZodObject<{
187
213
  sidebar_sections: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
188
214
  sidebar_buttons: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
189
215
  dock_panels: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
216
+ data_categories: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
190
217
  lsp_servers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
218
+ pi_extensions: z.ZodDefault<z.ZodArray<z.ZodObject<{
219
+ id: z.ZodString;
220
+ file: z.ZodString;
221
+ description: z.ZodOptional<z.ZodString>;
222
+ }, z.core.$strip>>>;
191
223
  }, z.core.$strip>;
192
224
  type Contributes = z.infer<typeof ContributesSchema>;
193
225
  /**
@@ -338,7 +370,13 @@ declare const PluginManifestSchema: z.ZodObject<{
338
370
  sidebar_sections: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
339
371
  sidebar_buttons: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
340
372
  dock_panels: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
373
+ data_categories: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
341
374
  lsp_servers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
375
+ pi_extensions: z.ZodDefault<z.ZodArray<z.ZodObject<{
376
+ id: z.ZodString;
377
+ file: z.ZodString;
378
+ description: z.ZodOptional<z.ZodString>;
379
+ }, z.core.$strip>>>;
342
380
  }, z.core.$strip>>;
343
381
  requires: z.ZodOptional<z.ZodObject<{
344
382
  apps: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -435,4 +473,4 @@ declare function validateManifestStrict(manifestJson: string): string;
435
473
  */
436
474
  declare function coreManifestJsonSchema(): unknown;
437
475
 
438
- export { type AppDependency, AppDependencySchema, type CapabilityReq, CapabilityReqSchema, type CompanionSurface, CompanionSurfaceSchema, type Contributes, ContributesSchema, type HookEventContribution, HookEventContributionSchema, type PluginManifest, PluginManifestSchema, type Requires, RequiresSchema, type RunnableKind, RunnableKindSchema, type RunnableMeta, RunnableMetaSchema, type SetupStep, SetupStepSchema, type Surface, SurfaceSchema, type ToolAppConfig, ToolAppConfigSchema, type TurnHookContribution, TurnHookContributionSchema, type WidgetContribution, WidgetContributionSchema, coreManifestJsonSchema, labelImpersonatesSystemChrome, validateManifestStrict, validatePluginId };
476
+ export { type AppDependency, AppDependencySchema, type CapabilityReq, CapabilityReqSchema, type CompanionSurface, CompanionSurfaceSchema, type Contributes, ContributesSchema, type HookEventContribution, HookEventContributionSchema, type PiExtensionContribution, PiExtensionContributionSchema, type PluginManifest, PluginManifestSchema, type Requires, RequiresSchema, type RunnableKind, RunnableKindSchema, type RunnableMeta, RunnableMetaSchema, type SetupStep, SetupStepSchema, type Surface, SurfaceSchema, type ToolAppConfig, ToolAppConfigSchema, type TurnHookContribution, TurnHookContributionSchema, type WidgetContribution, WidgetContributionSchema, coreManifestJsonSchema, labelImpersonatesSystemChrome, validateManifestStrict, validatePluginId };
@@ -115,6 +115,32 @@ declare const HookEventContributionSchema: z.ZodObject<{
115
115
  payload_example: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
116
116
  }, z.core.$strip>;
117
117
  type HookEventContribution = z.infer<typeof HookEventContributionSchema>;
118
+ /**
119
+ * One Pi extension the plugin ships — a TypeScript file the managed `ryu` (Pi)
120
+ * agent loads at process start. Mirrors Rust `PiExtensionContribution`.
121
+ *
122
+ * Carries a PATH, never a body: unlike `turn_hooks` there is no inline `code`
123
+ * twin, because nothing downstream reads the source as a string.
124
+ *
125
+ * That makes it a SIDECAR FILE, and `ryu pack` emits a single JSON bundle — so a
126
+ * plugin installed from a packed bundle arrives without its `pi-extensions/`
127
+ * directory and Core resolves the declaration to a visible skip. Same open gap as
128
+ * `skills/**`, which the bundle likewise does not carry. Today the path that works
129
+ * is a plugin whose directory is on disk (a built-in, a satellite checkout, a dev
130
+ * tree). Do not "fix" this by inlining the source into the manifest: a 50 KB
131
+ * TypeScript program escaped into a JSON string is the unauditable form the whole
132
+ * `code_file` extraction exists to prevent.
133
+ *
134
+ * Note this is UNSANDBOXED code: it runs inside the agent process with full host
135
+ * privilege, so Core gates it behind the operator-only `pi:extension` grant for
136
+ * any non-built-in plugin.
137
+ */
138
+ declare const PiExtensionContributionSchema: z.ZodObject<{
139
+ id: z.ZodString;
140
+ file: z.ZodString;
141
+ description: z.ZodOptional<z.ZodString>;
142
+ }, z.core.$strip>;
143
+ type PiExtensionContribution = z.infer<typeof PiExtensionContributionSchema>;
118
144
  /**
119
145
  * One app-widget contribution (Ryu Apps). Binds the render tool that produces the
120
146
  * widget to its `ui://widget/<slug>.html` template. Shape-identical to Core's
@@ -187,7 +213,13 @@ declare const ContributesSchema: z.ZodObject<{
187
213
  sidebar_sections: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
188
214
  sidebar_buttons: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
189
215
  dock_panels: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
216
+ data_categories: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
190
217
  lsp_servers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
218
+ pi_extensions: z.ZodDefault<z.ZodArray<z.ZodObject<{
219
+ id: z.ZodString;
220
+ file: z.ZodString;
221
+ description: z.ZodOptional<z.ZodString>;
222
+ }, z.core.$strip>>>;
191
223
  }, z.core.$strip>;
192
224
  type Contributes = z.infer<typeof ContributesSchema>;
193
225
  /**
@@ -338,7 +370,13 @@ declare const PluginManifestSchema: z.ZodObject<{
338
370
  sidebar_sections: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
339
371
  sidebar_buttons: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
340
372
  dock_panels: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
373
+ data_categories: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
341
374
  lsp_servers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
375
+ pi_extensions: z.ZodDefault<z.ZodArray<z.ZodObject<{
376
+ id: z.ZodString;
377
+ file: z.ZodString;
378
+ description: z.ZodOptional<z.ZodString>;
379
+ }, z.core.$strip>>>;
342
380
  }, z.core.$strip>>;
343
381
  requires: z.ZodOptional<z.ZodObject<{
344
382
  apps: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -435,4 +473,4 @@ declare function validateManifestStrict(manifestJson: string): string;
435
473
  */
436
474
  declare function coreManifestJsonSchema(): unknown;
437
475
 
438
- export { type AppDependency, AppDependencySchema, type CapabilityReq, CapabilityReqSchema, type CompanionSurface, CompanionSurfaceSchema, type Contributes, ContributesSchema, type HookEventContribution, HookEventContributionSchema, type PluginManifest, PluginManifestSchema, type Requires, RequiresSchema, type RunnableKind, RunnableKindSchema, type RunnableMeta, RunnableMetaSchema, type SetupStep, SetupStepSchema, type Surface, SurfaceSchema, type ToolAppConfig, ToolAppConfigSchema, type TurnHookContribution, TurnHookContributionSchema, type WidgetContribution, WidgetContributionSchema, coreManifestJsonSchema, labelImpersonatesSystemChrome, validateManifestStrict, validatePluginId };
476
+ export { type AppDependency, AppDependencySchema, type CapabilityReq, CapabilityReqSchema, type CompanionSurface, CompanionSurfaceSchema, type Contributes, ContributesSchema, type HookEventContribution, HookEventContributionSchema, type PiExtensionContribution, PiExtensionContributionSchema, type PluginManifest, PluginManifestSchema, type Requires, RequiresSchema, type RunnableKind, RunnableKindSchema, type RunnableMeta, RunnableMetaSchema, type SetupStep, SetupStepSchema, type Surface, SurfaceSchema, type ToolAppConfig, ToolAppConfigSchema, type TurnHookContribution, TurnHookContributionSchema, type WidgetContribution, WidgetContributionSchema, coreManifestJsonSchema, labelImpersonatesSystemChrome, validateManifestStrict, validatePluginId };
package/dist/manifest.js CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  CompanionSurfaceSchema,
5
5
  ContributesSchema,
6
6
  HookEventContributionSchema,
7
+ PiExtensionContributionSchema,
7
8
  PluginManifestSchema,
8
9
  RequiresSchema,
9
10
  RunnableKindSchema,
@@ -17,13 +18,14 @@ import {
17
18
  labelImpersonatesSystemChrome,
18
19
  validateManifestStrict,
19
20
  validatePluginId
20
- } from "./chunk-SKVIJH5I.js";
21
+ } from "./chunk-XTUK5I6I.js";
21
22
  export {
22
23
  AppDependencySchema,
23
24
  CapabilityReqSchema,
24
25
  CompanionSurfaceSchema,
25
26
  ContributesSchema,
26
27
  HookEventContributionSchema,
28
+ PiExtensionContributionSchema,
27
29
  PluginManifestSchema,
28
30
  RequiresSchema,
29
31
  RunnableKindSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryuhq/sdk",
3
- "version": "0.0.17",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Ryu developer SDK: typed builders and CLI for authoring manifest.json Plugin bundles",
6
6
  "main": "./dist/index.cjs",
@@ -44,11 +44,11 @@
44
44
  "clean": "rm -rf dist"
45
45
  },
46
46
  "dependencies": {
47
- "@ryuhq/sdk-native": "0.1.0",
47
+ "@ryuhq/sdk-native": "workspace:*",
48
48
  "zod": "^4.1.13"
49
49
  },
50
50
  "devDependencies": {
51
- "@types/bun": "^1.3.4",
51
+ "@types/bun": "catalog:",
52
52
  "json-schema-to-typescript": "^15.0.4",
53
53
  "tsup": "^8.5.1",
54
54
  "typescript": "^5"
@@ -49,7 +49,7 @@ export type ProviderTarget = "local-machine" | "remote-desktop";
49
49
  * An **empty/absent** `targets` list means the plugin runs on *every* surface —
50
50
  * that is the backward-compatible default and MUST NOT be read as "hidden".
51
51
  */
52
- export type Surface = "gateway" | "core" | "desktop" | "island" | "mobile" | "extension" | "web" | "cli";
52
+ export type Surface = "gateway" | "core" | "desktop" | "island" | "mobile" | "extension" | "web" | "cli" | "unknown";
53
53
 
54
54
  /**
55
55
  * An installable Ryu App manifest (`manifest.json`).
@@ -487,6 +487,40 @@ export interface Contributes {
487
487
  * older shells" instead of breaking the composer.
488
488
  */
489
489
  composer_controls?: unknown[];
490
+ /**
491
+ * **Deletable data categories** the app owns — one "Delete all X" row in
492
+ * Settings → Danger Zone (see [`DataCategoryContribution`]).
493
+ *
494
+ * The danger zone used to be two hardcoded lists that had to be edited
495
+ * together: a `DataCategory` enum in Core and a `CATEGORIES` array carrying the
496
+ * user-facing copy in the closed desktop source. Monitors and Meetings are
497
+ * app-owned data, so both lists named apps — which meant a node where Monitors
498
+ * was never enabled still offered to delete monitors, and the count was always
499
+ * 0. Declaring the category here makes the owning app the single source of both
500
+ * its existence and its wording, and makes the row appear and disappear with
501
+ * the app instead of with a client-side feature-detect.
502
+ *
503
+ * # Core-interpreted, so a typed struct — and NOT on the contributions endpoint
504
+ *
505
+ * Core has to resolve the id to something that can actually count and delete
506
+ * the rows, so per this type's own doc comment this gets a typed struct rather
507
+ * than opaque JSON, and it is gathered at its consumption site
508
+ * (`GET /api/data/counts`, which serves each category's descriptor next to its
509
+ * live count) rather than at `GET /api/plugins/contributions` — the same
510
+ * disposition as [`Contributes::tool_filters`] and [`Contributes::lsp_servers`].
511
+ *
512
+ * # Declaration, not implementation
513
+ *
514
+ * A declared category is served only when Core knows how to clear it; an id
515
+ * Core does not implement is skipped with a warn rather than being offered as a
516
+ * button that 400s. That split is deliberate and not a stepping stone to a
517
+ * generic HTTP truncate: clearing monitors has to tear down each monitor's
518
+ * backing scheduler job, and clearing meetings has to broadcast on the meetings
519
+ * SSE stream, so a blind `DELETE /monitors` would leave jobs ticking forever.
520
+ * The manifest owns *whether the row exists and what it says*; Core owns *what
521
+ * deleting actually entails*.
522
+ */
523
+ data_categories?: DataCategoryContribution[];
490
524
  /**
491
525
  * App-registered **workspace dock panels** — a tab in the desktop's bottom or
492
526
  * right dock (Terminal / Code Review / Browser / Simulator live there today).
@@ -582,6 +616,45 @@ export interface Contributes {
582
616
  lsp_servers?: {
583
617
  [k: string]: LspServerContribution;
584
618
  };
619
+ /**
620
+ * **Pi extensions** the plugin ships — TypeScript files the managed `ryu` (Pi)
621
+ * agent loads at process start:
622
+ *
623
+ * ```json
624
+ * "pi_extensions": [
625
+ * { "id": "shell", "file": "pi-extensions/ryu-shell.ts",
626
+ * "description": "background bash for the managed Pi agent" }
627
+ * ]
628
+ * ```
629
+ *
630
+ * Pi ships none of plan mode, sub-agents, permission prompts or background bash
631
+ * and says so deliberately in its own docs — "you can build or install those
632
+ * workflows as extensions or packages". This surface is that seam: the
633
+ * capabilities Core used to hardcode into the spawn path become plugins the user
634
+ * can enable and disable, and a third party can ship one at all.
635
+ *
636
+ * # This is UNSANDBOXED code, and the tier gate is not optional
637
+ *
638
+ * A [`Contributes::turn_hooks`] body runs in the deny-by-default Deno sandbox
639
+ * behind capability-gated `host.*` calls. A file named here runs **inside the Pi
640
+ * process** with full host privilege: the first-party ones spawn children and
641
+ * POST to Core. That is the same arbitrary-code-execution class as
642
+ * [`PluginManifest::mcp_servers`], so Core gates it identically — Core tier is
643
+ * auto-allowed, Community tier needs an operator-allowlisted grant, and the gate
644
+ * sits at the materializer, because writing the file is what makes it run.
645
+ *
646
+ * # Core-interpreted, so a typed struct — and NOT on the contributions endpoint
647
+ *
648
+ * Core resolves each `file` and projects it into the managed Pi's config dir, so
649
+ * per this type's own doc comment it gets a typed struct and is gathered at its
650
+ * consumption site (`pi_config::app_extensions`) rather than served from
651
+ * `GET /api/plugins/contributions` — the same disposition as
652
+ * [`Contributes::lsp_servers`].
653
+ *
654
+ * The `file` is deliberately NOT hydrated into an inline string the way a
655
+ * `code_file` is; see [`PluginManifest::pi_extension_refs`] for why.
656
+ */
657
+ pi_extensions?: PiExtensionContribution[];
585
658
  /**
586
659
  * Gateway policies the plugin contributes (referenced by runnable id).
587
660
  */
@@ -699,6 +772,42 @@ export interface ContributionId {
699
772
  */
700
773
  title?: string | null;
701
774
  }
775
+ /**
776
+ * One **deletable data category** an app owns (see [`Contributes::data_categories`]).
777
+ *
778
+ * Everything the Danger Zone needs to draw and arm one destructive row, so the copy
779
+ * lives with the app whose data it describes rather than in the desktop's source.
780
+ */
781
+ export interface DataCategoryContribution {
782
+ /**
783
+ * The word the user must type to arm the delete. Absent = the [`noun`], which is
784
+ * the right default often enough that requiring it would just be ceremony.
785
+ * Matched case-insensitively by the client.
786
+ *
787
+ * [`noun`]: DataCategoryContribution::noun
788
+ */
789
+ confirm_word?: string | null;
790
+ /**
791
+ * Exactly what disappears, shown in the confirm dialog. Required, and required
792
+ * to be specific: this is the last thing the user reads before an irreversible
793
+ * delete, and "this cannot be undone" tells them nothing they did not know.
794
+ */
795
+ detail: string;
796
+ /**
797
+ * Stable id — this is the `category` a `POST /api/data/clear` names, so it is
798
+ * the app's half of the delete contract and renaming it breaks the button.
799
+ */
800
+ id: string;
801
+ /**
802
+ * Plural noun for the live count line ("42 monitors" / "No monitors") and the
803
+ * "N deleted" toast. Lower-case: it is used mid-sentence.
804
+ */
805
+ noun: string;
806
+ /**
807
+ * The destructive button label and confirm-dialog title ("Delete all monitors").
808
+ */
809
+ title: string;
810
+ }
702
811
  /**
703
812
  * One app-registered **workspace dock panel** — a tab in the desktop's bottom or
704
813
  * right dock (see [`Contributes::dock_panels`]).
@@ -955,6 +1064,31 @@ export interface LspServerContribution {
955
1064
  */
956
1065
  workspaceFolder?: string | null;
957
1066
  }
1067
+ /**
1068
+ * One **Pi extension** a plugin ships (a [`Contributes::pi_extensions`] row).
1069
+ *
1070
+ * Carries a path, never a body: unlike [`TurnHookContribution`] there is no inline
1071
+ * `code` twin, because nothing downstream reads the source as a string.
1072
+ */
1073
+ export interface PiExtensionContribution {
1074
+ /**
1075
+ * Optional human-facing one-liner (what the extension adds to the agent).
1076
+ */
1077
+ description?: string | null;
1078
+ /**
1079
+ * Path to the TypeScript source, relative to the plugin root — exactly
1080
+ * `pi-extensions/<name>.ts`. See [`validate_pi_extension_path`].
1081
+ */
1082
+ file: string;
1083
+ /**
1084
+ * Stable id for this extension within the plugin (`[a-z0-9][a-z0-9._-]*`).
1085
+ *
1086
+ * Part of the materialized file name, so it is what makes one plugin's
1087
+ * extensions distinguishable from another's on disk — and why it is validated
1088
+ * with the same alphabet as an event name rather than left free-form.
1089
+ */
1090
+ id: string;
1091
+ }
958
1092
  /**
959
1093
  * One **settings tab** a plugin contributes (see [`Contributes::settings_tabs`]).
960
1094
  *
@@ -2209,7 +2343,7 @@ export interface SurfaceEntry {
2209
2343
  /**
2210
2344
  * How much of the plugin this surface supports.
2211
2345
  */
2212
- support?: "full" | "limited" | "list" | "commands" | "none";
2346
+ support?: "full" | "limited" | "list" | "commands" | "none" | "unknown";
2213
2347
  /**
2214
2348
  * Optional surface-specific UI descriptor (bundle id, mount point, …),
2215
2349
  * interpreted by the surface's app host. Opaque to the contract.
package/src/manifest.ts CHANGED
@@ -239,6 +239,41 @@ export const HookEventContributionSchema = z.object({
239
239
 
240
240
  export type HookEventContribution = z.infer<typeof HookEventContributionSchema>;
241
241
 
242
+ // ── PiExtensionContribution ───────────────────────────────────────────────────
243
+
244
+ /**
245
+ * One Pi extension the plugin ships — a TypeScript file the managed `ryu` (Pi)
246
+ * agent loads at process start. Mirrors Rust `PiExtensionContribution`.
247
+ *
248
+ * Carries a PATH, never a body: unlike `turn_hooks` there is no inline `code`
249
+ * twin, because nothing downstream reads the source as a string.
250
+ *
251
+ * That makes it a SIDECAR FILE, and `ryu pack` emits a single JSON bundle — so a
252
+ * plugin installed from a packed bundle arrives without its `pi-extensions/`
253
+ * directory and Core resolves the declaration to a visible skip. Same open gap as
254
+ * `skills/**`, which the bundle likewise does not carry. Today the path that works
255
+ * is a plugin whose directory is on disk (a built-in, a satellite checkout, a dev
256
+ * tree). Do not "fix" this by inlining the source into the manifest: a 50 KB
257
+ * TypeScript program escaped into a JSON string is the unauditable form the whole
258
+ * `code_file` extraction exists to prevent.
259
+ *
260
+ * Note this is UNSANDBOXED code: it runs inside the agent process with full host
261
+ * privilege, so Core gates it behind the operator-only `pi:extension` grant for
262
+ * any non-built-in plugin.
263
+ */
264
+ export const PiExtensionContributionSchema = z.object({
265
+ /** Stable id for this extension within the plugin (`[a-z0-9][a-z0-9._-]*`). */
266
+ id: z.string().min(1),
267
+ /** Path to the source, relative to the plugin root: `pi-extensions/<name>.ts`. */
268
+ file: z.string().min(1),
269
+ /** Optional one-liner describing what the extension adds to the agent. */
270
+ description: z.string().optional(),
271
+ });
272
+
273
+ export type PiExtensionContribution = z.infer<
274
+ typeof PiExtensionContributionSchema
275
+ >;
276
+
242
277
  // ── WidgetContribution (Ryu Apps) ─────────────────────────────────────────────
243
278
 
244
279
  /** Default widget MIME dialect. Mirrors Core `default_widget_mime`. */
@@ -335,6 +370,14 @@ export const ContributesSchema = z.object({
335
370
  * Rust-side `Contributes.dock_panels`; without it the CLI's zod parse would
336
371
  * strip the dock panel an app declares here. */
337
372
  dock_panels: z.array(z.record(z.string(), z.unknown())).default([]),
373
+ /** Deletable data categories the app owns — one "Delete all X" row in Settings
374
+ * → Danger Zone. Mirrors the Rust-side `Contributes.data_categories`; without
375
+ * it the CLI's zod parse would strip the declaration before signing, and the
376
+ * app's danger-zone row would simply never appear on any node that installed
377
+ * the packed bundle. Loosely typed here for the same reason as the surfaces
378
+ * above — Core is the layer that types it, because Core is the layer that has
379
+ * to resolve the id to something that can actually delete the rows. */
380
+ data_categories: z.array(z.record(z.string(), z.unknown())).default([]),
338
381
  /** Language servers the plugin declares, keyed by server name — the mirror of
339
382
  * Claude Code's `.lsp.json` / `lspServers`, so a config written for either host
340
383
  * loads in the other. Mirrors the Rust-side `Contributes.lsp_servers`; without
@@ -350,6 +393,15 @@ export const ContributesSchema = z.object({
350
393
  lsp_servers: z
351
394
  .record(z.string(), z.record(z.string(), z.unknown()))
352
395
  .default({}),
396
+ /** Pi extensions the plugin ships — TypeScript the managed `ryu` (Pi) agent
397
+ * loads at process start. Mirrors the Rust-side `Contributes.pi_extensions`;
398
+ * without it the CLI's zod parse would strip the declaration before signing,
399
+ * and the packed plugin would ship a `pi-extensions/` folder nothing loads.
400
+ *
401
+ * Typed (not a loose record) because Ryu owns this vocabulary — three fields,
402
+ * all of them Core-interpreted — unlike `lsp_servers`, whose entry shape is
403
+ * Claude Code's to extend. */
404
+ pi_extensions: z.array(PiExtensionContributionSchema).default([]),
353
405
  });
354
406
 
355
407
  export type Contributes = z.infer<typeof ContributesSchema>;
@@ -228,6 +228,10 @@ export function defineApp(options: DefineAppOptions): PluginManifest {
228
228
  // takes no `contributes` passthrough. An app that wants to declare language
229
229
  // servers writes them in a hand-authored `manifest.json`.
230
230
  lsp_servers: {},
231
+ // Same reason again: a danger-zone category and a Pi extension are both
232
+ // hand-authored declarations, not something derivable from runnables.
233
+ data_categories: [],
234
+ pi_extensions: [],
231
235
  widgets,
232
236
  };
233
237
 
@@ -176,13 +176,16 @@ export function definePlugin(options: DefinePluginOptions): PluginManifest {
176
176
  settings_tabs: options.settingsTabs ?? [],
177
177
  slash_commands: options.slashCommands ?? [],
178
178
  lsp_servers: options.lspServers ?? {},
179
- // A turn-hook plugin contributes no app widgets, sidebar entries or dock
180
- // panels; the fields are required on the resolved `Contributes` type (zod
181
- // defaults applied), so set them explicitly.
179
+ // A turn-hook plugin contributes no app widgets, sidebar entries, dock
180
+ // panels, danger-zone categories or Pi extensions; the fields are required
181
+ // on the resolved `Contributes` type (zod defaults applied), so set them
182
+ // explicitly.
182
183
  widgets: [],
183
184
  sidebar_sections: [],
184
185
  sidebar_buttons: [],
185
186
  dock_panels: [],
187
+ data_categories: [],
188
+ pi_extensions: [],
186
189
  };
187
190
  // Ship each inline tool as a `kind:"tool"` runnable (Core's `inline_deno`
188
191
  // backend). Shipping tools requires the `tool:execute` grant; add it once.