glab-agent 0.1.0
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/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/glab-agent.mjs +18 -0
- package/package.json +59 -0
- package/src/local-agent/agent-config.ts +315 -0
- package/src/local-agent/agent-provider.ts +59 -0
- package/src/local-agent/agent-runner.ts +244 -0
- package/src/local-agent/claude-runner.ts +136 -0
- package/src/local-agent/cli.ts +1497 -0
- package/src/local-agent/codex-runner.ts +153 -0
- package/src/local-agent/gitlab-glab-client.ts +722 -0
- package/src/local-agent/health-server.ts +56 -0
- package/src/local-agent/heartbeat.ts +33 -0
- package/src/local-agent/log-rotate.ts +56 -0
- package/src/local-agent/logger.ts +92 -0
- package/src/local-agent/metrics.ts +51 -0
- package/src/local-agent/mr-actions.ts +121 -0
- package/src/local-agent/notifier.ts +190 -0
- package/src/local-agent/process-manager.ts +193 -0
- package/src/local-agent/reply-runner.ts +111 -0
- package/src/local-agent/repo-cache.ts +144 -0
- package/src/local-agent/report.ts +183 -0
- package/src/local-agent/skill-import.ts +344 -0
- package/src/local-agent/skill-inject.ts +109 -0
- package/src/local-agent/skill-parse.ts +47 -0
- package/src/local-agent/smoke-test.ts +443 -0
- package/src/local-agent/state-store.ts +186 -0
- package/src/local-agent/token-check.ts +37 -0
- package/src/local-agent/watcher.ts +1226 -0
- package/src/local-agent/wiki-sync.ts +290 -0
- package/src/local-agent/worktree-manager.ts +141 -0
- package/src/text.ts +16 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { parse as parseYaml } from "yaml";
|
|
6
|
+
import { parseSkillMd } from "./skill-parse.js";
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
export interface ImportResult {
|
|
11
|
+
status: "imported" | "updated" | "unchanged" | "failed";
|
|
12
|
+
name: string;
|
|
13
|
+
message: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ParsedGitHubUrl {
|
|
17
|
+
owner: string;
|
|
18
|
+
repo: string;
|
|
19
|
+
path?: string;
|
|
20
|
+
ref?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse a GitHub URL to extract owner/repo and optional path.
|
|
25
|
+
* Supports:
|
|
26
|
+
* https://github.com/owner/repo
|
|
27
|
+
* https://github.com/owner/repo/tree/main/skills/my-skill.yaml
|
|
28
|
+
* owner/repo (shorthand)
|
|
29
|
+
* owner/repo/skills/my-skill.yaml (shorthand with path)
|
|
30
|
+
*/
|
|
31
|
+
export function parseGitHubUrl(input: string): ParsedGitHubUrl | null {
|
|
32
|
+
if (!input || typeof input !== "string") return null;
|
|
33
|
+
|
|
34
|
+
const trimmed = input.trim();
|
|
35
|
+
|
|
36
|
+
// Full HTTPS URL: https://github.com/owner/repo[/tree/ref/path...]
|
|
37
|
+
const httpsMatch = trimmed.match(
|
|
38
|
+
/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/([^/]+)(?:\/(.+))?)?$/
|
|
39
|
+
);
|
|
40
|
+
if (httpsMatch) {
|
|
41
|
+
const [, owner, repo, ref, filePath] = httpsMatch;
|
|
42
|
+
return {
|
|
43
|
+
owner,
|
|
44
|
+
repo,
|
|
45
|
+
ref: ref || undefined,
|
|
46
|
+
path: filePath || undefined
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Shorthand: owner/repo[/path...]
|
|
51
|
+
// Must have exactly "owner/repo" or "owner/repo/path..."
|
|
52
|
+
const shortMatch = trimmed.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\/(.+))?$/);
|
|
53
|
+
if (shortMatch) {
|
|
54
|
+
const [, owner, repo, filePath] = shortMatch;
|
|
55
|
+
return {
|
|
56
|
+
owner,
|
|
57
|
+
repo,
|
|
58
|
+
path: filePath || undefined
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fetch a single file from GitHub using the raw content URL.
|
|
67
|
+
* Uses `curl` command (zero npm dependencies, system tool).
|
|
68
|
+
*/
|
|
69
|
+
export async function fetchGitHubFile(
|
|
70
|
+
owner: string,
|
|
71
|
+
repo: string,
|
|
72
|
+
filePath: string,
|
|
73
|
+
ref?: string,
|
|
74
|
+
execImpl: typeof execFileAsync = execFileAsync
|
|
75
|
+
): Promise<string> {
|
|
76
|
+
const branch = ref || "main";
|
|
77
|
+
const url = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${filePath}`;
|
|
78
|
+
try {
|
|
79
|
+
const { stdout } = await execImpl("curl", ["-fsSL", url]);
|
|
80
|
+
return stdout;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
83
|
+
throw new Error(`Failed to fetch ${url}: ${msg}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface GitHubContentsEntry {
|
|
88
|
+
name: string;
|
|
89
|
+
path: string;
|
|
90
|
+
type: "file" | "dir";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Discover skill YAML files in a GitHub repo.
|
|
95
|
+
* Searches common locations: skills/, .agent_context/skills/, .glab-agent/skills/
|
|
96
|
+
* Uses GitHub API (via curl) to list directory contents.
|
|
97
|
+
*/
|
|
98
|
+
export async function discoverRemoteSkills(
|
|
99
|
+
owner: string,
|
|
100
|
+
repo: string,
|
|
101
|
+
ref?: string,
|
|
102
|
+
execImpl: typeof execFileAsync = execFileAsync
|
|
103
|
+
): Promise<string[]> {
|
|
104
|
+
const searchPaths = ["skills", ".agent_context/skills", ".glab-agent/skills"];
|
|
105
|
+
const found: string[] = [];
|
|
106
|
+
|
|
107
|
+
for (const searchPath of searchPaths) {
|
|
108
|
+
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${searchPath}${ref ? `?ref=${ref}` : ""}`;
|
|
109
|
+
try {
|
|
110
|
+
const { stdout } = await execImpl("curl", [
|
|
111
|
+
"-fsSL",
|
|
112
|
+
"-H", "Accept: application/vnd.github.v3+json",
|
|
113
|
+
apiUrl
|
|
114
|
+
]);
|
|
115
|
+
const entries = parseYaml(stdout) as GitHubContentsEntry[] | null;
|
|
116
|
+
if (!Array.isArray(entries)) continue;
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (
|
|
119
|
+
entry.type === "file" &&
|
|
120
|
+
(entry.name.endsWith(".yaml") || entry.name.endsWith(".yml") || entry.name === "SKILL.md")
|
|
121
|
+
) {
|
|
122
|
+
found.push(entry.path);
|
|
123
|
+
}
|
|
124
|
+
// Anthropic convention: skills/<name>/SKILL.md
|
|
125
|
+
if (entry.type === "dir") {
|
|
126
|
+
const subApiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${entry.path}${ref ? `?ref=${ref}` : ""}`;
|
|
127
|
+
try {
|
|
128
|
+
const { stdout: subStdout } = await execImpl("curl", [
|
|
129
|
+
"-fsSL", "-H", "Accept: application/vnd.github.v3+json", subApiUrl
|
|
130
|
+
]);
|
|
131
|
+
const subEntries = parseYaml(subStdout) as GitHubContentsEntry[] | null;
|
|
132
|
+
if (Array.isArray(subEntries)) {
|
|
133
|
+
for (const sub of subEntries) {
|
|
134
|
+
if (sub.type === "file" && sub.name === "SKILL.md") {
|
|
135
|
+
found.push(sub.path);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch { /* subdirectory listing failed — skip */ }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// Directory doesn't exist or API error — try next path
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return found;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Import a single skill YAML from GitHub to the local skills directory.
|
|
152
|
+
*/
|
|
153
|
+
export async function importSkill(
|
|
154
|
+
owner: string,
|
|
155
|
+
repo: string,
|
|
156
|
+
filePath: string,
|
|
157
|
+
skillsDir: string,
|
|
158
|
+
options?: { ref?: string; force?: boolean; execImpl?: typeof execFileAsync }
|
|
159
|
+
): Promise<ImportResult> {
|
|
160
|
+
const execImpl = options?.execImpl ?? execFileAsync;
|
|
161
|
+
const force = options?.force ?? false;
|
|
162
|
+
const ref = options?.ref;
|
|
163
|
+
|
|
164
|
+
// 1. Fetch the YAML content
|
|
165
|
+
let content: string;
|
|
166
|
+
try {
|
|
167
|
+
content = await fetchGitHubFile(owner, repo, filePath, ref, execImpl);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
170
|
+
return {
|
|
171
|
+
status: "failed",
|
|
172
|
+
name: path.basename(filePath, path.extname(filePath)),
|
|
173
|
+
message: `Fetch failed: ${msg}`
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 2. Parse content — branch on format (SKILL.md vs YAML)
|
|
178
|
+
const isMd = filePath.endsWith(".md") || filePath.endsWith("SKILL.md");
|
|
179
|
+
|
|
180
|
+
let skillName: string;
|
|
181
|
+
if (isMd) {
|
|
182
|
+
const parsed = parseSkillMd(content);
|
|
183
|
+
if (!parsed) {
|
|
184
|
+
return {
|
|
185
|
+
status: "failed",
|
|
186
|
+
name: path.basename(filePath, path.extname(filePath)),
|
|
187
|
+
message: "Invalid SKILL.md: missing frontmatter or name"
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (!parsed.prompt_inject) {
|
|
191
|
+
return {
|
|
192
|
+
status: "failed",
|
|
193
|
+
name: parsed.name,
|
|
194
|
+
message: "SKILL.md has empty body (no prompt content)"
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
skillName = parsed.name;
|
|
198
|
+
} else {
|
|
199
|
+
let parsed: Record<string, unknown>;
|
|
200
|
+
try {
|
|
201
|
+
const raw = parseYaml(content);
|
|
202
|
+
if (typeof raw !== "object" || raw === null) {
|
|
203
|
+
throw new Error("YAML did not parse to an object");
|
|
204
|
+
}
|
|
205
|
+
parsed = raw as Record<string, unknown>;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
208
|
+
return {
|
|
209
|
+
status: "failed",
|
|
210
|
+
name: path.basename(filePath, path.extname(filePath)),
|
|
211
|
+
message: `Invalid YAML: ${msg}`
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (typeof parsed.name !== "string" || !parsed.name) {
|
|
216
|
+
return {
|
|
217
|
+
status: "failed",
|
|
218
|
+
name: path.basename(filePath, path.extname(filePath)),
|
|
219
|
+
message: "Skill YAML missing required field: name"
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (typeof parsed.prompt_inject !== "string" || !parsed.prompt_inject) {
|
|
224
|
+
return {
|
|
225
|
+
status: "failed",
|
|
226
|
+
name: parsed.name as string,
|
|
227
|
+
message: "Skill YAML missing required field: prompt_inject"
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
skillName = parsed.name as string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 3. Determine local filename from skill name (preserve format)
|
|
234
|
+
const localFileName = isMd ? `${skillName}.md` : `${skillName}.yaml`;
|
|
235
|
+
const localPath = path.join(skillsDir, localFileName);
|
|
236
|
+
|
|
237
|
+
// 4. Check if file already exists
|
|
238
|
+
let existingContent: string | null = null;
|
|
239
|
+
try {
|
|
240
|
+
existingContent = await readFile(localPath, "utf8");
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
243
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
244
|
+
return {
|
|
245
|
+
status: "failed",
|
|
246
|
+
name: skillName,
|
|
247
|
+
message: `Failed to read existing skill file: ${msg}`
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
// File does not exist — will be imported
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (existingContent !== null) {
|
|
254
|
+
if (existingContent === content) {
|
|
255
|
+
return {
|
|
256
|
+
status: "unchanged",
|
|
257
|
+
name: skillName,
|
|
258
|
+
message: `Skill "${skillName}" is already up to date`
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (!force) {
|
|
262
|
+
return {
|
|
263
|
+
status: "unchanged",
|
|
264
|
+
name: skillName,
|
|
265
|
+
message: `Skill "${skillName}" already exists (use --force to overwrite)`
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
// force=true and content differs → will update
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 5. Write to skillsDir/<name>.yaml
|
|
272
|
+
try {
|
|
273
|
+
await mkdir(skillsDir, { recursive: true });
|
|
274
|
+
await writeFile(localPath, content, "utf8");
|
|
275
|
+
} catch (error) {
|
|
276
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
277
|
+
return {
|
|
278
|
+
status: "failed",
|
|
279
|
+
name: skillName,
|
|
280
|
+
message: `Failed to write skill file: ${msg}`
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 6. Return ImportResult
|
|
285
|
+
if (existingContent !== null) {
|
|
286
|
+
return {
|
|
287
|
+
status: "updated",
|
|
288
|
+
name: skillName,
|
|
289
|
+
message: `Skill "${skillName}" updated from ${owner}/${repo}`
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
status: "imported",
|
|
294
|
+
name: skillName,
|
|
295
|
+
message: `Skill "${skillName}" imported from ${owner}/${repo}`
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Import all skills from a GitHub repo.
|
|
301
|
+
*/
|
|
302
|
+
export async function importAllSkills(
|
|
303
|
+
owner: string,
|
|
304
|
+
repo: string,
|
|
305
|
+
skillsDir: string,
|
|
306
|
+
options?: { ref?: string; force?: boolean; execImpl?: typeof execFileAsync }
|
|
307
|
+
): Promise<ImportResult[]> {
|
|
308
|
+
const execImpl = options?.execImpl ?? execFileAsync;
|
|
309
|
+
|
|
310
|
+
// 1. Discover remote skills
|
|
311
|
+
let filePaths: string[];
|
|
312
|
+
try {
|
|
313
|
+
filePaths = await discoverRemoteSkills(owner, repo, options?.ref, execImpl);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
316
|
+
return [
|
|
317
|
+
{
|
|
318
|
+
status: "failed",
|
|
319
|
+
name: "(discovery)",
|
|
320
|
+
message: `Failed to discover skills in ${owner}/${repo}: ${msg}`
|
|
321
|
+
}
|
|
322
|
+
];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (filePaths.length === 0) {
|
|
326
|
+
return [
|
|
327
|
+
{
|
|
328
|
+
status: "failed",
|
|
329
|
+
name: "(discovery)",
|
|
330
|
+
message: `No skill files found in ${owner}/${repo} (looked for .yaml and SKILL.md)`
|
|
331
|
+
}
|
|
332
|
+
];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 2. Import each one
|
|
336
|
+
const results: ImportResult[] = [];
|
|
337
|
+
for (const filePath of filePaths) {
|
|
338
|
+
const result = await importSkill(owner, repo, filePath, skillsDir, options);
|
|
339
|
+
results.push(result);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 3. Return results array
|
|
343
|
+
return results;
|
|
344
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { AgentSkill } from "./agent-config.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Provider-specific skill directory conventions.
|
|
7
|
+
* Claude Code: .claude/skills/
|
|
8
|
+
* OpenCode: .config/opencode/skills/
|
|
9
|
+
* Codex: .agent_context/skills/
|
|
10
|
+
*/
|
|
11
|
+
export function skillDirForProvider(worktreePath: string, provider: "claude" | "codex"): string {
|
|
12
|
+
if (provider === "claude") return path.join(worktreePath, ".claude", "skills");
|
|
13
|
+
return path.join(worktreePath, ".agent_context", "skills");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the source .md file path for a shared skill (if it exists on disk).
|
|
18
|
+
* Returns undefined if the skill was defined inline (no source file).
|
|
19
|
+
*/
|
|
20
|
+
function sharedSkillMdPath(skillsDir: string | undefined, name: string): string | undefined {
|
|
21
|
+
if (!skillsDir) return undefined;
|
|
22
|
+
return path.join(skillsDir, `${slugifySkillName(name)}.md`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Write skill files into the worktree for native agent CLI recognition.
|
|
27
|
+
* - For .md shared skills: copies the original SKILL.md file as-is (preserving frontmatter).
|
|
28
|
+
* - For inline skills (from agent YAML): generates SKILL.md with frontmatter.
|
|
29
|
+
*
|
|
30
|
+
* Returns the number of skills written.
|
|
31
|
+
*/
|
|
32
|
+
export async function injectSkillFiles(
|
|
33
|
+
worktreePath: string,
|
|
34
|
+
provider: "claude" | "codex",
|
|
35
|
+
skills: AgentSkill[],
|
|
36
|
+
skillsDir?: string
|
|
37
|
+
): Promise<number> {
|
|
38
|
+
if (skills.length === 0) return 0;
|
|
39
|
+
|
|
40
|
+
const skillDir = skillDirForProvider(worktreePath, provider);
|
|
41
|
+
await mkdir(skillDir, { recursive: true });
|
|
42
|
+
|
|
43
|
+
let count = 0;
|
|
44
|
+
for (const skill of skills) {
|
|
45
|
+
const fileName = slugifySkillName(skill.name) + ".md";
|
|
46
|
+
const destPath = path.join(skillDir, fileName);
|
|
47
|
+
|
|
48
|
+
// Try to copy original .md file if it exists (preserves native SKILL.md format)
|
|
49
|
+
const srcPath = sharedSkillMdPath(skillsDir, skill.name);
|
|
50
|
+
let copied = false;
|
|
51
|
+
if (srcPath) {
|
|
52
|
+
try {
|
|
53
|
+
const original = await readFile(srcPath, "utf8");
|
|
54
|
+
await writeFile(destPath, original, "utf8");
|
|
55
|
+
copied = true;
|
|
56
|
+
} catch { /* source .md not found — fall through to generation */ }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!copied) {
|
|
60
|
+
const content = formatSkillMarkdown(skill);
|
|
61
|
+
await writeFile(destPath, content, "utf8");
|
|
62
|
+
}
|
|
63
|
+
count++;
|
|
64
|
+
}
|
|
65
|
+
return count;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Clean up injected skill files (called after agent run if needed).
|
|
70
|
+
*/
|
|
71
|
+
export async function cleanSkillFiles(
|
|
72
|
+
worktreePath: string,
|
|
73
|
+
provider: "claude" | "codex"
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const skillDir = skillDirForProvider(worktreePath, provider);
|
|
76
|
+
try {
|
|
77
|
+
await rm(skillDir, { recursive: true, force: true });
|
|
78
|
+
} catch { /* silent */ }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Format an inline skill as standard SKILL.md (YAML frontmatter + markdown body).
|
|
83
|
+
* Used for inline skills defined in agent YAML that don't have a source .md file.
|
|
84
|
+
*/
|
|
85
|
+
export function formatSkillMarkdown(skill: AgentSkill): string {
|
|
86
|
+
const lines: string[] = [];
|
|
87
|
+
lines.push("---");
|
|
88
|
+
lines.push(`name: ${skill.name}`);
|
|
89
|
+
if (skill.description) {
|
|
90
|
+
lines.push(`description: ${skill.description}`);
|
|
91
|
+
}
|
|
92
|
+
lines.push("---");
|
|
93
|
+
lines.push("");
|
|
94
|
+
lines.push(skill.prompt_inject.trimEnd());
|
|
95
|
+
lines.push("");
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Normalize skill name to a safe filename.
|
|
101
|
+
*/
|
|
102
|
+
export function slugifySkillName(name: string): string {
|
|
103
|
+
return (
|
|
104
|
+
name
|
|
105
|
+
.toLowerCase()
|
|
106
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
107
|
+
.replace(/^-+|-+$/g, "") || "skill"
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { parse as parseYaml } from "yaml";
|
|
2
|
+
import type { AgentSkill } from "./agent-config.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Regex to extract YAML frontmatter and markdown body from SKILL.md format.
|
|
6
|
+
* Group 1: YAML frontmatter content (between --- delimiters)
|
|
7
|
+
* Group 2: Markdown body (after closing ---)
|
|
8
|
+
*/
|
|
9
|
+
const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?([\s\S]*)$/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse a SKILL.md file (YAML frontmatter + markdown body) into an AgentSkill.
|
|
13
|
+
* Returns null if the content is not valid SKILL.md format or missing required fields.
|
|
14
|
+
*
|
|
15
|
+
* Expected format:
|
|
16
|
+
* ```
|
|
17
|
+
* ---
|
|
18
|
+
* name: skill-name
|
|
19
|
+
* description: Optional description
|
|
20
|
+
* ---
|
|
21
|
+
*
|
|
22
|
+
* Markdown body becomes prompt_inject
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export function parseSkillMd(content: string): AgentSkill | null {
|
|
26
|
+
const match = content.match(FRONTMATTER_RE);
|
|
27
|
+
if (!match) return null;
|
|
28
|
+
|
|
29
|
+
const [, frontmatterStr, body] = match;
|
|
30
|
+
|
|
31
|
+
let frontmatter: Record<string, unknown>;
|
|
32
|
+
try {
|
|
33
|
+
const raw = parseYaml(frontmatterStr);
|
|
34
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
35
|
+
frontmatter = raw as Record<string, unknown>;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const name = frontmatter.name;
|
|
41
|
+
if (typeof name !== "string" || !name) return null;
|
|
42
|
+
|
|
43
|
+
const description = typeof frontmatter.description === "string" ? frontmatter.description : "";
|
|
44
|
+
const prompt_inject = body.trim();
|
|
45
|
+
|
|
46
|
+
return { name, description, prompt_inject };
|
|
47
|
+
}
|