Skip to content

Commit cc82eeb

Browse files
authored
De-dupe skill utils (#1150)
1 parent 65a2cf8 commit cc82eeb

File tree

11 files changed

+164
-91
lines changed

11 files changed

+164
-91
lines changed

scripts/src/copilot-cli-char-budget.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
* See https://github.com/github/copilot-agent-runtime/blob/0e43d66f7570421ba4b27a36a86ea908de188e59/src/skills/skillToolDescription.ts#L30
44
*/
55

6-
import { listSkills, loadSkill } from "./helpers/skill-helper.js";
7-
import { escapeXml } from "./helpers/string-helpers.js";
6+
import { listSkills, loadSkill } from "./shared/skill-helper.js";
7+
import { escapeXml } from "./shared/string-helpers.js";
88

99
const SKILL_CHAR_BUDGET_ENV = "SKILL_CHAR_BUDGET";
1010
const DEFAULT_SKILL_CHAR_BUDGET = 15000;

scripts/src/frontmatter/__tests__/frontmatter.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
validateMetadataVersion,
1515
validateSkillFile,
1616
} from "../cli.js";
17-
import { parseSkillContent } from "../../shared/parse-skill.js";
17+
import { parseSkillContent } from "../../shared/skill-helper.js";
1818

1919
const TEST_DIR = resolve(__dirname, "__test_frontmatter__");
2020

scripts/src/frontmatter/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import { dirname, resolve, basename } from "node:path";
2121
import { fileURLToPath } from "node:url";
2222
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
23-
import { parseSkillContent } from "../shared/parse-skill.js";
23+
import { parseSkillContent } from "../shared/skill-helper.js";
2424

2525
// ── Paths ────────────────────────────────────────────────────────────────────
2626

scripts/src/helpers/skill-helper.ts

Lines changed: 0 additions & 71 deletions
This file was deleted.

scripts/src/local/commands/test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
1111
import { join } from "node:path";
1212
import { homedir } from "node:os";
1313
import { execFileSync } from "node:child_process";
14-
import { parseSkillContent } from "../../shared/parse-skill.js";
14+
import { parseSkillContent } from "../../shared/skill-helper.js";
1515

