@agi-cli/sdk 0.1.121 → 0.1.123
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 +1 -1
- package/src/core/src/tools/loader.ts +6 -0
- package/src/index.ts +38 -0
- package/src/skills/index.ts +34 -0
- package/src/skills/loader.ts +152 -0
- package/src/skills/parser.ts +108 -0
- package/src/skills/tool.ts +87 -0
- package/src/skills/types.ts +41 -0
- package/src/skills/validator.ts +110 -0
package/package.json
CHANGED
|
@@ -14,6 +14,7 @@ import { editTool } from './builtin/edit.ts';
|
|
|
14
14
|
import { buildWebSearchTool } from './builtin/websearch.ts';
|
|
15
15
|
import { buildTerminalTool } from './builtin/terminal.ts';
|
|
16
16
|
import type { TerminalManager } from '../terminals/index.ts';
|
|
17
|
+
import { initializeSkills, buildSkillTool } from '../../../skills/index.ts';
|
|
17
18
|
import fg from 'fast-glob';
|
|
18
19
|
import { dirname, isAbsolute, join } from 'node:path';
|
|
19
20
|
import { pathToFileURL } from 'node:url';
|
|
@@ -137,6 +138,11 @@ export async function discoverProjectTools(
|
|
|
137
138
|
const term = buildTerminalTool(projectRoot, globalTerminalManager);
|
|
138
139
|
tools.set(term.name, term.tool);
|
|
139
140
|
}
|
|
141
|
+
// Skills
|
|
142
|
+
// Always reinitialize to ensure skills are discovered for the current project
|
|
143
|
+
await initializeSkills(projectRoot);
|
|
144
|
+
const skillTool = buildSkillTool();
|
|
145
|
+
tools.set(skillTool.name, skillTool.tool);
|
|
140
146
|
|
|
141
147
|
async function loadFromBase(base: string | null | undefined) {
|
|
142
148
|
if (!base) return;
|
package/src/index.ts
CHANGED
|
@@ -208,3 +208,41 @@ export { z } from './core/src/index.ts';
|
|
|
208
208
|
// SDK-specific Agent Types
|
|
209
209
|
// =======================
|
|
210
210
|
export type { AgentConfig, AgentConfigEntry } from './agent/types.ts';
|
|
211
|
+
|
|
212
|
+
// =======================
|
|
213
|
+
// Skills (from internal skills module)
|
|
214
|
+
// =======================
|
|
215
|
+
export type {
|
|
216
|
+
SkillScope,
|
|
217
|
+
SkillMetadata,
|
|
218
|
+
SkillDefinition,
|
|
219
|
+
DiscoveredSkill,
|
|
220
|
+
SkillLoadResult,
|
|
221
|
+
SkillErrorResult,
|
|
222
|
+
SkillResult,
|
|
223
|
+
} from './skills/index.ts';
|
|
224
|
+
|
|
225
|
+
export {
|
|
226
|
+
validateMetadata as validateSkillMetadata,
|
|
227
|
+
validateSkillName,
|
|
228
|
+
SkillValidationError,
|
|
229
|
+
} from './skills/index.ts';
|
|
230
|
+
|
|
231
|
+
export { parseSkillFile, extractFrontmatter } from './skills/index.ts';
|
|
232
|
+
|
|
233
|
+
export {
|
|
234
|
+
discoverSkills,
|
|
235
|
+
loadSkill,
|
|
236
|
+
getSkillCache,
|
|
237
|
+
clearSkillCache,
|
|
238
|
+
findGitRoot,
|
|
239
|
+
listSkillsInDir,
|
|
240
|
+
} from './skills/index.ts';
|
|
241
|
+
|
|
242
|
+
export {
|
|
243
|
+
initializeSkills,
|
|
244
|
+
getDiscoveredSkills,
|
|
245
|
+
isSkillsInitialized,
|
|
246
|
+
buildSkillTool,
|
|
247
|
+
rebuildSkillDescription,
|
|
248
|
+
} from './skills/index.ts';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
SkillScope,
|
|
3
|
+
SkillMetadata,
|
|
4
|
+
SkillDefinition,
|
|
5
|
+
DiscoveredSkill,
|
|
6
|
+
SkillLoadResult,
|
|
7
|
+
SkillErrorResult,
|
|
8
|
+
SkillResult,
|
|
9
|
+
} from './types.ts';
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
validateMetadata,
|
|
13
|
+
validateSkillName,
|
|
14
|
+
SkillValidationError,
|
|
15
|
+
} from './validator.ts';
|
|
16
|
+
|
|
17
|
+
export { parseSkillFile, extractFrontmatter } from './parser.ts';
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
discoverSkills,
|
|
21
|
+
loadSkill,
|
|
22
|
+
getSkillCache,
|
|
23
|
+
clearSkillCache,
|
|
24
|
+
findGitRoot,
|
|
25
|
+
listSkillsInDir,
|
|
26
|
+
} from './loader.ts';
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
initializeSkills,
|
|
30
|
+
getDiscoveredSkills,
|
|
31
|
+
isSkillsInitialized,
|
|
32
|
+
buildSkillTool,
|
|
33
|
+
rebuildSkillDescription,
|
|
34
|
+
} from './tool.ts';
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { join, dirname } from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
import { parseSkillFile } from './parser.ts';
|
|
5
|
+
import type { SkillDefinition, DiscoveredSkill, SkillScope } from './types.ts';
|
|
6
|
+
import { getGlobalConfigDir, getHomeDir } from '../config/src/paths.ts';
|
|
7
|
+
|
|
8
|
+
const skillCache = new Map<string, SkillDefinition>();
|
|
9
|
+
|
|
10
|
+
const SKILL_DIRS = [
|
|
11
|
+
'.agi/skills',
|
|
12
|
+
'.claude/skills',
|
|
13
|
+
'.opencode/skills',
|
|
14
|
+
'.codex/skills',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export async function discoverSkills(
|
|
18
|
+
cwd: string,
|
|
19
|
+
repoRoot?: string,
|
|
20
|
+
): Promise<DiscoveredSkill[]> {
|
|
21
|
+
const skills = new Map<string, SkillDefinition>();
|
|
22
|
+
const home = getHomeDir();
|
|
23
|
+
|
|
24
|
+
const globalDirs = [
|
|
25
|
+
join(getGlobalConfigDir(), 'skills'),
|
|
26
|
+
join(home, '.claude/skills'),
|
|
27
|
+
join(home, '.config/opencode/skills'),
|
|
28
|
+
join(home, '.codex/skills'),
|
|
29
|
+
];
|
|
30
|
+
for (const dir of globalDirs) {
|
|
31
|
+
await loadSkillsFromDir(dir, 'user', skills);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (repoRoot && repoRoot !== cwd) {
|
|
35
|
+
for (const skillDir of SKILL_DIRS) {
|
|
36
|
+
await loadSkillsFromDir(join(repoRoot, skillDir), 'repo', skills);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let current = cwd;
|
|
41
|
+
const visited = new Set<string>();
|
|
42
|
+
while (current && !visited.has(current)) {
|
|
43
|
+
visited.add(current);
|
|
44
|
+
const scope: SkillScope =
|
|
45
|
+
current === cwd ? 'cwd' : current === repoRoot ? 'repo' : 'parent';
|
|
46
|
+
for (const skillDir of SKILL_DIRS) {
|
|
47
|
+
await loadSkillsFromDir(join(current, skillDir), scope, skills);
|
|
48
|
+
}
|
|
49
|
+
const parent = dirname(current);
|
|
50
|
+
if (parent === current) break;
|
|
51
|
+
if (repoRoot && !current.startsWith(repoRoot)) break;
|
|
52
|
+
current = parent;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
skillCache.clear();
|
|
56
|
+
for (const [name, def] of skills) {
|
|
57
|
+
skillCache.set(name, def);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return Array.from(skills.values()).map((s) => ({
|
|
61
|
+
name: s.metadata.name,
|
|
62
|
+
description: s.metadata.description,
|
|
63
|
+
path: s.path,
|
|
64
|
+
scope: s.scope,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function loadSkill(name: string): Promise<SkillDefinition | null> {
|
|
69
|
+
return skillCache.get(name) ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function getSkillCache(): Map<string, SkillDefinition> {
|
|
73
|
+
return skillCache;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function clearSkillCache(): void {
|
|
77
|
+
skillCache.clear();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function loadSkillsFromDir(
|
|
81
|
+
dir: string,
|
|
82
|
+
scope: SkillScope,
|
|
83
|
+
skills: Map<string, SkillDefinition>,
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
try {
|
|
86
|
+
await fs.access(dir);
|
|
87
|
+
} catch {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const pattern = '*/SKILL.md';
|
|
92
|
+
let files: string[];
|
|
93
|
+
try {
|
|
94
|
+
files = await fg(pattern, { cwd: dir, absolute: true });
|
|
95
|
+
} catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const filePath of files) {
|
|
100
|
+
try {
|
|
101
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
102
|
+
const skill = parseSkillFile(content, filePath, scope);
|
|
103
|
+
|
|
104
|
+
const dirName = dirname(filePath).split('/').pop();
|
|
105
|
+
if (dirName !== skill.metadata.name) {
|
|
106
|
+
if (process.env.AGI_DEBUG === '1') {
|
|
107
|
+
console.warn(
|
|
108
|
+
`Skill name '${skill.metadata.name}' doesn't match directory '${dirName}' in ${filePath}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
skills.set(skill.metadata.name, skill);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (process.env.AGI_DEBUG === '1') {
|
|
116
|
+
console.error(`Failed to load skill from ${filePath}:`, err);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function findGitRoot(startDir: string): Promise<string | null> {
|
|
123
|
+
let current = startDir;
|
|
124
|
+
const visited = new Set<string>();
|
|
125
|
+
|
|
126
|
+
while (current && !visited.has(current)) {
|
|
127
|
+
visited.add(current);
|
|
128
|
+
try {
|
|
129
|
+
await fs.access(join(current, '.git'));
|
|
130
|
+
return current;
|
|
131
|
+
} catch {
|
|
132
|
+
const parent = dirname(current);
|
|
133
|
+
if (parent === current) break;
|
|
134
|
+
current = parent;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function listSkillsInDir(dir: string): Promise<string[]> {
|
|
142
|
+
try {
|
|
143
|
+
await fs.access(dir);
|
|
144
|
+
} catch {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const pattern = '*/SKILL.md';
|
|
149
|
+
const files = await fg(pattern, { cwd: dir, absolute: false });
|
|
150
|
+
|
|
151
|
+
return files.map((f) => dirname(f));
|
|
152
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { SkillDefinition, SkillMetadata, SkillScope } from './types.ts';
|
|
2
|
+
import { validateMetadata } from './validator.ts';
|
|
3
|
+
|
|
4
|
+
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
|
|
5
|
+
|
|
6
|
+
export function parseSkillFile(
|
|
7
|
+
content: string,
|
|
8
|
+
path: string,
|
|
9
|
+
scope: SkillScope,
|
|
10
|
+
): SkillDefinition {
|
|
11
|
+
const match = content.match(FRONTMATTER_REGEX);
|
|
12
|
+
if (!match) {
|
|
13
|
+
throw new Error(`Invalid SKILL.md format: missing frontmatter in ${path}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const [, yamlStr, body] = match;
|
|
17
|
+
if (!yamlStr) {
|
|
18
|
+
throw new Error(`Empty frontmatter in ${path}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const metadata = parseYamlFrontmatter(yamlStr, path);
|
|
22
|
+
validateMetadata(metadata, path);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
metadata: metadata as SkillMetadata,
|
|
26
|
+
content: body?.trim() ?? '',
|
|
27
|
+
path,
|
|
28
|
+
scope,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseYamlFrontmatter(
|
|
33
|
+
yaml: string,
|
|
34
|
+
_path: string,
|
|
35
|
+
): Record<string, unknown> {
|
|
36
|
+
const result: Record<string, unknown> = {};
|
|
37
|
+
const lines = yaml.split('\n');
|
|
38
|
+
let currentKey: string | null = null;
|
|
39
|
+
let currentIndent = 0;
|
|
40
|
+
let nestedObject: Record<string, string> | null = null;
|
|
41
|
+
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
if (!line.trim()) continue;
|
|
44
|
+
|
|
45
|
+
const indent = line.search(/\S/);
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
|
|
48
|
+
if (indent === 0 || (indent <= currentIndent && nestedObject)) {
|
|
49
|
+
if (nestedObject && currentKey) {
|
|
50
|
+
result[currentKey] = nestedObject;
|
|
51
|
+
nestedObject = null;
|
|
52
|
+
currentKey = null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const colonIdx = trimmed.indexOf(':');
|
|
57
|
+
if (colonIdx === -1) continue;
|
|
58
|
+
|
|
59
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
60
|
+
const value = trimmed.slice(colonIdx + 1).trim();
|
|
61
|
+
|
|
62
|
+
if (indent > 0 && nestedObject) {
|
|
63
|
+
nestedObject[key] = parseYamlValue(value);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!value) {
|
|
68
|
+
currentKey = normalizeKey(key);
|
|
69
|
+
currentIndent = indent;
|
|
70
|
+
nestedObject = {};
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
result[normalizeKey(key)] = parseYamlValue(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (nestedObject && currentKey) {
|
|
78
|
+
result[currentKey] = nestedObject;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeKey(key: string): string {
|
|
85
|
+
if (key === 'allowed-tools') return 'allowedTools';
|
|
86
|
+
return key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseYamlValue(value: string): string {
|
|
90
|
+
if (
|
|
91
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
92
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
93
|
+
) {
|
|
94
|
+
return value.slice(1, -1);
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function extractFrontmatter(
|
|
100
|
+
content: string,
|
|
101
|
+
): { frontmatter: string; body: string } | null {
|
|
102
|
+
const match = content.match(FRONTMATTER_REGEX);
|
|
103
|
+
if (!match) return null;
|
|
104
|
+
return {
|
|
105
|
+
frontmatter: match[1] ?? '',
|
|
106
|
+
body: match[2] ?? '',
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { tool, type Tool } from 'ai';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { loadSkill, discoverSkills, findGitRoot } from './loader.ts';
|
|
4
|
+
import type { DiscoveredSkill, SkillResult } from './types.ts';
|
|
5
|
+
|
|
6
|
+
let cachedSkillList: DiscoveredSkill[] = [];
|
|
7
|
+
let initializedForPath: string | null = null;
|
|
8
|
+
|
|
9
|
+
export async function initializeSkills(
|
|
10
|
+
cwd: string,
|
|
11
|
+
repoRoot?: string,
|
|
12
|
+
): Promise<DiscoveredSkill[]> {
|
|
13
|
+
const root = repoRoot ?? (await findGitRoot(cwd)) ?? cwd;
|
|
14
|
+
cachedSkillList = await discoverSkills(cwd, root);
|
|
15
|
+
initializedForPath = cwd;
|
|
16
|
+
return cachedSkillList;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getDiscoveredSkills(): DiscoveredSkill[] {
|
|
20
|
+
return cachedSkillList;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isSkillsInitialized(forPath?: string): boolean {
|
|
24
|
+
if (!initializedForPath) return false;
|
|
25
|
+
if (forPath && forPath !== initializedForPath) return false;
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildSkillTool(): { name: string; tool: Tool } {
|
|
30
|
+
const skillTool = tool({
|
|
31
|
+
description: buildSkillDescription(),
|
|
32
|
+
inputSchema: z.object({
|
|
33
|
+
name: z.string().describe('Name of the skill to load'),
|
|
34
|
+
}),
|
|
35
|
+
async execute({ name }: { name: string }): Promise<SkillResult> {
|
|
36
|
+
const skill = await loadSkill(name);
|
|
37
|
+
if (!skill) {
|
|
38
|
+
return { ok: false, error: `Skill '${name}' not found` };
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
ok: true,
|
|
42
|
+
name: skill.metadata.name,
|
|
43
|
+
description: skill.metadata.description,
|
|
44
|
+
content: skill.content,
|
|
45
|
+
path: skill.path,
|
|
46
|
+
scope: skill.scope,
|
|
47
|
+
allowedTools: skill.metadata.allowedTools,
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return { name: 'skill', tool: skillTool };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildSkillDescription(): string {
|
|
56
|
+
if (cachedSkillList.length === 0) {
|
|
57
|
+
return 'Load a skill by name. No skills are currently available.';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const skillsXml = cachedSkillList
|
|
61
|
+
.map(
|
|
62
|
+
(s) =>
|
|
63
|
+
`<skill><name>${escapeXml(s.name)}</name><description>${escapeXml(s.description)}</description></skill>`,
|
|
64
|
+
)
|
|
65
|
+
.join('\n');
|
|
66
|
+
|
|
67
|
+
return `Load a skill by name to get detailed instructions.
|
|
68
|
+
|
|
69
|
+
<available_skills>
|
|
70
|
+
${skillsXml}
|
|
71
|
+
</available_skills>
|
|
72
|
+
|
|
73
|
+
Call this tool with the skill name when you need the full instructions.`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function escapeXml(str: string): string {
|
|
77
|
+
return str
|
|
78
|
+
.replace(/&/g, '&')
|
|
79
|
+
.replace(/</g, '<')
|
|
80
|
+
.replace(/>/g, '>')
|
|
81
|
+
.replace(/"/g, '"')
|
|
82
|
+
.replace(/'/g, ''');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function rebuildSkillDescription(): string {
|
|
86
|
+
return buildSkillDescription();
|
|
87
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type SkillScope = 'cwd' | 'parent' | 'repo' | 'user' | 'system';
|
|
2
|
+
|
|
3
|
+
export interface SkillMetadata {
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
license?: string;
|
|
7
|
+
compatibility?: string;
|
|
8
|
+
metadata?: Record<string, string>;
|
|
9
|
+
allowedTools?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SkillDefinition {
|
|
13
|
+
metadata: SkillMetadata;
|
|
14
|
+
content: string;
|
|
15
|
+
path: string;
|
|
16
|
+
scope: SkillScope;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DiscoveredSkill {
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
path: string;
|
|
23
|
+
scope: SkillScope;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SkillLoadResult {
|
|
27
|
+
ok: true;
|
|
28
|
+
name: string;
|
|
29
|
+
description: string;
|
|
30
|
+
content: string;
|
|
31
|
+
path: string;
|
|
32
|
+
scope: SkillScope;
|
|
33
|
+
allowedTools?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SkillErrorResult {
|
|
37
|
+
ok: false;
|
|
38
|
+
error: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type SkillResult = SkillLoadResult | SkillErrorResult;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { SkillMetadata } from './types.ts';
|
|
2
|
+
|
|
3
|
+
const NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
4
|
+
const MAX_NAME_LENGTH = 64;
|
|
5
|
+
const MAX_DESCRIPTION_LENGTH = 1024;
|
|
6
|
+
const MAX_COMPATIBILITY_LENGTH = 500;
|
|
7
|
+
|
|
8
|
+
export class SkillValidationError extends Error {
|
|
9
|
+
constructor(
|
|
10
|
+
message: string,
|
|
11
|
+
public path: string,
|
|
12
|
+
) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'SkillValidationError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validateMetadata(
|
|
19
|
+
meta: unknown,
|
|
20
|
+
path: string,
|
|
21
|
+
): asserts meta is SkillMetadata {
|
|
22
|
+
if (!meta || typeof meta !== 'object') {
|
|
23
|
+
throw new SkillValidationError(`Invalid frontmatter in ${path}`, path);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const m = meta as Record<string, unknown>;
|
|
27
|
+
|
|
28
|
+
if (typeof m.name !== 'string' || !m.name) {
|
|
29
|
+
throw new SkillValidationError(
|
|
30
|
+
`Missing required 'name' field in ${path}`,
|
|
31
|
+
path,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (m.name.length > MAX_NAME_LENGTH) {
|
|
35
|
+
throw new SkillValidationError(
|
|
36
|
+
`Skill name exceeds ${MAX_NAME_LENGTH} chars in ${path}`,
|
|
37
|
+
path,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (!NAME_REGEX.test(m.name)) {
|
|
41
|
+
throw new SkillValidationError(
|
|
42
|
+
`Invalid skill name '${m.name}' - must be lowercase alphanumeric with hyphens, no start/end hyphens, no consecutive hyphens`,
|
|
43
|
+
path,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof m.description !== 'string' || !m.description) {
|
|
48
|
+
throw new SkillValidationError(
|
|
49
|
+
`Missing required 'description' field in ${path}`,
|
|
50
|
+
path,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (m.description.length > MAX_DESCRIPTION_LENGTH) {
|
|
54
|
+
throw new SkillValidationError(
|
|
55
|
+
`Description exceeds ${MAX_DESCRIPTION_LENGTH} chars in ${path}`,
|
|
56
|
+
path,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (
|
|
61
|
+
m.compatibility &&
|
|
62
|
+
typeof m.compatibility === 'string' &&
|
|
63
|
+
m.compatibility.length > MAX_COMPATIBILITY_LENGTH
|
|
64
|
+
) {
|
|
65
|
+
throw new SkillValidationError(
|
|
66
|
+
`Compatibility exceeds ${MAX_COMPATIBILITY_LENGTH} chars in ${path}`,
|
|
67
|
+
path,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (m.metadata !== undefined) {
|
|
72
|
+
if (typeof m.metadata !== 'object' || m.metadata === null) {
|
|
73
|
+
throw new SkillValidationError(
|
|
74
|
+
`metadata must be an object in ${path}`,
|
|
75
|
+
path,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
for (const [key, value] of Object.entries(
|
|
79
|
+
m.metadata as Record<string, unknown>,
|
|
80
|
+
)) {
|
|
81
|
+
if (typeof value !== 'string') {
|
|
82
|
+
throw new SkillValidationError(
|
|
83
|
+
`metadata.${key} must be a string in ${path}`,
|
|
84
|
+
path,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (m['allowed-tools'] !== undefined && m.allowedTools === undefined) {
|
|
91
|
+
m.allowedTools = m['allowed-tools'];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (m.allowedTools !== undefined) {
|
|
95
|
+
if (typeof m.allowedTools === 'string') {
|
|
96
|
+
m.allowedTools = m.allowedTools.split(/\s+/).filter(Boolean);
|
|
97
|
+
}
|
|
98
|
+
if (!Array.isArray(m.allowedTools)) {
|
|
99
|
+
throw new SkillValidationError(
|
|
100
|
+
`allowed-tools must be a string or array in ${path}`,
|
|
101
|
+
path,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function validateSkillName(name: string): boolean {
|
|
108
|
+
if (!name || name.length > MAX_NAME_LENGTH) return false;
|
|
109
|
+
return NAME_REGEX.test(name);
|
|
110
|
+
}
|