@dex-ai/skills-extension 0.2.3
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/package.json +27 -0
- package/src/index.ts +334 -0
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dex-ai/skills-extension",
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"description": "File-based skill loader for @dex-ai/sdk — loads skills from ~/.dex/skills/ directories.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"default": "./src/index.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"typecheck": "tsc --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@dex-ai/sdk": "^0.1.2",
|
|
20
|
+
"zod": "^3.23.0"
|
|
21
|
+
},
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public",
|
|
25
|
+
"registry": "https://registry.npmjs.org/"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dex-ai/skills-extension — file-based skill loader.
|
|
3
|
+
*
|
|
4
|
+
* Loads skills from ~/.dex/skills/ directories. Each skill is a folder
|
|
5
|
+
* containing a SKILL.md entry point (with optional YAML frontmatter).
|
|
6
|
+
*
|
|
7
|
+
* Skills follow the same format as Claude Code skills:
|
|
8
|
+
* ~/.dex/skills/<name>/SKILL.md
|
|
9
|
+
*
|
|
10
|
+
* Extensions can also bundle their own skills using the loadSkill() helper.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* import { skillsExtension } from '@dex-ai/skills-extension';
|
|
14
|
+
*
|
|
15
|
+
* const agent = await Agent.create({
|
|
16
|
+
* extensions: [skillsExtension(), ...otherExtensions],
|
|
17
|
+
* });
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type {
|
|
21
|
+
Extension,
|
|
22
|
+
Skill,
|
|
23
|
+
Tool,
|
|
24
|
+
ToolOutput,
|
|
25
|
+
AgentContext,
|
|
26
|
+
} from "@dex-ai/sdk";
|
|
27
|
+
import { z } from "zod";
|
|
28
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
29
|
+
import { join, basename } from "node:path";
|
|
30
|
+
import { homedir } from "node:os";
|
|
31
|
+
|
|
32
|
+
/* ------------------------------------------------------------------ */
|
|
33
|
+
/* SKILL.md Parsing */
|
|
34
|
+
/* ------------------------------------------------------------------ */
|
|
35
|
+
|
|
36
|
+
export interface ParsedSkill {
|
|
37
|
+
/** Skill name (from frontmatter or directory name). */
|
|
38
|
+
name: string;
|
|
39
|
+
/** Short description for the catalog listing. */
|
|
40
|
+
description?: string;
|
|
41
|
+
/** Tools auto-approved when this skill is active. */
|
|
42
|
+
allowedTools?: string[];
|
|
43
|
+
/** The skill content (markdown body minus frontmatter). */
|
|
44
|
+
content: string;
|
|
45
|
+
/** Absolute path to the skill directory. */
|
|
46
|
+
dir: string;
|
|
47
|
+
/** List of supporting files (relative paths). */
|
|
48
|
+
files: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse YAML frontmatter from a SKILL.md string.
|
|
53
|
+
* Returns the frontmatter fields and the remaining body.
|
|
54
|
+
*/
|
|
55
|
+
function parseFrontmatter(raw: string): {
|
|
56
|
+
meta: Record<string, unknown>;
|
|
57
|
+
body: string;
|
|
58
|
+
} {
|
|
59
|
+
const trimmed = raw.trimStart();
|
|
60
|
+
if (!trimmed.startsWith("---")) {
|
|
61
|
+
return { meta: {}, body: raw };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const endIdx = trimmed.indexOf("\n---", 3);
|
|
65
|
+
if (endIdx === -1) {
|
|
66
|
+
return { meta: {}, body: raw };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const yamlBlock = trimmed.slice(4, endIdx); // after opening ---\n
|
|
70
|
+
const body = trimmed.slice(endIdx + 4).trimStart(); // after closing ---\n
|
|
71
|
+
|
|
72
|
+
// Simple YAML parser for flat key: value pairs
|
|
73
|
+
const meta: Record<string, unknown> = {};
|
|
74
|
+
for (const line of yamlBlock.split("\n")) {
|
|
75
|
+
const match = line.match(/^(\w[\w-]*)\s*:\s*(.+)$/);
|
|
76
|
+
if (match) {
|
|
77
|
+
const key = match[1]!;
|
|
78
|
+
let value: unknown = match[2]!.trim();
|
|
79
|
+
|
|
80
|
+
// Remove surrounding quotes
|
|
81
|
+
if (
|
|
82
|
+
typeof value === "string" &&
|
|
83
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
84
|
+
(value.startsWith("'") && value.endsWith("'")))
|
|
85
|
+
) {
|
|
86
|
+
value = (value as string).slice(1, -1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
meta[key] = value;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { meta, body };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* List supporting files in a skill directory (excluding SKILL.md itself).
|
|
98
|
+
*/
|
|
99
|
+
function listSupportingFiles(skillDir: string): string[] {
|
|
100
|
+
const files: string[] = [];
|
|
101
|
+
|
|
102
|
+
function walk(dir: string, prefix: string) {
|
|
103
|
+
let entries: string[];
|
|
104
|
+
try {
|
|
105
|
+
entries = readdirSync(dir);
|
|
106
|
+
} catch {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
if (entry === "node_modules" || entry.startsWith(".")) continue;
|
|
111
|
+
const full = join(dir, entry);
|
|
112
|
+
const rel = prefix ? `${prefix}/${entry}` : entry;
|
|
113
|
+
try {
|
|
114
|
+
if (statSync(full).isDirectory()) {
|
|
115
|
+
walk(full, rel);
|
|
116
|
+
} else if (entry !== "SKILL.md") {
|
|
117
|
+
files.push(rel);
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// skip unreadable
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
walk(skillDir, "");
|
|
126
|
+
return files;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Load a SKILL.md from a directory. Used by extensions to bundle skills
|
|
131
|
+
* in the same format as user-defined file-based skills.
|
|
132
|
+
*
|
|
133
|
+
* @param skillDir - Absolute path to the skill directory containing SKILL.md.
|
|
134
|
+
* @returns A Skill object ready to put on ext.skills.
|
|
135
|
+
*/
|
|
136
|
+
export function loadSkill(skillDir: string): Skill {
|
|
137
|
+
const parsed = parseSkillDir(skillDir);
|
|
138
|
+
return {
|
|
139
|
+
name: parsed.name,
|
|
140
|
+
...(parsed.description !== undefined
|
|
141
|
+
? { description: parsed.description }
|
|
142
|
+
: {}),
|
|
143
|
+
content: parsed.content,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Parse a skill directory into a ParsedSkill.
|
|
149
|
+
*/
|
|
150
|
+
function parseSkillDir(skillDir: string): ParsedSkill {
|
|
151
|
+
const skillMdPath = join(skillDir, "SKILL.md");
|
|
152
|
+
if (!existsSync(skillMdPath)) {
|
|
153
|
+
throw new Error(`SKILL.md not found in ${skillDir}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const raw = readFileSync(skillMdPath, "utf-8");
|
|
157
|
+
const { meta, body } = parseFrontmatter(raw);
|
|
158
|
+
|
|
159
|
+
const name = typeof meta.name === "string" ? meta.name : basename(skillDir);
|
|
160
|
+
|
|
161
|
+
let allowedTools: string[] | undefined;
|
|
162
|
+
if (typeof meta["allowed-tools"] === "string") {
|
|
163
|
+
allowedTools = (meta["allowed-tools"] as string)
|
|
164
|
+
.split(/\s+/)
|
|
165
|
+
.filter(Boolean);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const files = listSupportingFiles(skillDir);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
name,
|
|
172
|
+
...(typeof meta.description === "string"
|
|
173
|
+
? { description: meta.description }
|
|
174
|
+
: {}),
|
|
175
|
+
...(allowedTools !== undefined ? { allowedTools } : {}),
|
|
176
|
+
content: body,
|
|
177
|
+
dir: skillDir,
|
|
178
|
+
files,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Scan a directory for skill subdirectories (each containing SKILL.md).
|
|
184
|
+
*/
|
|
185
|
+
function scanSkillsDir(dir: string): ParsedSkill[] {
|
|
186
|
+
if (!existsSync(dir)) return [];
|
|
187
|
+
|
|
188
|
+
const skills: ParsedSkill[] = [];
|
|
189
|
+
let entries: string[];
|
|
190
|
+
try {
|
|
191
|
+
entries = readdirSync(dir);
|
|
192
|
+
} catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
const skillDir = join(dir, entry);
|
|
198
|
+
try {
|
|
199
|
+
if (!statSync(skillDir).isDirectory()) continue;
|
|
200
|
+
const skillMd = join(skillDir, "SKILL.md");
|
|
201
|
+
if (!existsSync(skillMd)) continue;
|
|
202
|
+
skills.push(parseSkillDir(skillDir));
|
|
203
|
+
} catch {
|
|
204
|
+
// skip unreadable entries
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return skills;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* ------------------------------------------------------------------ */
|
|
212
|
+
/* get_skill tool */
|
|
213
|
+
/* ------------------------------------------------------------------ */
|
|
214
|
+
|
|
215
|
+
const getSkillParams = z.object({
|
|
216
|
+
name: z
|
|
217
|
+
.string()
|
|
218
|
+
.min(1)
|
|
219
|
+
.describe("The skill name from the catalog to retrieve."),
|
|
220
|
+
file: z
|
|
221
|
+
.string()
|
|
222
|
+
.optional()
|
|
223
|
+
.describe("Optional supporting file path within the skill directory."),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
function getSkillTool(parsedSkills: Map<string, ParsedSkill>): Tool {
|
|
227
|
+
return {
|
|
228
|
+
name: "get_skill",
|
|
229
|
+
displayName: "Skill",
|
|
230
|
+
visible: false,
|
|
231
|
+
access: "read",
|
|
232
|
+
description:
|
|
233
|
+
"Retrieve the full content of a skill by name. Use when you need detailed instructions from the skills catalog.",
|
|
234
|
+
parameters: getSkillParams,
|
|
235
|
+
execute(input, gctx): ToolOutput {
|
|
236
|
+
const parsed = getSkillParams.parse(input);
|
|
237
|
+
|
|
238
|
+
// Try the parsed skills registry first (has file access)
|
|
239
|
+
const skill = parsedSkills.get(parsed.name);
|
|
240
|
+
|
|
241
|
+
if (!parsed.file) {
|
|
242
|
+
// Return skill content from state (includes all skills from all extensions)
|
|
243
|
+
const skillMap = gctx.agent.state.get("skills") as
|
|
244
|
+
| Map<string, string>
|
|
245
|
+
| undefined;
|
|
246
|
+
if (!skillMap) {
|
|
247
|
+
return { type: "error-text", value: "No skills available." };
|
|
248
|
+
}
|
|
249
|
+
const content = skillMap.get(parsed.name);
|
|
250
|
+
if (!content) {
|
|
251
|
+
const available = [...skillMap.keys()].join(", ");
|
|
252
|
+
return {
|
|
253
|
+
type: "error-text",
|
|
254
|
+
value: `Skill "${parsed.name}" not found. Available: ${available}`,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// If we have the parsed skill with supporting files, append file listing
|
|
259
|
+
let result = content;
|
|
260
|
+
if (skill && skill.files.length > 0) {
|
|
261
|
+
result += `\n\n## Supporting Files\n\n${skill.files.map((f) => `- ${f}`).join("\n")}\n\nUse \`get_skill\` with the \`file\` parameter to read any supporting file.`;
|
|
262
|
+
}
|
|
263
|
+
return { type: "text", value: result };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// File access — only available for parsed skills (file-based)
|
|
267
|
+
if (!skill) {
|
|
268
|
+
return {
|
|
269
|
+
type: "error-text",
|
|
270
|
+
value: `Skill "${parsed.name}" does not support file access (not a file-based skill).`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const filePath = join(skill.dir, parsed.file);
|
|
275
|
+
// Security: ensure the resolved path is within the skill directory
|
|
276
|
+
if (!filePath.startsWith(skill.dir)) {
|
|
277
|
+
return { type: "error-text", value: "Path traversal not allowed." };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
const content = readFileSync(filePath, "utf-8");
|
|
282
|
+
return { type: "text", value: content };
|
|
283
|
+
} catch {
|
|
284
|
+
return {
|
|
285
|
+
type: "error-text",
|
|
286
|
+
value: `File "${parsed.file}" not found in skill "${parsed.name}". Available: ${skill.files.join(", ") || "(none)"}`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* ------------------------------------------------------------------ */
|
|
294
|
+
/* Extension options */
|
|
295
|
+
/* ------------------------------------------------------------------ */
|
|
296
|
+
|
|
297
|
+
export interface SkillsExtensionOptions {
|
|
298
|
+
/** Directories to scan for skill folders. Default: ['~/.dex/skills'] */
|
|
299
|
+
dirs?: string[];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* ------------------------------------------------------------------ */
|
|
303
|
+
/* Extension factory */
|
|
304
|
+
/* ------------------------------------------------------------------ */
|
|
305
|
+
|
|
306
|
+
export function skillsExtension(opts: SkillsExtensionOptions = {}): Extension {
|
|
307
|
+
const dirs = opts.dirs ?? [join(homedir(), ".dex", "skills")];
|
|
308
|
+
|
|
309
|
+
// Scan skill directories and load all skills
|
|
310
|
+
const parsedSkills = new Map<string, ParsedSkill>();
|
|
311
|
+
for (const dir of dirs) {
|
|
312
|
+
for (const skill of scanSkillsDir(dir)) {
|
|
313
|
+
parsedSkills.set(skill.name, skill);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Convert to SDK Skill objects for the ext.skills field
|
|
318
|
+
const skills: Skill[] = [];
|
|
319
|
+
for (const [, parsed] of parsedSkills) {
|
|
320
|
+
skills.push({
|
|
321
|
+
name: parsed.name,
|
|
322
|
+
...(parsed.description !== undefined
|
|
323
|
+
? { description: parsed.description }
|
|
324
|
+
: {}),
|
|
325
|
+
content: parsed.content,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
name: "skills",
|
|
331
|
+
skills,
|
|
332
|
+
tools: [getSkillTool(parsedSkills)],
|
|
333
|
+
};
|
|
334
|
+
}
|