agentfold 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 +197 -0
- package/agentfold.mjs +1115 -0
- package/lib/compose-layers.mjs +23 -0
- package/lib/diff.mjs +47 -0
- package/lib/load-profile.mjs +112 -0
- package/lib/manifest.mjs +66 -0
- package/lib/pipeline-steps.mjs +853 -0
- package/lib/scope.mjs +158 -0
- package/lib/util.mjs +348 -0
- package/lib/validate.mjs +209 -0
- package/package.json +42 -0
package/lib/scope.mjs
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathExists, readJson } from './util.mjs';
|
|
3
|
+
|
|
4
|
+
const SELECTABLE_TYPES = ['skills', 'prompts', 'rules', 'commands', 'mcp', 'subagents'];
|
|
5
|
+
|
|
6
|
+
function asStringArray(value) {
|
|
7
|
+
if (!value) {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (Array.isArray(value)) {
|
|
12
|
+
return value.filter((item) => typeof item === 'string');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return typeof value === 'string' ? [value] : [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function toKebabCase(input) {
|
|
19
|
+
return String(input || '')
|
|
20
|
+
.trim()
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/\s+/g, '-')
|
|
23
|
+
.replace(/_/g, '-')
|
|
24
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
25
|
+
.replace(/-+/g, '-')
|
|
26
|
+
.replace(/^-|-$/g, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function readTagArray(tagsPath) {
|
|
30
|
+
const tags = await readJson(tagsPath);
|
|
31
|
+
if (!Array.isArray(tags)) {
|
|
32
|
+
throw new Error(`Expected array of tags at ${tagsPath}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return tags
|
|
36
|
+
.map((value) => toKebabCase(value))
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function loadScopeTagEnum({ configRoot }) {
|
|
41
|
+
const tagsPath = path.join(configRoot, 'config', 'definitions', 'scope-tags.json');
|
|
42
|
+
|
|
43
|
+
if (!(await pathExists(tagsPath))) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const tags = await readTagArray(tagsPath);
|
|
48
|
+
return [...new Set(tags)].sort((a, b) => a.localeCompare(b));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeTagSelector(rawTags) {
|
|
52
|
+
if (Array.isArray(rawTags)) {
|
|
53
|
+
return {
|
|
54
|
+
mode: 'any',
|
|
55
|
+
values: rawTags.map((value) => toKebabCase(value)).filter(Boolean),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!rawTags || typeof rawTags !== 'object') {
|
|
60
|
+
return { mode: 'any', values: [] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const mode = rawTags.mode === 'all' ? 'all' : 'any';
|
|
64
|
+
const values = asStringArray(rawTags.values)
|
|
65
|
+
.map((value) => toKebabCase(value))
|
|
66
|
+
.filter(Boolean);
|
|
67
|
+
|
|
68
|
+
return { mode, values };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function normalizeScopeSelector(rawSelector) {
|
|
72
|
+
const namespaces = asStringArray(rawSelector?.namespace)
|
|
73
|
+
.concat(asStringArray(rawSelector?.namespaces))
|
|
74
|
+
.map((value) => value.trim())
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
|
|
77
|
+
const tags = normalizeTagSelector(rawSelector?.tags);
|
|
78
|
+
|
|
79
|
+
return { namespaces, tags };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function emptyScopeSelector() {
|
|
83
|
+
return { namespaces: [], tags: { mode: 'any', values: [] } };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function normalizeProfileScopeSelectors(rawSelect) {
|
|
87
|
+
const normalized = {};
|
|
88
|
+
|
|
89
|
+
for (const type of SELECTABLE_TYPES) {
|
|
90
|
+
normalized[type] = normalizeScopeSelector(rawSelect?.[type]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return normalized;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function normalizeScopeMetadata(frontMatter) {
|
|
97
|
+
const scopeSource = frontMatter?.metadata?.scope || frontMatter?.scope || {};
|
|
98
|
+
|
|
99
|
+
const namespace = typeof scopeSource?.namespace === 'string'
|
|
100
|
+
? scopeSource.namespace.trim()
|
|
101
|
+
: '';
|
|
102
|
+
|
|
103
|
+
const tags = asStringArray(scopeSource?.tags)
|
|
104
|
+
.map((value) => toKebabCase(value))
|
|
105
|
+
.filter(Boolean);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
namespace,
|
|
109
|
+
tags: [...new Set(tags)].sort((a, b) => a.localeCompare(b)),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function matchesScope({ selector, metadata, fallbackCandidates = [] }) {
|
|
114
|
+
const hasSelector = selector.namespaces.length > 0 || selector.tags.values.length > 0;
|
|
115
|
+
if (!hasSelector) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const namespaceCandidates = [metadata?.namespace, ...fallbackCandidates]
|
|
120
|
+
.filter((value) => typeof value === 'string' && value.trim().length > 0);
|
|
121
|
+
|
|
122
|
+
const matchedNamespaces = selector.namespaces.length === 0
|
|
123
|
+
? true
|
|
124
|
+
: selector.namespaces.some((value) => namespaceCandidates.includes(value));
|
|
125
|
+
|
|
126
|
+
const metadataTags = new Set((metadata?.tags || []).map((value) => toKebabCase(value)));
|
|
127
|
+
const matchedTags = selector.tags.values.length === 0
|
|
128
|
+
? true
|
|
129
|
+
: selector.tags.mode === 'all'
|
|
130
|
+
? selector.tags.values.every((value) => metadataTags.has(value))
|
|
131
|
+
: selector.tags.values.some((value) => metadataTags.has(value));
|
|
132
|
+
|
|
133
|
+
return matchedNamespaces && matchedTags;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function validateScopeMetadata({ metadata, allowedTags, sourcePath, errors, warnings }) {
|
|
137
|
+
const invalidTags = metadata.tags.filter((tag) => !allowedTags.has(tag));
|
|
138
|
+
for (const tag of invalidTags) {
|
|
139
|
+
errors.push(`Unknown scope tag \"${tag}\" in ${sourcePath}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (metadata.namespace && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(metadata.namespace)) {
|
|
143
|
+
errors.push(`Invalid metadata.scope.namespace in ${sourcePath}. Use kebab-case.`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!metadata.namespace && metadata.tags.length === 0) {
|
|
147
|
+
warnings.push(`Missing scope metadata in ${sourcePath}. Add metadata.scope.namespace or metadata.scope.tags.`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function hasSelector(selector) {
|
|
152
|
+
if (!selector) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
return selector.namespaces.length > 0 || selector.tags.values.length > 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const selectableTypes = SELECTABLE_TYPES;
|
package/lib/util.mjs
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export async function ensureDir(dirPath) {
|
|
6
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function pathExists(targetPath) {
|
|
10
|
+
try {
|
|
11
|
+
await fs.access(targetPath);
|
|
12
|
+
return true;
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readJson(filePath) {
|
|
19
|
+
const text = await fs.readFile(filePath, 'utf8');
|
|
20
|
+
return JSON.parse(text);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function writeJson(filePath, value) {
|
|
24
|
+
await ensureDir(path.dirname(filePath));
|
|
25
|
+
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
26
|
+
await fs.writeFile(filePath, text, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function removeDir(targetPath) {
|
|
30
|
+
await fs.rm(targetPath, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function listFiles(rootDir) {
|
|
34
|
+
const result = [];
|
|
35
|
+
|
|
36
|
+
async function walk(currentDir) {
|
|
37
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
38
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
39
|
+
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
42
|
+
if (entry.isDirectory()) {
|
|
43
|
+
await walk(fullPath);
|
|
44
|
+
} else if (entry.isFile()) {
|
|
45
|
+
result.push(fullPath);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (await pathExists(rootDir)) {
|
|
51
|
+
await walk(rootDir);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function toPosixPath(filePath) {
|
|
58
|
+
return filePath.split(path.sep).join('/');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function sha256(content) {
|
|
62
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function sha256File(filePath) {
|
|
66
|
+
const content = await fs.readFile(filePath);
|
|
67
|
+
return sha256(content);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseFrontMatter(markdownText) {
|
|
71
|
+
const normalized = markdownText.replace(/\r\n/g, '\n');
|
|
72
|
+
if (!normalized.startsWith('---\n')) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const endIndex = normalized.indexOf('\n---\n', 4);
|
|
77
|
+
if (endIndex === -1) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const frontMatterBlock = normalized.slice(4, endIndex);
|
|
82
|
+
|
|
83
|
+
function parseScalar(rawValue) {
|
|
84
|
+
const value = rawValue.trim();
|
|
85
|
+
if (value === '') {
|
|
86
|
+
return '';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (value === 'true') {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (value === 'false') {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (/^-?\d+(?:\.\d+)?$/.test(value)) {
|
|
97
|
+
return Number(value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
101
|
+
const inner = value.slice(1, -1).trim();
|
|
102
|
+
if (!inner) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
return inner.split(',').map((part) => parseScalar(part.trim()));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return value.replace(/^['\"]|['\"]$/g, '');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function findNextMeaningful(lines, startIndex) {
|
|
112
|
+
for (let i = startIndex; i < lines.length; i += 1) {
|
|
113
|
+
const rawLine = lines[i];
|
|
114
|
+
const trimmed = rawLine.trim();
|
|
115
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
return { line: rawLine, trimmed };
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const lines = frontMatterBlock.split('\n');
|
|
124
|
+
const root = {};
|
|
125
|
+
const stack = [{ indent: -1, container: root, type: 'object' }];
|
|
126
|
+
|
|
127
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
128
|
+
const rawLine = lines[index];
|
|
129
|
+
const trimmed = rawLine.trim();
|
|
130
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const indent = rawLine.length - rawLine.trimStart().length;
|
|
135
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
136
|
+
stack.pop();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const frame = stack[stack.length - 1];
|
|
140
|
+
|
|
141
|
+
if (trimmed.startsWith('- ')) {
|
|
142
|
+
if (frame.type !== 'array') {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const valuePart = trimmed.slice(2).trim();
|
|
147
|
+
if (!valuePart) {
|
|
148
|
+
const objectValue = {};
|
|
149
|
+
frame.container.push(objectValue);
|
|
150
|
+
stack.push({ indent, container: objectValue, type: 'object' });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
frame.container.push(parseScalar(valuePart));
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const separatorIndex = trimmed.indexOf(':');
|
|
159
|
+
if (separatorIndex === -1 || frame.type !== 'object') {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const key = trimmed.slice(0, separatorIndex).trim();
|
|
164
|
+
const rawValue = trimmed.slice(separatorIndex + 1).trim();
|
|
165
|
+
if (rawValue) {
|
|
166
|
+
frame.container[key] = parseScalar(rawValue);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const next = findNextMeaningful(lines, index + 1);
|
|
171
|
+
const shouldUseArray = Boolean(next && next.line.length - next.line.trimStart().length > indent && next.trimmed.startsWith('- '));
|
|
172
|
+
|
|
173
|
+
if (shouldUseArray) {
|
|
174
|
+
const arrayValue = [];
|
|
175
|
+
frame.container[key] = arrayValue;
|
|
176
|
+
stack.push({ indent, container: arrayValue, type: 'array' });
|
|
177
|
+
} else {
|
|
178
|
+
const objectValue = {};
|
|
179
|
+
frame.container[key] = objectValue;
|
|
180
|
+
stack.push({ indent, container: objectValue, type: 'object' });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return root;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function parseCliArgs(argv) {
|
|
188
|
+
const options = {};
|
|
189
|
+
const positional = [];
|
|
190
|
+
|
|
191
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
192
|
+
const token = argv[i];
|
|
193
|
+
if (!token.startsWith('--')) {
|
|
194
|
+
positional.push(token);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const [key, directValue] = token.slice(2).split('=');
|
|
199
|
+
if (directValue !== undefined) {
|
|
200
|
+
options[key] = directValue;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const next = argv[i + 1];
|
|
205
|
+
if (!next || next.startsWith('--')) {
|
|
206
|
+
options[key] = true;
|
|
207
|
+
} else {
|
|
208
|
+
options[key] = next;
|
|
209
|
+
i += 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { options, positional };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function asArray(value) {
|
|
217
|
+
if (!value) {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
return Array.isArray(value) ? value : [value];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function createLogger({ quiet = false } = {}) {
|
|
224
|
+
return {
|
|
225
|
+
info(message) {
|
|
226
|
+
if (!quiet) {
|
|
227
|
+
console.log(message);
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
warn(message) {
|
|
231
|
+
console.warn(message);
|
|
232
|
+
},
|
|
233
|
+
error(message) {
|
|
234
|
+
console.error(message);
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function serializeYamlValue(value, indent = 0) {
|
|
240
|
+
const prefix = ' '.repeat(indent);
|
|
241
|
+
|
|
242
|
+
if (value === null || value === undefined) {
|
|
243
|
+
return '';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (typeof value === 'boolean' || typeof value === 'number') {
|
|
247
|
+
return String(value);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (typeof value === 'string') {
|
|
251
|
+
if (value.includes('\n') || value.includes(':') || value.includes('#')) {
|
|
252
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
253
|
+
}
|
|
254
|
+
return value;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (Array.isArray(value)) {
|
|
258
|
+
if (value.length === 0) {
|
|
259
|
+
return '[]';
|
|
260
|
+
}
|
|
261
|
+
if (value.every((item) => typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean')) {
|
|
262
|
+
return `[${value.map((item) => serializeYamlValue(item, 0)).join(', ')}]`;
|
|
263
|
+
}
|
|
264
|
+
const lines = value.map((item) => {
|
|
265
|
+
if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
|
|
266
|
+
const objectLines = Object.entries(item)
|
|
267
|
+
.map(([key, val]) => `${prefix} ${key}: ${serializeYamlValue(val, indent + 2)}`)
|
|
268
|
+
.join('\n');
|
|
269
|
+
return `${prefix}- \n${objectLines}`;
|
|
270
|
+
}
|
|
271
|
+
return `${prefix}- ${serializeYamlValue(item, indent + 2)}`;
|
|
272
|
+
});
|
|
273
|
+
return `\n${lines.join('\n')}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (typeof value === 'object') {
|
|
277
|
+
const lines = [];
|
|
278
|
+
for (const [key, val] of Object.entries(value)) {
|
|
279
|
+
if (typeof val === 'object' && val !== null) {
|
|
280
|
+
lines.push(`${prefix}${key}:${serializeYamlValue(val, indent + 2)}`);
|
|
281
|
+
} else {
|
|
282
|
+
lines.push(`${prefix}${key}: ${serializeYamlValue(val, indent + 2)}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return `\n${lines.join('\n')}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return String(value);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function stripScopeFrontMatter(content) {
|
|
292
|
+
const normalized = content.replace(/\r\n/g, '\n');
|
|
293
|
+
if (!normalized.startsWith('---\n')) {
|
|
294
|
+
return content;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const endIndex = normalized.indexOf('\n---\n', 4);
|
|
298
|
+
if (endIndex === -1) {
|
|
299
|
+
return content;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const frontMatter = parseFrontMatter(content);
|
|
303
|
+
if (!frontMatter) {
|
|
304
|
+
return content;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const hasMetadataScope = Boolean(frontMatter?.metadata?.scope);
|
|
308
|
+
const hasLegacyScope = Boolean(frontMatter?.scope);
|
|
309
|
+
if (!hasMetadataScope && !hasLegacyScope) {
|
|
310
|
+
return content;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const bodyContent = normalized.slice(endIndex + 5);
|
|
314
|
+
const { scope: _legacyScope, ...baseFields } = frontMatter;
|
|
315
|
+
const metadata = baseFields.metadata && typeof baseFields.metadata === 'object' && !Array.isArray(baseFields.metadata)
|
|
316
|
+
? baseFields.metadata
|
|
317
|
+
: null;
|
|
318
|
+
|
|
319
|
+
let remainingFields = baseFields;
|
|
320
|
+
if (metadata && Object.prototype.hasOwnProperty.call(metadata, 'scope')) {
|
|
321
|
+
const { scope: _metadataScope, ...remainingMetadata } = metadata;
|
|
322
|
+
if (Object.keys(remainingMetadata).length === 0) {
|
|
323
|
+
const { metadata: _removedMetadata, ...withoutMetadata } = baseFields;
|
|
324
|
+
remainingFields = withoutMetadata;
|
|
325
|
+
} else {
|
|
326
|
+
remainingFields = {
|
|
327
|
+
...baseFields,
|
|
328
|
+
metadata: remainingMetadata,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (Object.keys(remainingFields).length === 0) {
|
|
334
|
+
return bodyContent;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const yamlLines = [];
|
|
338
|
+
for (const [key, value] of Object.entries(remainingFields)) {
|
|
339
|
+
const serialized = serializeYamlValue(value, 0);
|
|
340
|
+
if (serialized.startsWith('\n')) {
|
|
341
|
+
yamlLines.push(`${key}:${serialized}`);
|
|
342
|
+
} else {
|
|
343
|
+
yamlLines.push(`${key}: ${serialized}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return `---\n${yamlLines.join('\n')}\n---\n${bodyContent}`;
|
|
348
|
+
}
|
package/lib/validate.mjs
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { parseFrontMatter } from './util.mjs';
|
|
3
|
+
import {
|
|
4
|
+
hasSelector,
|
|
5
|
+
normalizeScopeMetadata,
|
|
6
|
+
selectableTypes,
|
|
7
|
+
validateScopeMetadata,
|
|
8
|
+
} from './scope.mjs';
|
|
9
|
+
|
|
10
|
+
function pushError(errors, message) {
|
|
11
|
+
errors.push(message);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function pushWarning(warnings, message) {
|
|
15
|
+
warnings.push(message);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function validatePlan({ preset, skillDefinitions, promptDefinitions, subagentDefinitions }) {
|
|
19
|
+
const errors = [];
|
|
20
|
+
const warnings = [];
|
|
21
|
+
const allowedTags = new Set(Array.isArray(preset.scopeTags) ? preset.scopeTags : []);
|
|
22
|
+
|
|
23
|
+
for (const layer of preset.layers || []) {
|
|
24
|
+
for (const type of selectableTypes) {
|
|
25
|
+
const selector = layer.select?.[type];
|
|
26
|
+
if (!selector) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!['any', 'all'].includes(selector.tags?.mode)) {
|
|
31
|
+
pushError(errors, `Invalid tags.mode in layers.${layer.name}.select.${type}. Use "any" or "all".`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const tag of selector.tags?.values || []) {
|
|
35
|
+
if (!allowedTags.has(tag)) {
|
|
36
|
+
pushError(errors, `Unknown tag "${tag}" in layers.${layer.name}.select.${type}.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const namespace of selector.namespaces || []) {
|
|
41
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(namespace)) {
|
|
42
|
+
pushError(errors, `Invalid namespace "${namespace}" in layers.${layer.name}.select.${type}. Use kebab-case.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const skillByName = new Map();
|
|
49
|
+
for (const skill of skillDefinitions) {
|
|
50
|
+
const text = await fs.readFile(skill.skillFilePath, 'utf8').catch(() => null);
|
|
51
|
+
if (text === null) {
|
|
52
|
+
pushError(errors, `Missing SKILL.md for skill "${skill.name}" at ${skill.skillFilePath}`);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const fm = parseFrontMatter(text);
|
|
57
|
+
if (!fm) {
|
|
58
|
+
pushError(errors, `Missing YAML front matter in ${skill.skillFilePath}`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!fm.name) {
|
|
63
|
+
pushError(errors, `Missing front matter field "name" in ${skill.skillFilePath}`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!fm.description) {
|
|
68
|
+
pushError(errors, `Missing front matter field "description" in ${skill.skillFilePath}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
validateScopeMetadata({
|
|
72
|
+
metadata: normalizeScopeMetadata(fm),
|
|
73
|
+
allowedTags,
|
|
74
|
+
sourcePath: skill.skillFilePath,
|
|
75
|
+
errors,
|
|
76
|
+
warnings,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (fm.name !== skill.name) {
|
|
80
|
+
pushError(
|
|
81
|
+
errors,
|
|
82
|
+
`Skill name mismatch: folder "${skill.name}" but front matter name "${fm.name}" in ${skill.skillFilePath}`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!fm.name.includes('.') && !fm.name.includes('-')) {
|
|
87
|
+
pushWarning(
|
|
88
|
+
warnings,
|
|
89
|
+
`Skill "${fm.name}" has no prefix. Consider names like "francisco.topic.purpose" or "fh-topic-purpose".`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (skillByName.has(fm.name)) {
|
|
94
|
+
const previous = skillByName.get(fm.name);
|
|
95
|
+
pushError(
|
|
96
|
+
errors,
|
|
97
|
+
`Duplicate skill name "${fm.name}" across layers: ${previous.layer} and ${skill.layer}`
|
|
98
|
+
);
|
|
99
|
+
} else {
|
|
100
|
+
skillByName.set(fm.name, skill);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const promptNames = new Map();
|
|
105
|
+
for (const prompt of promptDefinitions) {
|
|
106
|
+
const key = prompt.name.toLowerCase();
|
|
107
|
+
if (!prompt.name.endsWith('.prompt.md')) {
|
|
108
|
+
pushError(errors, `Prompt must end with .prompt.md: ${prompt.sourcePath}`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (promptNames.has(key)) {
|
|
113
|
+
const previous = promptNames.get(key);
|
|
114
|
+
pushError(
|
|
115
|
+
errors,
|
|
116
|
+
`Prompt collision on destination ${prompt.name} across layers: ${previous.layer} and ${prompt.layer}`
|
|
117
|
+
);
|
|
118
|
+
} else {
|
|
119
|
+
promptNames.set(key, prompt);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const seenSubagentsByTarget = new Map();
|
|
124
|
+
for (const agent of subagentDefinitions) {
|
|
125
|
+
if (!seenSubagentsByTarget.has(agent.target)) {
|
|
126
|
+
seenSubagentsByTarget.set(agent.target, new Map());
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const perTarget = seenSubagentsByTarget.get(agent.target);
|
|
130
|
+
const key = agent.id.toLowerCase();
|
|
131
|
+
if (perTarget.has(key)) {
|
|
132
|
+
const previous = perTarget.get(key);
|
|
133
|
+
pushError(
|
|
134
|
+
errors,
|
|
135
|
+
`Subagent collision for target "${agent.target}" with id "${agent.id}" across layers: ${previous.layer} and ${agent.layer}`
|
|
136
|
+
);
|
|
137
|
+
} else {
|
|
138
|
+
perTarget.set(key, agent);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const text = await fs.readFile(agent.sourcePath, 'utf8');
|
|
142
|
+
const fm = parseFrontMatter(text);
|
|
143
|
+
if (fm) {
|
|
144
|
+
if (!fm.name) {
|
|
145
|
+
pushError(errors, `Missing front matter field "name" in subagent file ${agent.sourcePath}`);
|
|
146
|
+
}
|
|
147
|
+
if (!fm.description) {
|
|
148
|
+
pushError(errors, `Missing front matter field "description" in subagent file ${agent.sourcePath}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
validateScopeMetadata({
|
|
152
|
+
metadata: normalizeScopeMetadata(fm),
|
|
153
|
+
allowedTags,
|
|
154
|
+
sourcePath: agent.sourcePath,
|
|
155
|
+
errors,
|
|
156
|
+
warnings,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (agent.target === 'copilot' && !agent.name.endsWith('.agent.md')) {
|
|
161
|
+
pushError(errors, `Copilot subagent output must end with .agent.md: ${agent.name}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const perLayerCounts = new Map();
|
|
166
|
+
for (const layer of preset.layers || []) {
|
|
167
|
+
perLayerCounts.set(layer.name, {
|
|
168
|
+
skills: 0,
|
|
169
|
+
prompts: 0,
|
|
170
|
+
subagents: 0,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const skill of skillDefinitions) {
|
|
175
|
+
if (perLayerCounts.has(skill.layer)) {
|
|
176
|
+
perLayerCounts.get(skill.layer).skills += 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const prompt of promptDefinitions) {
|
|
181
|
+
if (perLayerCounts.has(prompt.layer)) {
|
|
182
|
+
perLayerCounts.get(prompt.layer).prompts += 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const subagent of subagentDefinitions) {
|
|
187
|
+
if (subagent.target !== 'portable') {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (perLayerCounts.has(subagent.layer)) {
|
|
191
|
+
perLayerCounts.get(subagent.layer).subagents += 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const layer of preset.layers || []) {
|
|
196
|
+
const counts = perLayerCounts.get(layer.name) || { skills: 0, prompts: 0, subagents: 0 };
|
|
197
|
+
if (hasSelector(layer.select?.skills) && counts.skills === 0) {
|
|
198
|
+
pushWarning(warnings, `layers.${layer.name}.select.skills matched no files.`);
|
|
199
|
+
}
|
|
200
|
+
if (hasSelector(layer.select?.prompts) && counts.prompts === 0) {
|
|
201
|
+
pushWarning(warnings, `layers.${layer.name}.select.prompts matched no files.`);
|
|
202
|
+
}
|
|
203
|
+
if (hasSelector(layer.select?.subagents) && counts.subagents === 0) {
|
|
204
|
+
pushWarning(warnings, `layers.${layer.name}.select.subagents matched no files.`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { errors, warnings };
|
|
209
|
+
}
|