1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
|
/**
* Pure, zero-I/O functions for the skills extension.
* All decision logic is input → output; no mocks needed for testing.
*/
/** A discovered skill entry (name + optional summary from metadata). */
export interface SkillEntry {
readonly name: string;
readonly summary?: string | undefined;
}
/** Result of parsing a skill file's metadata. */
export interface SkillMeta {
readonly summary?: string | undefined;
readonly hasMeta: boolean;
}
/**
* Parse skill file metadata.
* Line 1 = summary (when to use), Line 2 = "---" separator.
* Returns `{ hasMeta: true, summary }` when line 2 is `---`.
* Returns `{ hasMeta: false }` when line 2 is not `---` (malformed).
*/
export function parseSkillMeta(content: string): SkillMeta {
const lines = content.split("\n");
if (lines.length < 2) {
return { hasMeta: false };
}
const line2 = lines[1];
if (line2 === undefined || line2.trim() !== "---") {
return { hasMeta: false };
}
const summary = lines[0];
return { hasMeta: true, summary: summary?.trim() === "" ? undefined : summary };
}
/**
* Strip the metadata header from a loaded skill body.
* When hasMeta is true, returns lines 3+ (0-indexed: index 2 onward).
* When hasMeta is false, returns the whole file unchanged.
*/
export function stripLoadedBody(content: string, hasMeta: boolean): string {
if (!hasMeta) {
return content;
}
const lines = content.split("\n");
return lines.slice(2).join("\n");
}
/**
* Merge home and cwd skill entries. Cwd skills shadow home skills of the same name.
* Returns a deduplicated array sorted by name.
*/
export function mergeCatalog(
homeEntries: readonly SkillEntry[],
cwdEntries: readonly SkillEntry[],
): readonly SkillEntry[] {
const map = new Map<string, SkillEntry>();
for (const entry of homeEntries) {
map.set(entry.name, entry);
}
for (const entry of cwdEntries) {
map.set(entry.name, entry);
}
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Render the skill catalog into a description string for the tool definition.
* Lists all skills by name; appends summary only for skills with valid metadata.
*/
export function renderDescription(catalog: readonly SkillEntry[]): string {
if (catalog.length === 0) {
return "Load a skill by name. No skills are currently available.";
}
const lines = ["Load a skill by name. Available skills:"];
for (const entry of catalog) {
if (entry.summary !== undefined) {
lines.push(`- ${entry.name}: ${entry.summary}`);
} else {
lines.push(`- ${entry.name}`);
}
}
return lines.join("\n");
}
/**
* Validate that a skill name is a bare skill id (no path separators or traversal).
* Returns true if the name is safe, false if it contains `/`, `\`, `..`, or is empty.
*/
export function isValidSkillName(name: unknown): name is string {
if (typeof name !== "string" || name.length === 0) {
return false;
}
if (name.includes("/") || name.includes("\\")) {
return false;
}
if (name.includes("..")) {
return false;
}
return true;
}
/**
* Check that a resolved absolute path is within the base directory.
* Prefix check — catches `..` traversal and absolute paths outside base.
*/
export function isPathWithinDir(resolvedPath: string, base: string): boolean {
const normalizedBase = base.endsWith("/") ? base : `${base}/`;
return resolvedPath === base || resolvedPath.startsWith(normalizedBase);
}
|