1616
const TIMEOUT_MS = 120_000;
1717
const MARKETPLACE_NAME = "github-copilot-for-azure";
@@ -62,7 +62,7 @@ function checkPluginConfig(expectedCachePath: string): TestResult {
6262

6363
const marketplace = config.marketplaces?.[MARKETPLACE_NAME];
6464
if (!marketplace || marketplace.source?.source !== "github" ||
65-
marketplace.source?.repo !== "microsoft/github-copilot-for-azure") {
65+
marketplace.source?.repo !== "microsoft/github-copilot-for-azure") {
6666
return { name: "Plugin config", passed: false, detail: `Marketplace "${MARKETPLACE_NAME}" not configured correctly` };
6767
}
6868

scripts/src/local/commands/verify.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
import { join } from "node:path";
1919
import { homedir } from "node:os";
2020
import { setup } from "./setup.js";
21-
import { parseSkillContent } from "../../shared/parse-skill.js";
21+
import { parseSkillContent } from "../../shared/skill-helper.js";
2222

2323
const MARKETPLACE_NAME = "github-copilot-for-azure";
2424
const PLUGIN_NAME = "azure";

scripts/src/shared/__tests__/parse-skill.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import { describe, it, expect } from "vitest";
6-
import { parseSkillContent } from "../parse-skill.js";
6+
import { parseSkillContent } from "../skill-helper.js";
77

88
describe("parseSkillContent", () => {
99
it("parses valid frontmatter", () => {

scripts/src/shared/skill-helper.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* Skill Utility
3+
*
4+
* Shared helpers for loading, listing, and parsing SKILL.md files from the
5+
* plugin/skills directory. All frontmatter parsing goes through
6+
* `parseSkillContent` which normalises line endings, validates `---`
7+
* delimiters, and exposes the raw YAML source.
8+
*/
9+
10+
import * as fs from "node:fs";
11+
import * as path from "node:path";
12+
import { fileURLToPath } from "node:url";
13+
import matter from "gray-matter";
14+
15+
const __filename = fileURLToPath(import.meta.url);
16+
const __dirname = path.dirname(__filename);
17+
18+
// ── Types ────────────────────────────────────────────────────────────────────
19+
20+
/** Parsed frontmatter result from a SKILL.md file. */
21+
export interface ParsedSkill {
22+
/** Parsed key-value pairs (name, description, …). */
23+
data: Record<string, unknown>;
24+
/** Markdown body after the closing `---`. */
25+
content: string;
26+
/**
27+
* Raw frontmatter text between the `---` delimiters (not including the
28+
* delimiters themselves). Useful for checks that need the original YAML
29+
* source — e.g. detecting block scalars (`>-`, `|`) or XML-like tags.
30+
*/
31+
raw: string;
32+
}
33+
34+
interface SkillMetadata {
35+
name: string;
36+
description: string;
37+
[key: string]: unknown;
38+
}
39+
40+
export interface LoadedSkill {
41+
metadata: SkillMetadata;
42+
content: string;
43+
path: string;
44+
filePath: string;
45+
}
46+
47+
// ── Parser ───────────────────────────────────────────────────────────────────
48+
49+
/**
50+
* Parse SKILL.md file content and extract frontmatter.
51+
*
52+
* - Normalises `\r\n` → `\n` before parsing.
53+
* - Returns `null` when the file does not contain valid `---` delimited
54+
* frontmatter (instead of throwing).
55+
*/
56+
export function parseSkillContent(fileContent: string): ParsedSkill | null {
57+
// Normalise Windows line-endings
58+
const normalised = fileContent.replace(/\r\n/g, "\n");
59+
60+
// Quick guard: gray-matter is lenient — we require the file to start
61+
// with `---` (the agentskills.io spec mandates it).
62+
if (!normalised.startsWith("---")) return null;
63+
64+
// Also require a closing `---` delimiter. gray-matter treats
65+
// EOF as an implicit close, but the spec requires explicit delimiters.
66+
const closingIndex = normalised.indexOf("\n---", 3);
67+
if (closingIndex === -1) return null;
68+
69+
try {
70+
const result = matter(normalised);
71+
72+
// gray-matter sets `data` to `{}` when there is no frontmatter or
73+
// when the delimiters are malformed. Treat that as "no frontmatter".
74+
if (Object.keys(result.data).length === 0) return null;
75+
76+
// Extract the raw YAML block. gray-matter exposes `result.matter` in
77+
// recent versions but its behaviour across versions is inconsistent,
78+
// so we derive it ourselves from the normalised input.
79+
const raw = normalised.substring(4, closingIndex);
80+
81+
return {
82+
data: result.data as Record<string, unknown>,
83+
content: result.content,
84+
raw,
85+
};
86+
} catch {
87+
// YAML parse error → treat as "no valid frontmatter"
88+
return null;
89+
}
90+
}
91+
92+
// ── Loaders ──────────────────────────────────────────────────────────────────
93+
94+
/**
95+
* Load a skill by name.
96+
*
97+
* Reads the SKILL.md file from `plugin/skills/<skillName>` and parses it
98+
* via `parseSkillContent`. Throws when the file is missing or contains
99+
* no valid frontmatter.
100+
*/
101+
export function loadSkill(skillName: string): LoadedSkill {
102+
const skillPath = path.join(
103+
path.resolve(__dirname, "../../../plugin/skills"),
104+
skillName
105+
);
106+
const skillFile = path.join(skillPath, "SKILL.md");
107+
108+
if (!fs.existsSync(skillFile)) {
109+
throw new Error(`SKILL.md not found for skill: ${skillName} at ${skillFile}`);
110+
}
111+
112+
const fileContent = fs.readFileSync(skillFile, "utf-8");
113+
const parsed = parseSkillContent(fileContent);
114+
115+
if (!parsed) {
116+
throw new Error(`Invalid or missing frontmatter in SKILL.md for skill: ${skillName}`);
117+
}
118+
119+
return {
120+
metadata: {
121+
name: (parsed.data.name as string) || skillName,
122+
description: (parsed.data.description as string) || "",
123+
...parsed.data
124+
},
125+
content: parsed.content.trim(),
126+
path: skillPath,
127+
filePath: skillFile
128+
};
129+
}
130+
131+
/**
132+
* @returns Names of skills in azure plugin.
133+
*/
134+
export function listSkills(): string[] {
135+
const skillsDir = path.resolve(__dirname, "../../../plugin/skills");
136+
const items = fs.readdirSync(skillsDir, { withFileTypes: true });
137+
return items
138+
.filter((item) => item.isDirectory())
139+
.filter((item) => {
140+
const skillMdPath = path.join(skillsDir, item.name, "SKILL.md");
141+
return fs.existsSync(skillMdPath);
142+
})
143+
.map((item) => item.name);
144+
}

scripts/src/tokens/__tests__/utils.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ describe("loadConfig", () => {
4545
}
4646
};
4747
writeFileSync(join(TEST_DIR, ".token-limits.json"), JSON.stringify(testConfig));
48-
48+
4949
const config = loadConfig(TEST_DIR);
5050
expect(config.defaults["*.md"]).toBe(1500);
5151
expect(config.defaults["SKILL.md"]).toBe(3000);
@@ -54,15 +54,15 @@ describe("loadConfig", () => {
5454

5555
it("returns defaults for invalid JSON", () => {
5656
writeFileSync(join(TEST_DIR, ".token-limits.json"), "not valid json");
57-
57+
5858
const config = loadConfig(TEST_DIR);
5959
expect(config.defaults).toBeDefined();
6060
expect(config.defaults["*.md"]).toBeDefined();
6161
});
6262

6363
it("returns defaults for config missing defaults field", () => {
6464
writeFileSync(join(TEST_DIR, ".token-limits.json"), JSON.stringify({ overrides: {} }));
65-
65+
6666
const config = loadConfig(TEST_DIR);
6767
expect(config.defaults).toBeDefined();
6868
});
@@ -118,7 +118,7 @@ describe("getLimitForFile", () => {
118118
},
119119
overrides: {}
120120
};
121-
121+
122122
const result = getLimitForFile("plugin/skills/my-skill/SKILL.md", configWithMultiplePatterns, "/root");
123123
// SKILL.md exact filename match wins over globstar pattern due to specificity scoring
124124
// (no wildcards = +10000 points)
@@ -144,7 +144,7 @@ describe("findMarkdownFiles", () => {
144144
writeFileSync(join(TEST_DIR, "README.md"), "# Test");
145145
writeFileSync(join(TEST_DIR, "GUIDE.md"), "# Guide");
146146
writeFileSync(join(TEST_DIR, "script.ts"), 'console.log("hi")');
147-
147+
148148
const files = findMarkdownFiles(TEST_DIR);
149149
expect(files.length).toBe(2);
150150
expect(files.some(f => f.endsWith("README.md"))).toBe(true);
@@ -153,7 +153,7 @@ describe("findMarkdownFiles", () => {
153153

154154
it("finds .mdx files", () => {
155155
writeFileSync(join(TEST_DIR, "component.mdx"), "# MDX");
156-
156+
157157
const files = findMarkdownFiles(TEST_DIR);
158158
expect(files.length).toBe(1);
159159
expect(files[0].endsWith("component.mdx")).toBe(true);
@@ -164,7 +164,7 @@ describe("findMarkdownFiles", () => {
164164
writeFileSync(join(TEST_DIR, "root.md"), "# Root");
165165
writeFileSync(join(TEST_DIR, "sub", "sub.md"), "# Sub");
166166
writeFileSync(join(TEST_DIR, "sub", "deep", "deep.md"), "# Deep");
167-
167+
168168
const files = findMarkdownFiles(TEST_DIR);
169169
expect(files.length).toBe(3);
170170
});
@@ -173,7 +173,7 @@ describe("findMarkdownFiles", () => {
173173
mkdirSync(join(TEST_DIR, "node_modules"));
174174
writeFileSync(join(TEST_DIR, "README.md"), "# Test");
175175
writeFileSync(join(TEST_DIR, "node_modules", "pkg.md"), "# Package");
176-
176+
177177
const files = findMarkdownFiles(TEST_DIR);
178178
expect(files.length).toBe(1);
179179
expect(files[0].endsWith("README.md")).toBe(true);
@@ -183,7 +183,7 @@ describe("findMarkdownFiles", () => {
183183
mkdirSync(join(TEST_DIR, ".git"));
184184
writeFileSync(join(TEST_DIR, "README.md"), "# Test");
185185
writeFileSync(join(TEST_DIR, ".git", "config.md"), "# Config");
186-
186+
187187
const files = findMarkdownFiles(TEST_DIR);
188188
expect(files.length).toBe(1);
189189
});
@@ -196,7 +196,7 @@ describe("findMarkdownFiles", () => {
196196
it("returns empty array for directory with no markdown files", () => {
197197
writeFileSync(join(TEST_DIR, "script.ts"), "code");
198198
writeFileSync(join(TEST_DIR, "data.json"), "{}");
199-
199+
200200
const files = findMarkdownFiles(TEST_DIR);
201201
expect(files).toEqual([]);
202202
});

0 commit comments

Comments
 (0)