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.
@@ -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
+ }