@dbx-tools/appkit-mastra 0.6.9 → 0.6.11
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 +33 -35
- package/index.ts +14 -30
- package/lib/index.d.ts +14 -30
- package/lib/index.js +13 -28
- package/lib/src/config.d.ts +0 -22
- package/lib/src/config.js +2 -6
- package/lib/src/filesystems.d.ts +52 -170
- package/lib/src/filesystems.js +168 -888
- package/lib/src/plugin.js +2 -14
- package/lib/src/remote-skills.d.ts +45 -24
- package/lib/src/remote-skills.js +188 -65
- package/lib/src/workspaces.d.ts +4 -0
- package/lib/src/workspaces.js +37 -13
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +13 -10
- package/src/config.ts +1 -29
- package/src/filesystems.ts +225 -970
- package/src/plugin.ts +1 -14
- package/src/remote-skills.ts +259 -89
- package/src/workspaces.ts +41 -16
- package/lib/src/databricks-aitools.d.ts +0 -83
- package/lib/src/databricks-aitools.js +0 -150
- package/src/databricks-aitools.ts +0 -216
package/src/plugin.ts
CHANGED
|
@@ -97,7 +97,6 @@ import { createMemoryBuilder, createServicePrincipalPool, needsLakebase } from "
|
|
|
97
97
|
import { logFeedback, resolveFeedbackEnabled } from "./mlflow.ts";
|
|
98
98
|
import { buildObservability } from "./observability.ts";
|
|
99
99
|
import { provisionRemoteSkills } from "./remote-skills.ts";
|
|
100
|
-
import { provisionDatabricksAITools } from "./databricks-aitools.ts";
|
|
101
100
|
import {
|
|
102
101
|
attachRoutePatchMiddleware,
|
|
103
102
|
createRequestContext,
|
|
@@ -1012,24 +1011,12 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
1012
1011
|
});
|
|
1013
1012
|
}
|
|
1014
1013
|
|
|
1015
|
-
// Fold Databricks AI Tools skills (`databricks aitools`) in as extra local
|
|
1016
|
-
// skill scan paths: reuse the CLI's installed tree when present, else shell
|
|
1017
|
-
// out to the CLI to materialize a curated set.
|
|
1018
|
-
const aiTools = await provisionDatabricksAITools(this.config.databricksAITools);
|
|
1019
|
-
if (aiTools.localSkillPaths.length > 0) {
|
|
1020
|
-
this.logger.info("databricks ai tools provisioned", {
|
|
1021
|
-
source: aiTools.source,
|
|
1022
|
-
localSkillPaths: aiTools.localSkillPaths.length,
|
|
1023
|
-
});
|
|
1024
|
-
}
|
|
1025
|
-
const extraSkillPaths = [...provisioned.localSkillPaths, ...aiTools.localSkillPaths];
|
|
1026
|
-
|
|
1027
1014
|
this.built = await buildAgents({
|
|
1028
1015
|
config: this.config,
|
|
1029
1016
|
context: this.context,
|
|
1030
1017
|
memoryBuilder,
|
|
1031
1018
|
log: this.logger,
|
|
1032
|
-
extraSkillPaths,
|
|
1019
|
+
extraSkillPaths: provisioned.localSkillPaths,
|
|
1033
1020
|
});
|
|
1034
1021
|
|
|
1035
1022
|
// `mastra.server.apiRoutes` is only honored by Mastra's standalone
|
package/src/remote-skills.ts
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Startup provisioning of remote Agent-Skill sources for a Mastra workspace.
|
|
3
3
|
*
|
|
4
|
-
* A {@link RemoteSkillSource} names WHERE a `SKILL.md` tree comes from -
|
|
5
|
-
* GitHub `owner/repo`, any git / GitLab URL,
|
|
6
|
-
* optional per-source policy.
|
|
7
|
-
* source into a local
|
|
8
|
-
* to hand Mastra as
|
|
4
|
+
* A {@link RemoteSkillSource} names WHERE a `SKILL.md` tree comes from - the
|
|
5
|
+
* {@link AITOOLS_SOURCE} constant, a GitHub `owner/repo`, any git / GitLab URL,
|
|
6
|
+
* or a direct download URL - and optional per-source policy.
|
|
7
|
+
* {@link provisionRemoteSkills} materializes every source into a local
|
|
8
|
+
* `SKILL.md` tree at app boot and returns the directories to hand Mastra as
|
|
9
|
+
* extra skill scan paths.
|
|
9
10
|
*
|
|
10
11
|
* Resolution per source, in order:
|
|
11
12
|
*
|
|
12
|
-
* 1. the
|
|
13
|
+
* 1. the {@link AITOOLS_SOURCE} constant (`"aitools"`): Databricks' own skill
|
|
14
|
+
* set, read straight from the public repo the `databricks aitools` CLI
|
|
15
|
+
* sources from. No CLI, no Databricks auth - which is what makes it usable
|
|
16
|
+
* inside a Databricks App container, where the CLI is not installed;
|
|
17
|
+
* 2. the OPTIONAL `skills` npm CLI (peer dep): if installed, each source is
|
|
13
18
|
* copied into a staging dir with `skills add <source> --agent <dir> --copy`,
|
|
14
19
|
* which understands every source format the ecosystem does (GitHub
|
|
15
20
|
* shorthand, git URLs, archive/download URLs);
|
|
16
|
-
*
|
|
21
|
+
* 3. otherwise a plain {@link fetch} of the source URL (built with
|
|
17
22
|
* {@link net.urlBuilder}), writing the downloaded `SKILL.md` to a staging
|
|
18
23
|
* dir.
|
|
19
24
|
*
|
|
@@ -27,23 +32,25 @@
|
|
|
27
32
|
* discovered by the built-in Assistant-skills mount. Pass `userEmail` (or an
|
|
28
33
|
* explicit `databricksBasePath`) to target `/Users/<email>/.assistant/skills`
|
|
29
34
|
* instead. When no Databricks client is resolvable at startup, the tree is
|
|
30
|
-
* written
|
|
31
|
-
* the current process.
|
|
35
|
+
* written under {@link localFS.tmpFS} and returned as an extra local skill path
|
|
36
|
+
* for the current process.
|
|
32
37
|
*
|
|
33
38
|
* @module
|
|
34
39
|
*/
|
|
35
40
|
|
|
36
|
-
import {
|
|
41
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
37
42
|
import { createRequire } from "node:module";
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
43
|
+
import { dirname, join, posix } from "node:path";
|
|
44
|
+
import type { WorkspaceClient } from "@databricks/sdk-experimental";
|
|
40
45
|
import { appkit } from "@dbx-tools/appkit";
|
|
41
46
|
import type { WorkspaceClientLike } from "@dbx-tools/appkit";
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
45
|
-
|
|
46
|
-
import {
|
|
47
|
+
import { exec } from "@dbx-tools/core";
|
|
48
|
+
import { DatabricksFileSystem } from "@dbx-tools/databricks";
|
|
49
|
+
import { localFS, type LocalFileSystem } from "@dbx-tools/fs";
|
|
50
|
+
import { find } from "@dbx-tools/path";
|
|
51
|
+
import { error, hash, json, log, net, object, string } from "@dbx-tools/shared-core";
|
|
52
|
+
import type { OneOrMany } from "@dbx-tools/shared-core";
|
|
53
|
+
import type { FileSystem } from "@dbx-tools/shared-fs";
|
|
47
54
|
|
|
48
55
|
const logger = log.logger("mastra/remote-skills");
|
|
49
56
|
|
|
@@ -64,20 +71,58 @@ const SKILLS_CLI_LAYOUT = [".agents", "skills"] as const;
|
|
|
64
71
|
/** Cap on a direct-fetch download body, matching the `skills` CLI default. */
|
|
65
72
|
const DEFAULT_MAX_DOWNLOAD_BYTES = 10 * 1024 * 1024;
|
|
66
73
|
|
|
74
|
+
/** Stable temp directory holding one rebuilt skill tree per remote source. */
|
|
75
|
+
const LOCAL_SKILLS_DIR = "mastra-local-skills";
|
|
76
|
+
|
|
77
|
+
/* ------------------------------ AI Tools ------------------------------ */
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Source constant selecting Databricks' own AI Tools skill set.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* mastra({ remoteSkills: "aitools" });
|
|
84
|
+
*/
|
|
85
|
+
export const AITOOLS_SOURCE = "aitools";
|
|
86
|
+
|
|
87
|
+
/** The {@link AITOOLS_SOURCE} literal, for callers spelling the union out. */
|
|
88
|
+
export type AiToolsSource = typeof AITOOLS_SOURCE;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The PUBLIC repo `databricks aitools install` sources from. Reading it
|
|
92
|
+
* directly is what lets an app get the same skills the CLI installs without
|
|
93
|
+
* the CLI - which a Databricks App container does not ship.
|
|
94
|
+
*/
|
|
95
|
+
const AITOOLS_REPO = "databricks/databricks-agent-skills";
|
|
96
|
+
|
|
97
|
+
/** Repo ref the skills are read from. Override per source with `ref`. */
|
|
98
|
+
const AITOOLS_REF = "main";
|
|
99
|
+
|
|
100
|
+
/** Skill files downloaded at once; the set is ~30 skills of a few files each. */
|
|
101
|
+
const AITOOLS_CONCURRENCY = 8;
|
|
102
|
+
|
|
103
|
+
/** The subset of the repo's generated `manifest.json` this module reads. */
|
|
104
|
+
interface AiToolsManifest {
|
|
105
|
+
skills?: Record<string, { files?: string[]; repo_dir?: string }>;
|
|
106
|
+
}
|
|
107
|
+
|
|
67
108
|
/* -------------------------------- types -------------------------------- */
|
|
68
109
|
|
|
69
110
|
/**
|
|
70
111
|
* One remote skill source and its per-source policy.
|
|
71
112
|
*
|
|
72
|
-
* `source` is anything the `skills` ecosystem
|
|
73
|
-
* `owner/repo` shorthand, a full GitHub / GitLab / git
|
|
74
|
-
* download URL to a `SKILL.md` or archive.
|
|
113
|
+
* `source` is {@link AITOOLS_SOURCE} or anything the `skills` ecosystem
|
|
114
|
+
* understands: a GitHub `owner/repo` shorthand, a full GitHub / GitLab / git
|
|
115
|
+
* URL, or a direct download URL to a `SKILL.md` or archive.
|
|
75
116
|
*/
|
|
76
117
|
export interface RemoteSkillSourceOptions {
|
|
77
|
-
/** GitHub shorthand, git / GitLab URL, or a
|
|
78
|
-
source:
|
|
79
|
-
/** Install only these skill names from the source
|
|
118
|
+
/** `"aitools"`, a GitHub shorthand, a git / GitLab URL, or a download URL. */
|
|
119
|
+
source: net.UrlLike | AiToolsSource;
|
|
120
|
+
/** Install only these skill names from the source. */
|
|
80
121
|
skills?: string | string[];
|
|
122
|
+
/** Include experimental skills. `"aitools"` only. */
|
|
123
|
+
experimental?: boolean;
|
|
124
|
+
/** Pin the repo ref (tag / branch / sha). `"aitools"` only; defaults to `main`. */
|
|
125
|
+
ref?: string;
|
|
81
126
|
/** Override the byte ceiling on a direct-fetch download for this source. */
|
|
82
127
|
maxDownloadBytes?: number;
|
|
83
128
|
/**
|
|
@@ -88,22 +133,26 @@ export interface RemoteSkillSourceOptions {
|
|
|
88
133
|
}
|
|
89
134
|
|
|
90
135
|
/**
|
|
91
|
-
* A remote skill source:
|
|
92
|
-
* {@link RemoteSkillSourceOptions} with
|
|
136
|
+
* A remote skill source: the {@link AITOOLS_SOURCE} constant, a URL-like
|
|
137
|
+
* (string / `URL` / `{ url }`), or a {@link RemoteSkillSourceOptions} bag with
|
|
138
|
+
* per-source policy.
|
|
93
139
|
*/
|
|
94
|
-
export type RemoteSkillSource =
|
|
140
|
+
export type RemoteSkillSource = net.UrlLike | AiToolsSource | RemoteSkillSourceOptions;
|
|
95
141
|
|
|
96
142
|
/**
|
|
97
|
-
* The workspace `remoteSkills` option
|
|
98
|
-
* {@link ProvisionRemoteSkillsOptions} bag when top-level policy is needed.
|
|
143
|
+
* The workspace `remoteSkills` option: one source, a non-empty list of them, or
|
|
144
|
+
* a {@link ProvisionRemoteSkillsOptions} bag when top-level policy is needed.
|
|
99
145
|
*/
|
|
100
146
|
export type RemoteSkillsOption =
|
|
101
|
-
RemoteSkillSource | RemoteSkillSource
|
|
147
|
+
RemoteSkillSource | OneOrMany<RemoteSkillSource> | ProvisionRemoteSkillsOptions;
|
|
148
|
+
|
|
149
|
+
/** A source entry with its `source` flattened to a plain string. */
|
|
150
|
+
type NormalizedSource = Omit<RemoteSkillSourceOptions, "source"> & { source: string };
|
|
102
151
|
|
|
103
152
|
/** Top-level remote-skills provisioning options. */
|
|
104
153
|
export interface ProvisionRemoteSkillsOptions {
|
|
105
154
|
/** Sources to materialize at startup. */
|
|
106
|
-
sources: RemoteSkillSource | RemoteSkillSource
|
|
155
|
+
sources: RemoteSkillSource | OneOrMany<RemoteSkillSource>;
|
|
107
156
|
/**
|
|
108
157
|
* Fail app startup when a source can't be resolved. Defaults to `true`. A
|
|
109
158
|
* per-source `failOnError` wins over this.
|
|
@@ -143,34 +192,50 @@ export function normalizeRemoteSkillsOption(
|
|
|
143
192
|
option: RemoteSkillsOption | undefined,
|
|
144
193
|
): ProvisionRemoteSkillsOptions | undefined {
|
|
145
194
|
if (option === undefined) return undefined;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
// A lone RemoteSkillSourceOptions object.
|
|
153
|
-
return { sources: [option] };
|
|
195
|
+
const bag = isProvisionOptions(option) ? option : undefined;
|
|
196
|
+
const sources = toSourceList(bag ? bag.sources : (option as RemoteSkillSource));
|
|
197
|
+
// An empty list is the same as no configuration at all.
|
|
198
|
+
if (!object.isOneOrMany(sources)) return undefined;
|
|
199
|
+
return { ...bag, sources };
|
|
154
200
|
}
|
|
155
201
|
|
|
156
202
|
/** A `sources`-bearing bag is the top-level options shape, not a single source. */
|
|
157
|
-
function isProvisionOptions(
|
|
158
|
-
value
|
|
159
|
-
|
|
160
|
-
|
|
203
|
+
function isProvisionOptions(value: unknown): value is ProvisionRemoteSkillsOptions {
|
|
204
|
+
return object.isRecord(value) && "sources" in value;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** One source or many, as a plain array. A source is never itself an array. */
|
|
208
|
+
function toSourceList(
|
|
209
|
+
input: RemoteSkillSource | OneOrMany<RemoteSkillSource>,
|
|
210
|
+
): RemoteSkillSource[] {
|
|
211
|
+
return Array.isArray(input) ? [...input] : [input];
|
|
161
212
|
}
|
|
162
213
|
|
|
163
|
-
/**
|
|
164
|
-
function toSourceOptions(source: RemoteSkillSource):
|
|
165
|
-
|
|
214
|
+
/** Flatten a source entry - string, `URL`, `{ url }`, or options bag - to a {@link NormalizedSource}. */
|
|
215
|
+
function toSourceOptions(source: RemoteSkillSource): NormalizedSource {
|
|
216
|
+
if (typeof source === "string") return { source };
|
|
217
|
+
if (source instanceof URL) return { source: source.toString() };
|
|
218
|
+
if ("source" in source) return { ...source, source: toSourceString(source.source) };
|
|
219
|
+
return { source: source.url };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** A {@link net.UrlLike} as the plain string the staging paths work with. */
|
|
223
|
+
function toSourceString(source: net.UrlLike): string {
|
|
224
|
+
if (typeof source === "string") return source;
|
|
225
|
+
return source instanceof URL ? source.toString() : source.url;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** True when a source selects the built-in Databricks AI Tools skill set. */
|
|
229
|
+
function isAiToolsSource(source: string): boolean {
|
|
230
|
+
return source.trim().toLowerCase() === AITOOLS_SOURCE;
|
|
166
231
|
}
|
|
167
232
|
|
|
168
233
|
/**
|
|
169
234
|
* Materialize every configured remote skill source at app startup.
|
|
170
235
|
*
|
|
171
236
|
* Writes each resolved `SKILL.md` tree to the Databricks user Assistant skills
|
|
172
|
-
* folder when a writable workspace is available, else
|
|
173
|
-
* returned in {@link ProvisionedRemoteSkills.localSkillPaths}.
|
|
237
|
+
* folder when a writable workspace is available, else under
|
|
238
|
+
* {@link localFS.tmpFS} returned in {@link ProvisionedRemoteSkills.localSkillPaths}.
|
|
174
239
|
*/
|
|
175
240
|
export async function provisionRemoteSkills(
|
|
176
241
|
option: RemoteSkillsOption | undefined,
|
|
@@ -183,12 +248,17 @@ export async function provisionRemoteSkills(
|
|
|
183
248
|
const client = options.client ?? appkit.tryGetExecutionContext()?.client;
|
|
184
249
|
const databricksBasePath = resolveDatabricksBasePath(options, client);
|
|
185
250
|
const destination = databricksBasePath
|
|
186
|
-
? new
|
|
251
|
+
? new DatabricksFileSystem({
|
|
252
|
+
client: client as WorkspaceClient,
|
|
253
|
+
root: databricksBasePath,
|
|
254
|
+
readOnly: false,
|
|
255
|
+
createRoot: true,
|
|
256
|
+
})
|
|
187
257
|
: undefined;
|
|
188
258
|
|
|
189
259
|
const localSkillPaths: string[] = [];
|
|
190
260
|
const skillNames: string[] = [];
|
|
191
|
-
let staging:
|
|
261
|
+
let staging: LocalFileSystem | undefined;
|
|
192
262
|
|
|
193
263
|
try {
|
|
194
264
|
const sources = Array.isArray(options.sources) ? options.sources : [options.sources];
|
|
@@ -196,17 +266,17 @@ export async function provisionRemoteSkills(
|
|
|
196
266
|
const sourceOptions = toSourceOptions(entry);
|
|
197
267
|
const failOnError = sourceOptions.failOnError ?? failDefault;
|
|
198
268
|
try {
|
|
199
|
-
staging ??= await
|
|
200
|
-
const stagedDir = await stageSource(sourceOptions, staging, options);
|
|
269
|
+
staging ??= await initializedScratch("mastra-remote-skills");
|
|
270
|
+
const stagedDir = await stageSource(sourceOptions, staging.root, options);
|
|
201
271
|
const staged = await collectSkillDirs(stagedDir);
|
|
202
272
|
if (staged.length === 0) {
|
|
203
273
|
throw new Error(`no SKILL.md found for source "${sourceOptions.source}"`);
|
|
204
274
|
}
|
|
205
275
|
if (destination && databricksBasePath) {
|
|
206
|
-
await
|
|
276
|
+
await copySkillDirs(destination, staged);
|
|
207
277
|
skillNames.push(...staged.map((dir) => dir.name));
|
|
208
278
|
} else {
|
|
209
|
-
const localDir = await persistLocally(staged);
|
|
279
|
+
const localDir = await persistLocally(sourceOptions.source, staged);
|
|
210
280
|
localSkillPaths.push(localDir);
|
|
211
281
|
skillNames.push(...staged.map((dir) => dir.name));
|
|
212
282
|
}
|
|
@@ -229,7 +299,9 @@ export async function provisionRemoteSkills(
|
|
|
229
299
|
}
|
|
230
300
|
}
|
|
231
301
|
} finally {
|
|
232
|
-
if (staging)
|
|
302
|
+
if (staging) {
|
|
303
|
+
await rm(staging.root, { recursive: true, force: true }).catch(() => undefined);
|
|
304
|
+
}
|
|
233
305
|
}
|
|
234
306
|
|
|
235
307
|
return { localSkillPaths, databricksBasePath, skillNames };
|
|
@@ -250,27 +322,96 @@ function resolveDatabricksBasePath(
|
|
|
250
322
|
}
|
|
251
323
|
|
|
252
324
|
/**
|
|
253
|
-
* Stage one source into a fresh dir under `
|
|
325
|
+
* Stage one source into a fresh dir under `stagingRoot`, preferring the optional
|
|
254
326
|
* `skills` CLI and falling back to a direct fetch.
|
|
255
327
|
*/
|
|
256
328
|
async function stageSource(
|
|
257
|
-
sourceOptions:
|
|
258
|
-
|
|
329
|
+
sourceOptions: NormalizedSource,
|
|
330
|
+
stagingRoot: string,
|
|
259
331
|
options: ProvisionRemoteSkillsOptions,
|
|
260
332
|
): Promise<string> {
|
|
261
|
-
const target =
|
|
333
|
+
const target = join(stagingRoot, `src-${hash.id()}`);
|
|
334
|
+
await mkdir(target, { recursive: true });
|
|
335
|
+
// AI Tools resolves from a known repo layout, so it never needs the CLI.
|
|
336
|
+
if (isAiToolsSource(sourceOptions.source)) return stageAiTools(sourceOptions, target, options);
|
|
262
337
|
const viaCli = await stageViaSkillsCli(sourceOptions, target);
|
|
263
338
|
if (viaCli) return viaCli;
|
|
264
339
|
return stageViaFetch(sourceOptions, target, options);
|
|
265
340
|
}
|
|
266
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Materialize the Databricks AI Tools skills into `target`.
|
|
344
|
+
*
|
|
345
|
+
* Reads the repo's own generated `manifest.json` (which names each skill's
|
|
346
|
+
* files and whether it lives under `skills/` or `experimental/`) and downloads
|
|
347
|
+
* them - the same thing `databricks aitools install` does, minus the CLI and
|
|
348
|
+
* minus any Databricks auth, since the repo is public.
|
|
349
|
+
*/
|
|
350
|
+
async function stageAiTools(
|
|
351
|
+
sourceOptions: NormalizedSource,
|
|
352
|
+
target: string,
|
|
353
|
+
options: ProvisionRemoteSkillsOptions,
|
|
354
|
+
): Promise<string> {
|
|
355
|
+
const maxBytes = resolveMaxBytes(sourceOptions, options);
|
|
356
|
+
const ref = string.trimToNull(sourceOptions.ref) ?? AITOOLS_REF;
|
|
357
|
+
const manifest = json.parse(
|
|
358
|
+
(await download(aiToolsRawUrl(ref, "manifest.json"), maxBytes)).toString("utf8"),
|
|
359
|
+
undefined,
|
|
360
|
+
) as AiToolsManifest | undefined;
|
|
361
|
+
|
|
362
|
+
const wanted = new Set(string.parseList(sourceOptions.skills));
|
|
363
|
+
const selected = Object.entries(manifest?.skills ?? {}).filter(([name, entry]) =>
|
|
364
|
+
wanted.size > 0
|
|
365
|
+
? wanted.has(name)
|
|
366
|
+
: entry.repo_dir !== "experimental" || sourceOptions.experimental === true,
|
|
367
|
+
);
|
|
368
|
+
if (selected.length === 0) {
|
|
369
|
+
throw new Error(`no matching skills in ${AITOOLS_REPO}@${ref}`);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const files = selected.flatMap(([name, entry]) =>
|
|
373
|
+
(entry.files ?? []).map((file) => ({
|
|
374
|
+
url: aiToolsRawUrl(ref, `${entry.repo_dir ?? "skills"}/${name}/${file}`),
|
|
375
|
+
path: join(target, name, ...file.split("/")),
|
|
376
|
+
})),
|
|
377
|
+
);
|
|
378
|
+
await mapConcurrent(files, AITOOLS_CONCURRENCY, async (item) => {
|
|
379
|
+
const body = await download(item.url, maxBytes);
|
|
380
|
+
await mkdir(dirname(item.path), { recursive: true });
|
|
381
|
+
await writeFile(item.path, body);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
logger.debug("aitools:staged", { ref, skills: selected.length, files: files.length });
|
|
385
|
+
return target;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Raw-content URL for a path in the AI Tools repo at `ref`. */
|
|
389
|
+
function aiToolsRawUrl(ref: string, path: string): string {
|
|
390
|
+
return `https://raw.githubusercontent.com/${AITOOLS_REPO}/${ref}/${path}`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Run `worker` over `items`, at most `limit` in flight. */
|
|
394
|
+
async function mapConcurrent<T>(
|
|
395
|
+
items: readonly T[],
|
|
396
|
+
limit: number,
|
|
397
|
+
worker: (item: T) => Promise<void>,
|
|
398
|
+
): Promise<void> {
|
|
399
|
+
let cursor = 0;
|
|
400
|
+
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
401
|
+
while (cursor < items.length) {
|
|
402
|
+
await worker(items[cursor++]!);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
await Promise.all(runners);
|
|
406
|
+
}
|
|
407
|
+
|
|
267
408
|
/**
|
|
268
409
|
* Copy a source into `target` with the `skills` CLI when it is installed.
|
|
269
410
|
* Returns the dir holding the copied `SKILL.md` trees, or `undefined` when the
|
|
270
411
|
* CLI is absent (so the caller can fall back to a fetch).
|
|
271
412
|
*/
|
|
272
413
|
async function stageViaSkillsCli(
|
|
273
|
-
sourceOptions:
|
|
414
|
+
sourceOptions: NormalizedSource,
|
|
274
415
|
target: string,
|
|
275
416
|
): Promise<string | undefined> {
|
|
276
417
|
const cli = await resolveSkillsCli();
|
|
@@ -290,7 +431,7 @@ async function stageViaSkillsCli(
|
|
|
290
431
|
}
|
|
291
432
|
if (string.parseList(sourceOptions.skills).length === 0) args.push("--skill", "*");
|
|
292
433
|
|
|
293
|
-
const result = await spawn(cli.command, args, {
|
|
434
|
+
const result = await exec.spawn(cli.command, args, {
|
|
294
435
|
cwd: target,
|
|
295
436
|
stdout: "capture",
|
|
296
437
|
stderr: "capture",
|
|
@@ -326,7 +467,7 @@ async function resolveSkillsCli(): Promise<{ command: string; args: string[] } |
|
|
|
326
467
|
* Used when the `skills` CLI isn't installed; supports a direct `SKILL.md` URL.
|
|
327
468
|
*/
|
|
328
469
|
async function stageViaFetch(
|
|
329
|
-
sourceOptions:
|
|
470
|
+
sourceOptions: NormalizedSource,
|
|
330
471
|
target: string,
|
|
331
472
|
options: ProvisionRemoteSkillsOptions,
|
|
332
473
|
): Promise<string> {
|
|
@@ -336,18 +477,7 @@ async function stageViaFetch(
|
|
|
336
477
|
`source "${sourceOptions.source}" is not a URL and the optional "skills" package is not installed`,
|
|
337
478
|
);
|
|
338
479
|
}
|
|
339
|
-
const
|
|
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
|
-
}
|
|
480
|
+
const buffer = await download(url.toString(), resolveMaxBytes(sourceOptions, options));
|
|
351
481
|
const name = string.toSlug(deriveSkillName(url.toString())) || "remote-skill";
|
|
352
482
|
const skillDir = join(target, name);
|
|
353
483
|
await mkdir(skillDir, { recursive: true });
|
|
@@ -355,6 +485,27 @@ async function stageViaFetch(
|
|
|
355
485
|
return target;
|
|
356
486
|
}
|
|
357
487
|
|
|
488
|
+
/** The effective download ceiling: per-source, then top-level, then the default. */
|
|
489
|
+
function resolveMaxBytes(
|
|
490
|
+
sourceOptions: NormalizedSource,
|
|
491
|
+
options: ProvisionRemoteSkillsOptions,
|
|
492
|
+
): number {
|
|
493
|
+
return sourceOptions.maxDownloadBytes ?? options.maxDownloadBytes ?? DEFAULT_MAX_DOWNLOAD_BYTES;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** GET a URL, failing loudly on a non-2xx status or an oversized body. */
|
|
497
|
+
async function download(url: string, maxBytes: number): Promise<Buffer> {
|
|
498
|
+
const response = await fetch(url);
|
|
499
|
+
if (!response.ok) {
|
|
500
|
+
throw new Error(`download failed: ${response.status} ${response.statusText} (${url})`);
|
|
501
|
+
}
|
|
502
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
503
|
+
if (buffer.byteLength > maxBytes) {
|
|
504
|
+
throw new Error(`download exceeds ${maxBytes} bytes (${url})`);
|
|
505
|
+
}
|
|
506
|
+
return buffer;
|
|
507
|
+
}
|
|
508
|
+
|
|
358
509
|
/** Derive a skill directory name from a download URL's last path segment. */
|
|
359
510
|
function deriveSkillName(urlString: string): string {
|
|
360
511
|
const url = net.urlBuilder(urlString);
|
|
@@ -389,28 +540,47 @@ async function collectSkillDirs(root: string): Promise<StagedSkillDir[]> {
|
|
|
389
540
|
return dirs;
|
|
390
541
|
}
|
|
391
542
|
|
|
392
|
-
/**
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
543
|
+
/**
|
|
544
|
+
* Copy every staged skill directory into {@link destination}, preserving the
|
|
545
|
+
* `<skill>/<relative>` layout. The destination is just a {@link FileSystem},
|
|
546
|
+
* so the Databricks Assistant tree and the local fallback share one copier.
|
|
547
|
+
*/
|
|
548
|
+
async function copySkillDirs(destination: FileSystem, dirs: StagedSkillDir[]): Promise<void> {
|
|
549
|
+
await destination.init();
|
|
398
550
|
for (const dir of dirs) {
|
|
399
|
-
for (const relative of findFiles("**/*", { cwd: dir.absolutePath, nodir: true })) {
|
|
551
|
+
for (const relative of find.findFiles("**/*", { cwd: dir.absolutePath, nodir: true })) {
|
|
400
552
|
const buffer = await readFile(join(dir.absolutePath, relative));
|
|
401
|
-
const
|
|
402
|
-
await destination.writeFile(
|
|
553
|
+
const skillPath = posix.join(dir.name, relative.split(/[\\/]/).join("/"));
|
|
554
|
+
await destination.writeFile(skillPath, buffer, { overwrite: true });
|
|
403
555
|
}
|
|
404
556
|
}
|
|
405
557
|
}
|
|
406
558
|
|
|
407
|
-
/**
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
559
|
+
/**
|
|
560
|
+
* Copy staged skill dirs into a STABLE local tree keyed by the source, and
|
|
561
|
+
* return its root path.
|
|
562
|
+
*
|
|
563
|
+
* A given source resolves to the same content on almost every boot, so each
|
|
564
|
+
* one owns a directory named for its {@link hash.fnvHash} rather than leaving
|
|
565
|
+
* a fresh scratch dir behind per restart. Keying on the source (not the
|
|
566
|
+
* content) is what keeps two different sources from overwriting each other.
|
|
567
|
+
*/
|
|
568
|
+
async function persistLocally(source: string, dirs: StagedSkillDir[]): Promise<string> {
|
|
569
|
+
const stable = await localFS.rebuildFS(`${LOCAL_SKILLS_DIR}/${hash.fnvHash(source)}`, (scratch) =>
|
|
570
|
+
copySkillDirs(scratch, dirs),
|
|
571
|
+
);
|
|
572
|
+
return stable.root;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* A scratch filesystem whose root exists on disk, for the paths that hand a
|
|
577
|
+
* real directory to `node:fs` or a child process rather than going through
|
|
578
|
+
* the {@link FileSystem} API.
|
|
579
|
+
*/
|
|
580
|
+
async function initializedScratch(prefix: string): Promise<LocalFileSystem> {
|
|
581
|
+
const scratch = localFS.scratchFS(prefix);
|
|
582
|
+
await scratch.init();
|
|
583
|
+
return scratch;
|
|
414
584
|
}
|
|
415
585
|
|
|
416
586
|
/** Best-effort existence check. */
|