@dbx-tools/appkit-mastra 0.6.5 → 0.6.7

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/agents.ts CHANGED
@@ -153,10 +153,29 @@ function deriveToolId(description: string): string {
153
153
  * type inference and to match the AppKit API surface.
154
154
  */
155
155
  export function createAgent<T extends MastraAgentDefinition>(def: T): T {
156
- const workspace = def.workspace ?? createWorkspace();
156
+ if (def.workspace) return { ...def };
157
+ const workspace = createWorkspace();
158
+ markDefaultWorkspace(workspace);
157
159
  return { ...def, workspace };
158
160
  }
159
161
 
162
+ /**
163
+ * Brand for a {@link Workspace} that `createAgent` built with no caller
164
+ * options, so {@link buildAgents} may rebuild it with startup-provisioned
165
+ * `extraSkillPaths` (a caller-supplied workspace is never touched).
166
+ */
167
+ const DEFAULT_WORKSPACE = Symbol.for("dbx-tools/appkit-mastra/default-workspace");
168
+
169
+ /** Brand `workspace` as the auto-created default. */
170
+ function markDefaultWorkspace(workspace: Workspace): void {
171
+ (workspace as unknown as Record<symbol, boolean>)[DEFAULT_WORKSPACE] = true;
172
+ }
173
+
174
+ /** Whether `workspace` was auto-created by {@link createAgent}. */
175
+ function isDefaultWorkspace(workspace: Workspace | undefined): boolean {
176
+ return Boolean(workspace && (workspace as unknown as Record<symbol, boolean>)[DEFAULT_WORKSPACE]);
177
+ }
178
+
160
179
  /**
161
180
  * Filter / rename options accepted by every plugin's `.toolkit()`
162
181
  * method. Mirrors AppKit's `ToolkitOptions` verbatim so options pass
@@ -449,8 +468,14 @@ export async function buildAgents(opts: {
449
468
  context: plugin.PluginContextLike | undefined;
450
469
  memoryBuilder?: MemoryBuilder;
451
470
  log: log.Logger;
471
+ /**
472
+ * Local skill scan paths (remote skills provisioned to a temp dir at
473
+ * startup) folded into every agent that uses the auto-created default
474
+ * workspace.
475
+ */
476
+ extraSkillPaths?: string[];
452
477
  }): Promise<BuiltAgents> {
453
- const { config, context, memoryBuilder, log } = opts;
478
+ const { config, context, memoryBuilder, log, extraSkillPaths } = opts;
454
479
  const definitions = resolveDefinitions(config);
455
480
  const ids = Object.keys(definitions);
456
481
  const defaultAgentId = config.defaultAgent ?? ids[0] ?? FALLBACK_AGENT_ID;
@@ -485,7 +510,11 @@ export async function buildAgents(opts: {
485
510
 
486
511
  for (const [id, def] of Object.entries(definitions)) {
487
512
  const tools = await resolveTools(def.tools, plugins, ambientTools);
488
- const workspace = resolveAgentWorkspace(def.workspace);
513
+ let workspace = resolveAgentWorkspace(def.workspace);
514
+ if (extraSkillPaths?.length && isDefaultWorkspace(workspace)) {
515
+ workspace = createWorkspace({ extraSkillPaths });
516
+ markDefaultWorkspace(workspace);
517
+ }
489
518
  const gated = approvalGatedToolIds(tools);
490
519
  if (gated.length > 0) approvalGatedByAgent.push({ agentId: id, toolIds: gated });
491
520
  const memory = memoryBuilder?.forAgent(id, def);
package/src/config.ts CHANGED
@@ -25,6 +25,7 @@ import type { PgVectorConfig, PostgresStoreConfig } from "@mastra/pg";
25
25
  import type { MastraAgentDefinition, MastraTools } from "./agents.ts";
26
26
  import type { GenieSpacesConfig } from "./genie.ts";
27
27
  import type { MastraIdentityMode } from "./identity.ts";
28
+ import type { RemoteSkillsOption } from "./remote-skills.ts";
28
29
 
29
30
  /**
30
31
  * `RequestContext` key under which {@link MastraServer} stores the
@@ -552,6 +553,33 @@ export interface MastraPluginConfig extends BasePluginConfig {
552
553
  * source.
553
554
  */
554
555
  brand?: BrandContext;
556
+ /**
557
+ * Remote Agent-Skill sources materialized once at app startup. A single
558
+ * source, a list, or a `{ sources, failOnError?, ... }` bag.
559
+ *
560
+ * Each source is a GitHub `owner/repo`, a git / GitLab URL, or a direct
561
+ * `SKILL.md` / archive download URL. Resolution prefers the OPTIONAL `skills`
562
+ * npm CLI (a peer dep) which is copied into a temp dir and then persisted;
563
+ * without it, a URL source is fetched directly. A source that resolves
564
+ * through neither fails startup unless `failOnError: false`.
565
+ *
566
+ * Provisioned skills are written to the Databricks user's Assistant skills
567
+ * folder (`/Users/<email>/.assistant/skills`, the "save this as a skill"
568
+ * target) so they persist and are picked up by the built-in Assistant-skills
569
+ * mount. With no writable workspace, they go to a local temp dir instead.
570
+ *
571
+ * @example
572
+ * ```ts
573
+ * mastra({ remoteSkills: "vercel-labs/agent-skills" });
574
+ * mastra({
575
+ * remoteSkills: {
576
+ * sources: ["vercel-labs/agent-skills", "https://example.com/skill.md"],
577
+ * failOnError: false,
578
+ * },
579
+ * });
580
+ * ```
581
+ */
582
+ remoteSkills?: RemoteSkillsOption;
555
583
  }
556
584
 
557
585
  /**
@@ -570,6 +598,11 @@ export const MASTRA_CONFIG_SCHEMA: ConfigSchema = {
570
598
  type: "string",
571
599
  description: 'Mastra OpenAI-compatible provider id. Defaults to "databricks".',
572
600
  },
601
+ remoteSkills: {
602
+ type: ["string", "array", "object"],
603
+ description:
604
+ "Remote Agent-Skill sources provisioned at startup (GitHub owner/repo, git/GitLab URL, or a direct SKILL.md/archive URL). Prefers the optional `skills` npm CLI, else a direct fetch; fails startup on error unless failOnError:false. Written to the Databricks user Assistant skills folder, or a local temp dir when no workspace is writable.",
605
+ },
573
606
  storage: {
574
607
  type: ["boolean", "object"],
575
608
  description:
package/src/plugin.ts CHANGED
@@ -96,6 +96,7 @@ import { buildMcpServer, type ResolvedMcp } from "./mcp.ts";
96
96
  import { createMemoryBuilder, createServicePrincipalPool, needsLakebase } from "./memory.ts";
97
97
  import { logFeedback, resolveFeedbackEnabled } from "./mlflow.ts";
98
98
  import { buildObservability } from "./observability.ts";
99
+ import { provisionRemoteSkills } from "./remote-skills.ts";
99
100
  import {
100
101
  attachRoutePatchMiddleware,
101
102
  createRequestContext,
@@ -996,11 +997,26 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
996
997
  // distinct user identities; the `asUser(req)` scope around
997
998
  // `handleChat` is what lets `getExecutionContext()` return the
998
999
  // right user inside the resolver.
1000
+ // Materialize any remote Agent-Skill sources ONCE at startup. Runs
1001
+ // outside a request scope, so it uses the app service principal's client
1002
+ // and defaults to the shared workspace Assistant skills tree; a source
1003
+ // that lands on a local temp dir (no writable workspace) is folded into
1004
+ // every default-workspace agent via `extraSkillPaths`.
1005
+ const provisioned = await provisionRemoteSkills(this.config.remoteSkills);
1006
+ if (provisioned.skillNames.length > 0) {
1007
+ this.logger.info("remote skills provisioned", {
1008
+ skills: provisioned.skillNames,
1009
+ databricksBasePath: provisioned.databricksBasePath,
1010
+ localSkillPaths: provisioned.localSkillPaths.length,
1011
+ });
1012
+ }
1013
+
999
1014
  this.built = await buildAgents({
1000
1015
  config: this.config,
1001
1016
  context: this.context,
1002
1017
  memoryBuilder,
1003
1018
  log: this.logger,
1019
+ extraSkillPaths: provisioned.localSkillPaths,
1004
1020
  });
1005
1021
 
1006
1022
  // `mastra.server.apiRoutes` is only honored by Mastra's standalone
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Startup provisioning of remote Agent-Skill sources for a Mastra workspace.
3
+ *
4
+ * A {@link RemoteSkillSource} names WHERE a `SKILL.md` tree comes from - a
5
+ * GitHub `owner/repo`, any git / GitLab URL, or a direct download URL - and
6
+ * optional per-source policy. {@link provisionRemoteSkills} materializes every
7
+ * source into a local `SKILL.md` tree at app boot and returns the directories
8
+ * to hand Mastra as extra skill scan paths.
9
+ *
10
+ * Resolution per source, in order:
11
+ *
12
+ * 1. the OPTIONAL `skills` npm CLI (peer dep): if installed, each source is
13
+ * copied into a staging dir with `skills add <source> --agent <dir> --copy`,
14
+ * which understands every source format the ecosystem does (GitHub
15
+ * shorthand, git URLs, archive/download URLs);
16
+ * 2. otherwise a plain {@link fetch} of the source URL (built with
17
+ * {@link net.urlBuilder}), writing the downloaded `SKILL.md` to a staging
18
+ * dir.
19
+ *
20
+ * A source that resolves through neither path fails app startup, unless the
21
+ * source (or the top-level call) sets `failOnError: false`, in which case it is
22
+ * logged and skipped so one bad source never takes the app down.
23
+ *
24
+ * The default destination is the Databricks workspace Assistant skills tree
25
+ * (`/Workspace/.assistant/skills`, the same tree a "save this as a skill"
26
+ * action writes to), so provisioned skills persist across restarts and are
27
+ * discovered by the built-in Assistant-skills mount. Pass `userEmail` (or an
28
+ * explicit `databricksBasePath`) to target `/Users/<email>/.assistant/skills`
29
+ * instead. When no Databricks client is resolvable at startup, the tree is
30
+ * written to a local temp dir and returned as an extra local skill path for
31
+ * the current process.
32
+ *
33
+ * @module
34
+ */
35
+
36
+ import { mkdtemp, cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
37
+ import { createRequire } from "node:module";
38
+ import { tmpdir } from "node:os";
39
+ import { join, posix } from "node:path";
40
+ import { appkit } from "@dbx-tools/appkit";
41
+ import type { WorkspaceClientLike } from "@dbx-tools/appkit";
42
+ import { spawn } from "@dbx-tools/core";
43
+ import { findFiles } from "@dbx-tools/path";
44
+ import { error, log, net, string } from "@dbx-tools/shared-core";
45
+
46
+ import { DatabricksWorkspaceFilesystem } from "./filesystems.ts";
47
+
48
+ const logger = log.logger("mastra/remote-skills");
49
+
50
+ /** Shared Assistant skills tree in the Databricks workspace (default target). */
51
+ const ASSISTANT_SHARED_SKILLS_PATH = "/Workspace/.assistant/skills";
52
+
53
+ /** Assistant skills directory for a specific Databricks user. */
54
+ function userAssistantSkillsPath(userEmail: string): string {
55
+ return `/Users/${userEmail.trim()}/.assistant/skills`;
56
+ }
57
+
58
+ /** npm agent id the `skills` CLI installs a bare `SKILL.md` tree under. */
59
+ const SKILLS_CLI_AGENT = "agents";
60
+
61
+ /** Where the `skills` CLI drops a plain `SKILL.md` tree under a target dir. */
62
+ const SKILLS_CLI_LAYOUT = [".agents", "skills"] as const;
63
+
64
+ /** Cap on a direct-fetch download body, matching the `skills` CLI default. */
65
+ const DEFAULT_MAX_DOWNLOAD_BYTES = 10 * 1024 * 1024;
66
+
67
+ /* -------------------------------- types -------------------------------- */
68
+
69
+ /**
70
+ * One remote skill source and its per-source policy.
71
+ *
72
+ * `source` is anything the `skills` ecosystem understands: a GitHub
73
+ * `owner/repo` shorthand, a full GitHub / GitLab / git URL, or a direct
74
+ * download URL to a `SKILL.md` or archive.
75
+ */
76
+ export interface RemoteSkillSourceOptions {
77
+ /** GitHub shorthand, git / GitLab URL, or a direct download URL. */
78
+ source: string;
79
+ /** Install only these skill names from the source (CLI path only). */
80
+ skills?: string | string[];
81
+ /** Override the byte ceiling on a direct-fetch download for this source. */
82
+ maxDownloadBytes?: number;
83
+ /**
84
+ * When `false`, a source that fails to resolve is logged and skipped instead
85
+ * of failing app startup. Overrides the top-level {@link ProvisionRemoteSkillsOptions.failOnError}.
86
+ */
87
+ failOnError?: boolean;
88
+ }
89
+
90
+ /**
91
+ * A remote skill source: a bare source string (GitHub shorthand / URL) or a
92
+ * {@link RemoteSkillSourceOptions} with per-source policy.
93
+ */
94
+ export type RemoteSkillSource = string | RemoteSkillSourceOptions;
95
+
96
+ /**
97
+ * The workspace `remoteSkills` option. A single source, a list, or a
98
+ * {@link ProvisionRemoteSkillsOptions} bag when top-level policy is needed.
99
+ */
100
+ export type RemoteSkillsOption =
101
+ RemoteSkillSource | RemoteSkillSource[] | ProvisionRemoteSkillsOptions;
102
+
103
+ /** Top-level remote-skills provisioning options. */
104
+ export interface ProvisionRemoteSkillsOptions {
105
+ /** Sources to materialize at startup. */
106
+ sources: RemoteSkillSource | RemoteSkillSource[];
107
+ /**
108
+ * Fail app startup when a source can't be resolved. Defaults to `true`. A
109
+ * per-source `failOnError` wins over this.
110
+ */
111
+ failOnError?: boolean;
112
+ /**
113
+ * Absolute Databricks path that roots the destination Assistant skills tree.
114
+ * Defaults to the OBO user's `/Users/<email>/.assistant/skills`.
115
+ */
116
+ databricksBasePath?: string;
117
+ /** Auth-scoped Databricks client. Defaults to the AppKit execution context. */
118
+ client?: WorkspaceClientLike;
119
+ /** User email used to derive the default Databricks destination. */
120
+ userEmail?: string;
121
+ /** Byte ceiling on a direct-fetch download. Defaults to 10 MiB. */
122
+ maxDownloadBytes?: number;
123
+ }
124
+
125
+ /** What {@link provisionRemoteSkills} resolved. */
126
+ export interface ProvisionedRemoteSkills {
127
+ /**
128
+ * Extra LOCAL skill scan paths to hand Mastra (a temp dir per source that
129
+ * couldn't be written to Databricks). Empty when everything landed in the
130
+ * Databricks Assistant tree, which the built-in mount already scans.
131
+ */
132
+ localSkillPaths: string[];
133
+ /** Absolute Databricks destination each source was written to, if any. */
134
+ databricksBasePath?: string;
135
+ /** Names of every skill directory that was provisioned. */
136
+ skillNames: string[];
137
+ }
138
+
139
+ /* ------------------------------- helpers ------------------------------- */
140
+
141
+ /** Normalize the `remoteSkills` option into a flat options bag. */
142
+ export function normalizeRemoteSkillsOption(
143
+ option: RemoteSkillsOption | undefined,
144
+ ): ProvisionRemoteSkillsOptions | undefined {
145
+ if (option === undefined) return undefined;
146
+ if (typeof option === "string") return { sources: [option] };
147
+ if (Array.isArray(option)) return option.length > 0 ? { sources: option } : undefined;
148
+ if (isProvisionOptions(option)) {
149
+ const sources = Array.isArray(option.sources) ? option.sources : [option.sources];
150
+ return sources.length > 0 ? { ...option, sources } : undefined;
151
+ }
152
+ // A lone RemoteSkillSourceOptions object.
153
+ return { sources: [option] };
154
+ }
155
+
156
+ /** A `sources`-bearing bag is the top-level options shape, not a single source. */
157
+ function isProvisionOptions(
158
+ value: RemoteSkillSourceOptions | ProvisionRemoteSkillsOptions,
159
+ ): value is ProvisionRemoteSkillsOptions {
160
+ return "sources" in value;
161
+ }
162
+
163
+ /** Coerce a source entry to its normalized {@link RemoteSkillSourceOptions}. */
164
+ function toSourceOptions(source: RemoteSkillSource): RemoteSkillSourceOptions {
165
+ return typeof source === "string" ? { source } : source;
166
+ }
167
+
168
+ /**
169
+ * Materialize every configured remote skill source at app startup.
170
+ *
171
+ * Writes each resolved `SKILL.md` tree to the Databricks user Assistant skills
172
+ * folder when a writable workspace is available, else to a local temp dir
173
+ * returned in {@link ProvisionedRemoteSkills.localSkillPaths}.
174
+ */
175
+ export async function provisionRemoteSkills(
176
+ option: RemoteSkillsOption | undefined,
177
+ ): Promise<ProvisionedRemoteSkills> {
178
+ const options = normalizeRemoteSkillsOption(option);
179
+ const empty: ProvisionedRemoteSkills = { localSkillPaths: [], skillNames: [] };
180
+ if (!options) return empty;
181
+
182
+ const failDefault = options.failOnError !== false;
183
+ const client = options.client ?? appkit.tryGetExecutionContext()?.client;
184
+ const databricksBasePath = resolveDatabricksBasePath(options, client);
185
+ const destination = databricksBasePath
186
+ ? new DatabricksWorkspaceFilesystem({ client, basePath: databricksBasePath, readOnly: false })
187
+ : undefined;
188
+
189
+ const localSkillPaths: string[] = [];
190
+ const skillNames: string[] = [];
191
+ let staging: string | undefined;
192
+
193
+ try {
194
+ const sources = Array.isArray(options.sources) ? options.sources : [options.sources];
195
+ for (const entry of sources) {
196
+ const sourceOptions = toSourceOptions(entry);
197
+ const failOnError = sourceOptions.failOnError ?? failDefault;
198
+ try {
199
+ staging ??= await mkdtemp(join(tmpdir(), "mastra-remote-skills-"));
200
+ const stagedDir = await stageSource(sourceOptions, staging, options);
201
+ const staged = await collectSkillDirs(stagedDir);
202
+ if (staged.length === 0) {
203
+ throw new Error(`no SKILL.md found for source "${sourceOptions.source}"`);
204
+ }
205
+ if (destination && databricksBasePath) {
206
+ await uploadSkillDirs(destination, staged);
207
+ skillNames.push(...staged.map((dir) => dir.name));
208
+ } else {
209
+ const localDir = await persistLocally(staged);
210
+ localSkillPaths.push(localDir);
211
+ skillNames.push(...staged.map((dir) => dir.name));
212
+ }
213
+ logger.debug("source:provisioned", {
214
+ source: sourceOptions.source,
215
+ destination: databricksBasePath ?? "local-temp",
216
+ skills: staged.map((dir) => dir.name),
217
+ });
218
+ } catch (err) {
219
+ if (failOnError) {
220
+ throw new Error(
221
+ `failed to provision remote skill source "${sourceOptions.source}": ${error.errorMessage(err)}`,
222
+ { cause: error.toError(err) },
223
+ );
224
+ }
225
+ logger.warn("source:skipped", {
226
+ source: sourceOptions.source,
227
+ error: error.errorMessage(err),
228
+ });
229
+ }
230
+ }
231
+ } finally {
232
+ if (staging) await rm(staging, { recursive: true, force: true }).catch(() => undefined);
233
+ }
234
+
235
+ return { localSkillPaths, databricksBasePath, skillNames };
236
+ }
237
+
238
+ /** Resolve the Databricks Assistant skills destination, or `undefined` for local temp. */
239
+ function resolveDatabricksBasePath(
240
+ options: ProvisionRemoteSkillsOptions,
241
+ client: WorkspaceClientLike | undefined,
242
+ ): string | undefined {
243
+ if (!client) return undefined;
244
+ if (options.databricksBasePath) return options.databricksBasePath.trim() || undefined;
245
+ const email = string.trimToNull(options.userEmail);
246
+ // A named user targets their personal Assistant tree (the "save a skill"
247
+ // target); otherwise the shared workspace Assistant tree, which the built-in
248
+ // Assistant-skills mount already scans.
249
+ return email ? userAssistantSkillsPath(email) : ASSISTANT_SHARED_SKILLS_PATH;
250
+ }
251
+
252
+ /**
253
+ * Stage one source into a fresh dir under `staging`, preferring the optional
254
+ * `skills` CLI and falling back to a direct fetch.
255
+ */
256
+ async function stageSource(
257
+ sourceOptions: RemoteSkillSourceOptions,
258
+ staging: string,
259
+ options: ProvisionRemoteSkillsOptions,
260
+ ): Promise<string> {
261
+ const target = await mkdtemp(join(staging, "src-"));
262
+ const viaCli = await stageViaSkillsCli(sourceOptions, target);
263
+ if (viaCli) return viaCli;
264
+ return stageViaFetch(sourceOptions, target, options);
265
+ }
266
+
267
+ /**
268
+ * Copy a source into `target` with the `skills` CLI when it is installed.
269
+ * Returns the dir holding the copied `SKILL.md` trees, or `undefined` when the
270
+ * CLI is absent (so the caller can fall back to a fetch).
271
+ */
272
+ async function stageViaSkillsCli(
273
+ sourceOptions: RemoteSkillSourceOptions,
274
+ target: string,
275
+ ): Promise<string | undefined> {
276
+ const cli = await resolveSkillsCli();
277
+ if (!cli) return undefined;
278
+
279
+ const args = [
280
+ ...cli.args,
281
+ "add",
282
+ sourceOptions.source,
283
+ "--agent",
284
+ SKILLS_CLI_AGENT,
285
+ "--copy",
286
+ "-y",
287
+ ];
288
+ for (const skill of string.parseList(sourceOptions.skills)) {
289
+ args.push("--skill", skill);
290
+ }
291
+ if (string.parseList(sourceOptions.skills).length === 0) args.push("--skill", "*");
292
+
293
+ const result = await spawn(cli.command, args, {
294
+ cwd: target,
295
+ stdout: "capture",
296
+ stderr: "capture",
297
+ check: false,
298
+ });
299
+ if (result.exitCode !== 0) {
300
+ throw new Error(
301
+ `skills CLI failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`,
302
+ );
303
+ }
304
+ return join(target, ...SKILLS_CLI_LAYOUT);
305
+ }
306
+
307
+ /**
308
+ * Locate the optional `skills` CLI bin, resolved from THIS package's module
309
+ * graph. Returns `undefined` when the peer dep isn't installed, so the caller
310
+ * falls back to a direct fetch.
311
+ */
312
+ async function resolveSkillsCli(): Promise<{ command: string; args: string[] } | undefined> {
313
+ try {
314
+ const require = createRequire(import.meta.url);
315
+ const pkgPath = require.resolve("skills/package.json");
316
+ const bin = join(pkgPath, "..", "bin", "cli.mjs");
317
+ if (await pathExists(bin)) return { command: process.execPath, args: [bin] };
318
+ } catch {
319
+ // peer dep absent or unresolvable - fetch fallback handles it
320
+ }
321
+ return undefined;
322
+ }
323
+
324
+ /**
325
+ * Fetch a source URL and write the downloaded `SKILL.md` under `target`.
326
+ * Used when the `skills` CLI isn't installed; supports a direct `SKILL.md` URL.
327
+ */
328
+ async function stageViaFetch(
329
+ sourceOptions: RemoteSkillSourceOptions,
330
+ target: string,
331
+ options: ProvisionRemoteSkillsOptions,
332
+ ): Promise<string> {
333
+ const url = net.urlBuilder(sourceOptions.source);
334
+ if (!url) {
335
+ throw new Error(
336
+ `source "${sourceOptions.source}" is not a URL and the optional "skills" package is not installed`,
337
+ );
338
+ }
339
+ const maxBytes =
340
+ sourceOptions.maxDownloadBytes ?? options.maxDownloadBytes ?? DEFAULT_MAX_DOWNLOAD_BYTES;
341
+ const response = await fetch(url.toString());
342
+ if (!response.ok) {
343
+ throw new Error(
344
+ `download failed: ${response.status} ${response.statusText} (${url.toString()})`,
345
+ );
346
+ }
347
+ const buffer = Buffer.from(await response.arrayBuffer());
348
+ if (buffer.byteLength > maxBytes) {
349
+ throw new Error(`download exceeds ${maxBytes} bytes (${url.toString()})`);
350
+ }
351
+ const name = string.toSlug(deriveSkillName(url.toString())) || "remote-skill";
352
+ const skillDir = join(target, name);
353
+ await mkdir(skillDir, { recursive: true });
354
+ await writeFile(join(skillDir, "SKILL.md"), buffer);
355
+ return target;
356
+ }
357
+
358
+ /** Derive a skill directory name from a download URL's last path segment. */
359
+ function deriveSkillName(urlString: string): string {
360
+ const url = net.urlBuilder(urlString);
361
+ const last = url?.pathname.split("/").filter(Boolean).pop() ?? "";
362
+ return last.replace(/\.(md|zip|tar|tgz|gz)$/i, "");
363
+ }
364
+
365
+ /** A skill directory (holding a `SKILL.md`) staged on local disk. */
366
+ interface StagedSkillDir {
367
+ name: string;
368
+ absolutePath: string;
369
+ }
370
+
371
+ /**
372
+ * Collect every skill directory under `root`: a `root/SKILL.md` (root IS the
373
+ * skill) or each immediate child dir that contains a `SKILL.md`.
374
+ */
375
+ async function collectSkillDirs(root: string): Promise<StagedSkillDir[]> {
376
+ if (!(await pathExists(root))) return [];
377
+ if (await pathExists(join(root, "SKILL.md"))) {
378
+ const name = posix.basename(root.split(/[\\/]/).join("/"));
379
+ return [{ name, absolutePath: root }];
380
+ }
381
+ const dirs: StagedSkillDir[] = [];
382
+ for (const entry of await readdir(root, { withFileTypes: true })) {
383
+ if (!entry.isDirectory()) continue;
384
+ const dir = join(root, entry.name);
385
+ if (await pathExists(join(dir, "SKILL.md"))) {
386
+ dirs.push({ name: entry.name, absolutePath: dir });
387
+ }
388
+ }
389
+ return dirs;
390
+ }
391
+
392
+ /** Upload each staged skill directory into the Databricks destination tree. */
393
+ async function uploadSkillDirs(
394
+ destination: DatabricksWorkspaceFilesystem,
395
+ dirs: StagedSkillDir[],
396
+ ): Promise<void> {
397
+ await destination.init?.();
398
+ for (const dir of dirs) {
399
+ for (const relative of findFiles("**/*", { cwd: dir.absolutePath, nodir: true })) {
400
+ const buffer = await readFile(join(dir.absolutePath, relative));
401
+ const remotePath = posix.join("/", dir.name, relative.split(/[\\/]/).join("/"));
402
+ await destination.writeFile(remotePath, buffer, { overwrite: true });
403
+ }
404
+ }
405
+ }
406
+
407
+ /** Copy staged skill dirs into a persistent local temp dir; return its path. */
408
+ async function persistLocally(dirs: StagedSkillDir[]): Promise<string> {
409
+ const base = await mkdtemp(join(tmpdir(), "mastra-local-skills-"));
410
+ for (const dir of dirs) {
411
+ await cp(dir.absolutePath, join(base, dir.name), { recursive: true });
412
+ }
413
+ return base;
414
+ }
415
+
416
+ /** Best-effort existence check. */
417
+ async function pathExists(path: string): Promise<boolean> {
418
+ try {
419
+ await stat(path);
420
+ return true;
421
+ } catch {
422
+ return false;
423
+ }
424
+ }
package/src/workspaces.ts CHANGED
@@ -78,6 +78,14 @@ export interface CreateWorkspaceOptions {
78
78
  checkSkillFileMtime?: boolean;
79
79
  /** Enable BM25 keyword search over indexed workspace content. */
80
80
  bm25?: boolean;
81
+ /**
82
+ * Extra LOCAL skill scan paths added to every request's skill discovery.
83
+ * Used by the plugin to surface remote skills provisioned to a local temp
84
+ * dir at startup (see `remote-skills.ts`). Databricks-hosted remote skills
85
+ * need no entry here - they land in the Assistant tree the built-in mount
86
+ * already scans.
87
+ */
88
+ extraSkillPaths?: string[];
81
89
  }
82
90
 
83
91
  /**
@@ -104,8 +112,12 @@ export interface CreateWorkspaceOptions {
104
112
  export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace {
105
113
  const { id, name } = resolveWorkspaceIdentity(options);
106
114
  const resolvers = buildMountResolvers(options);
115
+ const extraSkillPaths = options.extraSkillPaths ?? [];
107
116
  const skills =
108
- options.skills ?? (resolvers.length > 0 ? buildWorkspaceSkillsResolver(resolvers) : undefined);
117
+ options.skills ??
118
+ (resolvers.length > 0 || extraSkillPaths.length > 0
119
+ ? buildWorkspaceSkillsResolver(resolvers, extraSkillPaths)
120
+ : undefined);
109
121
  const checkSkillFileMtime = options.checkSkillFileMtime ?? options.assistantSkills !== false;
110
122
  const bm25 = options.bm25 !== false;
111
123
  logger.debug("workspace:create", {
@@ -117,6 +129,7 @@ export function createWorkspace(options: CreateWorkspaceOptions = {}): Workspace
117
129
  customSkillsResolver: Boolean(options.skills),
118
130
  checkSkillFileMtime,
119
131
  bm25,
132
+ extraSkillPaths: extraSkillPaths.length,
120
133
  });
121
134
 
122
135
  return new Workspace({
@@ -339,10 +352,14 @@ async function resolveWorkspaceFilesystem(
339
352
  * Build the dynamic {@link SkillsResolver} that collects `skillPaths` from
340
353
  * every mount resolver on each request.
341
354
  */
342
- function buildWorkspaceSkillsResolver(resolvers: WorkspaceMountResolver[]): SkillsResolver {
355
+ function buildWorkspaceSkillsResolver(
356
+ resolvers: WorkspaceMountResolver[],
357
+ extraSkillPaths: string[] = [],
358
+ ): SkillsResolver {
343
359
  return async (context: SkillsContext) => {
344
360
  const { skillPaths } = await resolveWorkspaceContribution(resolvers, context);
345
- logger.debug("skills:resolved", { skillPaths });
346
- return skillPaths ?? [];
361
+ const merged = [...(skillPaths ?? []), ...extraSkillPaths];
362
+ logger.debug("skills:resolved", { skillPaths, extraSkillPaths });
363
+ return merged;
347
364
  };
348
365
  }