@hagicode/skillsbase 0.1.0-dev.1.1.426f4b0
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 +132 -0
- package/README.zh-CN.md +127 -0
- package/bin/skillsbase.mjs +44 -0
- package/dist/cli.mjs +1109 -0
- package/package.json +53 -0
- package/templates/actions/skillsbase-manage/action.yml +96 -0
- package/templates/actions/skillsbase-sync/action.yml +44 -0
- package/templates/docs/maintainer-workflow.md +32 -0
- package/templates/skills/README.md +9 -0
- package/templates/sources.yaml +25 -0
- package/templates/workflows/skills-manage.yml +54 -0
- package/templates/workflows/skills-sync.yml +30 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { promises } from "node:fs";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
//#region src/lib/output.ts
|
|
8
|
+
var CliError = class extends Error {
|
|
9
|
+
exitCode;
|
|
10
|
+
details;
|
|
11
|
+
nextSteps;
|
|
12
|
+
constructor(message, options = {}) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "CliError";
|
|
15
|
+
this.exitCode = options.exitCode ?? 1;
|
|
16
|
+
this.details = options.details ?? [];
|
|
17
|
+
this.nextSteps = options.nextSteps ?? [];
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
function printCommandUsage(stdout) {
|
|
21
|
+
stdout.write([
|
|
22
|
+
"Usage: skillsbase <command> [options]",
|
|
23
|
+
"",
|
|
24
|
+
"Commands:",
|
|
25
|
+
" init Create the managed repository baseline",
|
|
26
|
+
" sync Reconcile managed skills from sources.yaml",
|
|
27
|
+
" add Add a skill to a source block and sync",
|
|
28
|
+
" remove Remove a skill from a source block and sync",
|
|
29
|
+
" github_action Generate managed GitHub Actions assets",
|
|
30
|
+
"",
|
|
31
|
+
"Global Options:",
|
|
32
|
+
" --repo <path> Target repository path (default: current directory)",
|
|
33
|
+
" --allow-missing-sources Skip missing local source roots during sync/add/remove",
|
|
34
|
+
" --help, -h Show help",
|
|
35
|
+
" --version, -v Show version",
|
|
36
|
+
""
|
|
37
|
+
].join("\n"));
|
|
38
|
+
}
|
|
39
|
+
function printCommandResult(result, output) {
|
|
40
|
+
const lines = [
|
|
41
|
+
`## ${result.title ?? result.command}`,
|
|
42
|
+
"",
|
|
43
|
+
`repository: ${result.repository}`,
|
|
44
|
+
`exit_code: ${result.exitCode ?? 0}`
|
|
45
|
+
];
|
|
46
|
+
if (result.schema) lines.push(`schema: ${result.schema}`);
|
|
47
|
+
if (result.items?.length) {
|
|
48
|
+
lines.push("", "items:");
|
|
49
|
+
for (const item of result.items) lines.push(`- ${item}`);
|
|
50
|
+
}
|
|
51
|
+
if (result.nextSteps?.length) {
|
|
52
|
+
lines.push("", "next:");
|
|
53
|
+
for (const nextStep of result.nextSteps) lines.push(`- ${nextStep}`);
|
|
54
|
+
}
|
|
55
|
+
lines.push("");
|
|
56
|
+
output.write(`${lines.join("\n")}\n`);
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/lib/constants.ts
|
|
60
|
+
var MANAGED_SIGNATURE = "Managed by skillsbase CLI";
|
|
61
|
+
var DEFAULT_SKILLS_CLI_VERSION = "1.4.8";
|
|
62
|
+
var DEFAULT_INSTALL_AGENT = "codex";
|
|
63
|
+
var DEFAULT_SKILLS_ROOT = "skills";
|
|
64
|
+
var DEFAULT_METADATA_FILE = ".skill-source.json";
|
|
65
|
+
var DEFAULT_MANAGED_BY = "skillsbase";
|
|
66
|
+
var DEFAULT_NODE_VERSION = "22.12.0";
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region src/lib/files.ts
|
|
69
|
+
async function pathExists(targetPath) {
|
|
70
|
+
try {
|
|
71
|
+
await promises.access(targetPath);
|
|
72
|
+
return true;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function readFileIfExists(targetPath, encoding = "utf8") {
|
|
78
|
+
if (!await pathExists(targetPath)) return null;
|
|
79
|
+
return promises.readFile(targetPath, encoding);
|
|
80
|
+
}
|
|
81
|
+
async function ensureDirectory(targetPath) {
|
|
82
|
+
await promises.mkdir(targetPath, { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
async function listDirectories(rootPath) {
|
|
85
|
+
if (!await pathExists(rootPath)) return [];
|
|
86
|
+
return (await promises.readdir(rootPath, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort((left, right) => left.localeCompare(right));
|
|
87
|
+
}
|
|
88
|
+
async function collectRelativeFiles(rootPath, basePath = rootPath) {
|
|
89
|
+
const entries = await promises.readdir(rootPath, { withFileTypes: true });
|
|
90
|
+
const files = [];
|
|
91
|
+
for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name))) {
|
|
92
|
+
const absolutePath = path.join(rootPath, entry.name);
|
|
93
|
+
if (entry.isDirectory()) {
|
|
94
|
+
files.push(...await collectRelativeFiles(absolutePath, basePath));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (entry.isFile()) files.push(toPosix(path.relative(basePath, absolutePath)));
|
|
98
|
+
}
|
|
99
|
+
return files;
|
|
100
|
+
}
|
|
101
|
+
async function readTree(rootPath) {
|
|
102
|
+
const files = await collectRelativeFiles(rootPath);
|
|
103
|
+
const tree = /* @__PURE__ */ new Map();
|
|
104
|
+
for (const relativePath of files) tree.set(relativePath, await promises.readFile(path.join(rootPath, relativePath)));
|
|
105
|
+
return tree;
|
|
106
|
+
}
|
|
107
|
+
async function writeTree(rootPath, tree) {
|
|
108
|
+
await promises.rm(rootPath, {
|
|
109
|
+
recursive: true,
|
|
110
|
+
force: true
|
|
111
|
+
});
|
|
112
|
+
await promises.mkdir(rootPath, { recursive: true });
|
|
113
|
+
for (const [relativePath, content] of [...tree.entries()].sort(([left], [right]) => left.localeCompare(right))) {
|
|
114
|
+
const targetPath = path.join(rootPath, relativePath);
|
|
115
|
+
await promises.mkdir(path.dirname(targetPath), { recursive: true });
|
|
116
|
+
await promises.writeFile(targetPath, content);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function removeIfEmptyUpward(startPath, stopPath) {
|
|
120
|
+
let currentPath = startPath;
|
|
121
|
+
const normalizedStop = path.resolve(stopPath);
|
|
122
|
+
while (currentPath.startsWith(normalizedStop) && currentPath !== normalizedStop) {
|
|
123
|
+
if (!await pathExists(currentPath)) {
|
|
124
|
+
currentPath = path.dirname(currentPath);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if ((await promises.readdir(currentPath)).length > 0) return;
|
|
128
|
+
await promises.rmdir(currentPath);
|
|
129
|
+
currentPath = path.dirname(currentPath);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function stableJson(value) {
|
|
133
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
134
|
+
}
|
|
135
|
+
function toPosix(filePath) {
|
|
136
|
+
return filePath.split(path.sep).join(path.posix.sep);
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/lib/manifest.ts
|
|
140
|
+
function parseScalar(rawValue) {
|
|
141
|
+
const value = rawValue.trim();
|
|
142
|
+
if (value.length === 0) return "";
|
|
143
|
+
if (value === "true") return true;
|
|
144
|
+
if (value === "false") return false;
|
|
145
|
+
if (/^-?\d+$/.test(value)) return Number(value);
|
|
146
|
+
if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
function quoteYamlString(value) {
|
|
150
|
+
if (value === "") return "\"\"";
|
|
151
|
+
if (/^[A-Za-z0-9._/@:+-]+$/.test(value)) return value;
|
|
152
|
+
return JSON.stringify(value);
|
|
153
|
+
}
|
|
154
|
+
function validateSource(source) {
|
|
155
|
+
for (const key of [
|
|
156
|
+
"key",
|
|
157
|
+
"label",
|
|
158
|
+
"kind",
|
|
159
|
+
"root",
|
|
160
|
+
"targetPrefix",
|
|
161
|
+
"include"
|
|
162
|
+
]) if (!(key in source)) throw new CliError(`Source "${source.key ?? "<unknown>"}" is missing key "${key}".`, { details: ["Repair `sources.yaml` or rerun `skillsbase init`."] });
|
|
163
|
+
if (!Array.isArray(source.include) || source.include.some((value) => typeof value !== "string")) throw new CliError(`Source "${source.key}" must define an include list.`, { details: ["Use `include: []` only via the CLI serializer; do not change its type."] });
|
|
164
|
+
return {
|
|
165
|
+
key: String(source.key),
|
|
166
|
+
label: String(source.label),
|
|
167
|
+
kind: String(source.kind),
|
|
168
|
+
root: String(source.root),
|
|
169
|
+
targetPrefix: String(source.targetPrefix),
|
|
170
|
+
include: [...source.include]
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function isRemoteRepositorySource(source) {
|
|
174
|
+
return source.kind === "github-repository";
|
|
175
|
+
}
|
|
176
|
+
function buildSourcePath(source, originalName) {
|
|
177
|
+
if (isRemoteRepositorySource(source)) return `${source.root}@${originalName}`;
|
|
178
|
+
return path.join(source.root, originalName);
|
|
179
|
+
}
|
|
180
|
+
function resolveSourceRoot(repoPath, source) {
|
|
181
|
+
if (isRemoteRepositorySource(source) || path.isAbsolute(source.root)) return source.root;
|
|
182
|
+
return path.resolve(repoPath, source.root);
|
|
183
|
+
}
|
|
184
|
+
function resolveSourcePath(repoPath, source, originalName) {
|
|
185
|
+
if (isRemoteRepositorySource(source)) return buildSourcePath(source, originalName);
|
|
186
|
+
return path.join(resolveSourceRoot(repoPath, source), originalName);
|
|
187
|
+
}
|
|
188
|
+
function cloneSource(source) {
|
|
189
|
+
return {
|
|
190
|
+
...source,
|
|
191
|
+
include: [...source.include ?? []]
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function cloneManifestWithSources(manifest, sources) {
|
|
195
|
+
return {
|
|
196
|
+
...manifest,
|
|
197
|
+
sources: sources.map(cloneSource)
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function sortInclude(include) {
|
|
201
|
+
return [...include].sort((left, right) => left.localeCompare(right));
|
|
202
|
+
}
|
|
203
|
+
function assertManifestHasSources(manifest) {
|
|
204
|
+
if (manifest.sources.length === 0) throw new CliError("Manifest does not declare any source blocks.", { details: ["Run `skillsbase init` first or add a source block to `sources.yaml`."] });
|
|
205
|
+
}
|
|
206
|
+
function getSourceKeys(manifest) {
|
|
207
|
+
return manifest.sources.map((source) => source.key).join(", ");
|
|
208
|
+
}
|
|
209
|
+
function findSourceByKey(manifest, sourceKey) {
|
|
210
|
+
const source = manifest.sources.find((candidate) => candidate.key === sourceKey);
|
|
211
|
+
if (!source) throw new CliError(`Unknown source key: ${sourceKey}`, { details: [`Declared sources: ${getSourceKeys(manifest)}`] });
|
|
212
|
+
return source;
|
|
213
|
+
}
|
|
214
|
+
function findSourcesBySkill(manifest, skillName) {
|
|
215
|
+
return manifest.sources.filter((source) => source.include.includes(skillName));
|
|
216
|
+
}
|
|
217
|
+
function buildMissingSkillError(skillName, options = {}) {
|
|
218
|
+
const matchingKeys = options.matchingKeys ?? [];
|
|
219
|
+
const details = options.sourceKey ? [`skill: ${skillName}`, `source: ${options.sourceKey}`] : [`skill: ${skillName}`];
|
|
220
|
+
if (matchingKeys.length > 0) details.push(`matching sources: ${matchingKeys.join(", ")}`);
|
|
221
|
+
return new CliError(options.sourceKey == null ? `Skill "${skillName}" is not declared in sources.yaml.` : `Skill "${skillName}" is not declared in source "${options.sourceKey}".`, { details });
|
|
222
|
+
}
|
|
223
|
+
function createManifest(repoPath, options = {}) {
|
|
224
|
+
return {
|
|
225
|
+
version: 1,
|
|
226
|
+
skillsRoot: DEFAULT_SKILLS_ROOT,
|
|
227
|
+
metadataFile: DEFAULT_METADATA_FILE,
|
|
228
|
+
managedBy: DEFAULT_MANAGED_BY,
|
|
229
|
+
remoteRepository: options.remoteRepository ?? path.basename(repoPath),
|
|
230
|
+
staleCleanup: true,
|
|
231
|
+
skillsCliVersion: DEFAULT_SKILLS_CLI_VERSION,
|
|
232
|
+
installAgent: DEFAULT_INSTALL_AGENT,
|
|
233
|
+
sources: options.sources ?? [],
|
|
234
|
+
manifestPath: path.join(repoPath, "sources.yaml"),
|
|
235
|
+
repoPath
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function serialiseManifest(manifest) {
|
|
239
|
+
const lines = [
|
|
240
|
+
"# Managed by skillsbase CLI.",
|
|
241
|
+
"# Edit source entries to add or remove managed skills.",
|
|
242
|
+
`version: ${manifest.version}`,
|
|
243
|
+
`skillsRoot: ${quoteYamlString(manifest.skillsRoot)}`,
|
|
244
|
+
`metadataFile: ${quoteYamlString(manifest.metadataFile)}`,
|
|
245
|
+
`managedBy: ${quoteYamlString(manifest.managedBy)}`,
|
|
246
|
+
`remoteRepository: ${quoteYamlString(manifest.remoteRepository)}`,
|
|
247
|
+
`staleCleanup: ${manifest.staleCleanup ? "true" : "false"}`,
|
|
248
|
+
`skillsCliVersion: ${quoteYamlString(manifest.skillsCliVersion ?? "1.4.8")}`,
|
|
249
|
+
`installAgent: ${quoteYamlString(manifest.installAgent ?? "codex")}`,
|
|
250
|
+
"sources:"
|
|
251
|
+
];
|
|
252
|
+
for (const source of manifest.sources) {
|
|
253
|
+
lines.push(` - key: ${quoteYamlString(source.key)}`);
|
|
254
|
+
lines.push(` label: ${quoteYamlString(source.label)}`);
|
|
255
|
+
lines.push(` kind: ${quoteYamlString(source.kind)}`);
|
|
256
|
+
lines.push(` root: ${quoteYamlString(source.root)}`);
|
|
257
|
+
lines.push(` targetPrefix: ${quoteYamlString(source.targetPrefix ?? "")}`);
|
|
258
|
+
lines.push(" include:");
|
|
259
|
+
for (const skillName of source.include ?? []) lines.push(` - ${quoteYamlString(skillName)}`);
|
|
260
|
+
}
|
|
261
|
+
return `${lines.join("\n")}\n`;
|
|
262
|
+
}
|
|
263
|
+
function validateManifest(manifest) {
|
|
264
|
+
for (const key of [
|
|
265
|
+
"version",
|
|
266
|
+
"skillsRoot",
|
|
267
|
+
"metadataFile",
|
|
268
|
+
"managedBy",
|
|
269
|
+
"remoteRepository",
|
|
270
|
+
"staleCleanup",
|
|
271
|
+
"sources"
|
|
272
|
+
]) if (!(key in manifest)) throw new CliError(`Missing required manifest key: ${key}`, { details: ["Run `skillsbase init` to recreate the baseline contract."] });
|
|
273
|
+
if (!Array.isArray(manifest.sources) || typeof manifest.version !== "number" || typeof manifest.skillsRoot !== "string" || typeof manifest.metadataFile !== "string" || typeof manifest.managedBy !== "string" || typeof manifest.remoteRepository !== "string" || typeof manifest.staleCleanup !== "boolean") throw new CliError("Manifest `sources` must be a list.", { details: ["Repair `sources.yaml` and try again."] });
|
|
274
|
+
return {
|
|
275
|
+
version: manifest.version,
|
|
276
|
+
skillsRoot: manifest.skillsRoot,
|
|
277
|
+
metadataFile: manifest.metadataFile,
|
|
278
|
+
managedBy: manifest.managedBy,
|
|
279
|
+
remoteRepository: manifest.remoteRepository,
|
|
280
|
+
staleCleanup: manifest.staleCleanup,
|
|
281
|
+
skillsCliVersion: typeof manifest.skillsCliVersion === "string" ? manifest.skillsCliVersion : DEFAULT_SKILLS_CLI_VERSION,
|
|
282
|
+
installAgent: typeof manifest.installAgent === "string" ? manifest.installAgent : DEFAULT_INSTALL_AGENT,
|
|
283
|
+
sources: manifest.sources.map(validateSource),
|
|
284
|
+
manifestPath: typeof manifest.manifestPath === "string" ? manifest.manifestPath : "",
|
|
285
|
+
repoPath: typeof manifest.repoPath === "string" ? manifest.repoPath : "",
|
|
286
|
+
skillsRootPath: typeof manifest.skillsRootPath === "string" ? manifest.skillsRootPath : void 0
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
async function loadManifest(repoPath) {
|
|
290
|
+
const manifestPath = path.join(repoPath, "sources.yaml");
|
|
291
|
+
if (!await pathExists(manifestPath)) throw new CliError("Missing `sources.yaml`.", { details: [`Repository: ${repoPath}`, "Run `skillsbase init` first, then retry the command."] });
|
|
292
|
+
const text = await promises.readFile(manifestPath, "utf8");
|
|
293
|
+
const manifest = { sources: [] };
|
|
294
|
+
const lines = text.split(/\r?\n/);
|
|
295
|
+
let currentSource = null;
|
|
296
|
+
let currentListKey = null;
|
|
297
|
+
for (const [index, rawLine] of lines.entries()) {
|
|
298
|
+
const line = rawLine.replace(/\s+$/, "");
|
|
299
|
+
const lineNumber = index + 1;
|
|
300
|
+
if (line.length === 0 || line.trimStart().startsWith("#")) continue;
|
|
301
|
+
const topLevelMatch = /^([A-Za-z][A-Za-z0-9]*):\s*(.*)$/.exec(line);
|
|
302
|
+
if (topLevelMatch && !line.startsWith(" ")) {
|
|
303
|
+
const [, key, value] = topLevelMatch;
|
|
304
|
+
if (key === "sources") {
|
|
305
|
+
currentSource = null;
|
|
306
|
+
currentListKey = null;
|
|
307
|
+
} else manifest[key] = parseScalar(value);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const sourceStartMatch = /^ - key:\s*(.+)$/.exec(line);
|
|
311
|
+
if (sourceStartMatch) {
|
|
312
|
+
currentSource = {
|
|
313
|
+
key: String(parseScalar(sourceStartMatch[1])),
|
|
314
|
+
include: []
|
|
315
|
+
};
|
|
316
|
+
manifest.sources.push(currentSource);
|
|
317
|
+
currentListKey = null;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const sourcePropertyMatch = /^ ([A-Za-z][A-Za-z0-9]*):\s*(.*)$/.exec(line);
|
|
321
|
+
if (sourcePropertyMatch && currentSource) {
|
|
322
|
+
const [, key, value] = sourcePropertyMatch;
|
|
323
|
+
if (value.length === 0) {
|
|
324
|
+
currentSource[key] = [];
|
|
325
|
+
currentListKey = key;
|
|
326
|
+
} else {
|
|
327
|
+
currentSource[key] = parseScalar(value);
|
|
328
|
+
currentListKey = null;
|
|
329
|
+
}
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const listItemMatch = /^ - (.+)$/.exec(line);
|
|
333
|
+
if (listItemMatch && currentSource && currentListKey) {
|
|
334
|
+
const listValue = currentSource[currentListKey];
|
|
335
|
+
if (!Array.isArray(listValue)) throw new CliError(`Invalid list state for "${currentListKey}" at line ${lineNumber}.`);
|
|
336
|
+
listValue.push(parseScalar(listItemMatch[1]));
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
throw new CliError(`Unsupported sources.yaml syntax at line ${lineNumber}.`, { details: [rawLine] });
|
|
340
|
+
}
|
|
341
|
+
const validated = validateManifest(manifest);
|
|
342
|
+
return {
|
|
343
|
+
...validated,
|
|
344
|
+
manifestPath,
|
|
345
|
+
repoPath,
|
|
346
|
+
skillsRootPath: path.join(repoPath, validated.skillsRoot)
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
async function saveManifest(manifest) {
|
|
350
|
+
const nextText = serialiseManifest(manifest);
|
|
351
|
+
await promises.writeFile(manifest.manifestPath, nextText, "utf8");
|
|
352
|
+
}
|
|
353
|
+
function buildManifestEntries(manifest, repoPath = manifest.repoPath) {
|
|
354
|
+
const entries = [];
|
|
355
|
+
for (const source of manifest.sources) {
|
|
356
|
+
const resolvedSourceRoot = resolveSourceRoot(repoPath, source);
|
|
357
|
+
for (const originalName of source.include ?? []) {
|
|
358
|
+
const targetName = `${source.targetPrefix ?? ""}${originalName}`;
|
|
359
|
+
entries.push({
|
|
360
|
+
sourceKey: source.key,
|
|
361
|
+
sourceLabel: source.label,
|
|
362
|
+
sourceKind: source.kind,
|
|
363
|
+
remoteSource: isRemoteRepositorySource(source),
|
|
364
|
+
sourceRoot: source.root,
|
|
365
|
+
sourcePath: buildSourcePath(source, originalName),
|
|
366
|
+
resolvedSourceRoot,
|
|
367
|
+
resolvedSourcePath: resolveSourcePath(repoPath, source, originalName),
|
|
368
|
+
originalName,
|
|
369
|
+
targetName,
|
|
370
|
+
targetPath: path.join(repoPath, manifest.skillsRoot, targetName),
|
|
371
|
+
targetPathRelative: toPosix(path.join(manifest.skillsRoot, targetName))
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const collisions = /* @__PURE__ */ new Map();
|
|
376
|
+
for (const entry of entries) {
|
|
377
|
+
const keys = collisions.get(entry.targetName) ?? [];
|
|
378
|
+
keys.push(entry.sourceKey);
|
|
379
|
+
collisions.set(entry.targetName, keys);
|
|
380
|
+
}
|
|
381
|
+
const duplicateTargets = [...collisions.entries()].filter(([, keys]) => keys.length > 1);
|
|
382
|
+
if (duplicateTargets.length > 0) throw new CliError(`Manifest target-name collision detected: ${duplicateTargets.map(([targetName, keys]) => `${targetName} (${keys.join(", ")})`).join(", ")}`, { details: ["Adjust `targetPrefix` or `include` entries in `sources.yaml`."] });
|
|
383
|
+
return entries.sort((left, right) => left.targetName.localeCompare(right.targetName));
|
|
384
|
+
}
|
|
385
|
+
function addSkillToManifest(manifest, skillName, options = {}) {
|
|
386
|
+
assertManifestHasSources(manifest);
|
|
387
|
+
const selectedSource = options.sourceKey == null ? manifest.sources[0] : findSourceByKey(manifest, options.sourceKey);
|
|
388
|
+
return cloneManifestWithSources(manifest, manifest.sources.map((source) => {
|
|
389
|
+
if (source.key !== selectedSource.key) return source;
|
|
390
|
+
return {
|
|
391
|
+
...source,
|
|
392
|
+
include: sortInclude(new Set(source.include ?? []).add(skillName))
|
|
393
|
+
};
|
|
394
|
+
}));
|
|
395
|
+
}
|
|
396
|
+
function removeSkillFromManifest(manifest, skillName, options = {}) {
|
|
397
|
+
assertManifestHasSources(manifest);
|
|
398
|
+
const selectedSource = options.sourceKey == null ? null : findSourceByKey(manifest, options.sourceKey);
|
|
399
|
+
if (selectedSource != null) {
|
|
400
|
+
if (!selectedSource.include.includes(skillName)) {
|
|
401
|
+
const matchingKeys = findSourcesBySkill(manifest, skillName).map((source) => source.key);
|
|
402
|
+
throw buildMissingSkillError(skillName, {
|
|
403
|
+
sourceKey: selectedSource.key,
|
|
404
|
+
matchingKeys
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return cloneManifestWithSources(manifest, manifest.sources.map((source) => {
|
|
408
|
+
if (source.key !== selectedSource.key) return source;
|
|
409
|
+
return {
|
|
410
|
+
...source,
|
|
411
|
+
include: sortInclude(source.include.filter((candidate) => candidate !== skillName))
|
|
412
|
+
};
|
|
413
|
+
}));
|
|
414
|
+
}
|
|
415
|
+
const matchingSources = findSourcesBySkill(manifest, skillName);
|
|
416
|
+
if (matchingSources.length === 0) throw buildMissingSkillError(skillName);
|
|
417
|
+
if (matchingSources.length > 1) {
|
|
418
|
+
const matchingKeys = matchingSources.map((source) => source.key).sort((left, right) => left.localeCompare(right));
|
|
419
|
+
throw new CliError(`Skill "${skillName}" is declared in multiple sources.`, { details: [`matching sources: ${matchingKeys.join(", ")}`, `Use \`skillsbase remove ${skillName} --source <key>\` to disambiguate.`] });
|
|
420
|
+
}
|
|
421
|
+
const [uniqueSource] = matchingSources;
|
|
422
|
+
return cloneManifestWithSources(manifest, manifest.sources.map((source) => {
|
|
423
|
+
if (source.key !== uniqueSource.key) return source;
|
|
424
|
+
return {
|
|
425
|
+
...source,
|
|
426
|
+
include: sortInclude(source.include.filter((candidate) => candidate !== skillName))
|
|
427
|
+
};
|
|
428
|
+
}));
|
|
429
|
+
}
|
|
430
|
+
function buildMetadata(manifest, entry, installRecord) {
|
|
431
|
+
return {
|
|
432
|
+
schemaVersion: 1,
|
|
433
|
+
managed: true,
|
|
434
|
+
managedBy: manifest.managedBy,
|
|
435
|
+
sourceKey: entry.sourceKey,
|
|
436
|
+
sourceKind: entry.sourceKind,
|
|
437
|
+
sourceLabel: entry.sourceLabel,
|
|
438
|
+
sourceRoot: entry.sourceRoot,
|
|
439
|
+
sourcePath: entry.sourcePath,
|
|
440
|
+
originalName: entry.originalName,
|
|
441
|
+
targetName: entry.targetName,
|
|
442
|
+
targetPath: entry.targetPathRelative,
|
|
443
|
+
remoteRepository: manifest.remoteRepository,
|
|
444
|
+
installAgent: manifest.installAgent,
|
|
445
|
+
installReference: installRecord.installReference,
|
|
446
|
+
installedMetadata: installRecord.installedMetadata,
|
|
447
|
+
files: [...installRecord.files].sort((left, right) => left.localeCompare(right))
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
//#endregion
|
|
451
|
+
//#region src/lib/installer.ts
|
|
452
|
+
var execFile$1 = promisify(execFile);
|
|
453
|
+
var INSTALL_TIMEOUT_MS = 18e4;
|
|
454
|
+
var REMOTE_INSTALL_RETRIES = 3;
|
|
455
|
+
var NPM_ENV_KEYS_TO_UNSET = [
|
|
456
|
+
"INIT_CWD",
|
|
457
|
+
"npm_command",
|
|
458
|
+
"npm_config_local_prefix",
|
|
459
|
+
"npm_config_prefix",
|
|
460
|
+
"npm_execpath",
|
|
461
|
+
"npm_lifecycle_event",
|
|
462
|
+
"npm_lifecycle_script",
|
|
463
|
+
"npm_package_json",
|
|
464
|
+
"npm_prefix"
|
|
465
|
+
];
|
|
466
|
+
function toInstallReference(entry) {
|
|
467
|
+
if (entry.remoteSource) return entry.sourcePath;
|
|
468
|
+
if (path.isAbsolute(entry.sourcePath) || entry.sourcePath.startsWith(`.${path.sep}`) || entry.sourcePath === ".") return entry.sourcePath;
|
|
469
|
+
return `.${path.sep}${entry.sourcePath}`;
|
|
470
|
+
}
|
|
471
|
+
function buildNpxArgs(manifest, subcommand, extraArgs) {
|
|
472
|
+
return [
|
|
473
|
+
"--yes",
|
|
474
|
+
`skills@${manifest.skillsCliVersion}`,
|
|
475
|
+
subcommand,
|
|
476
|
+
...extraArgs
|
|
477
|
+
];
|
|
478
|
+
}
|
|
479
|
+
function renderExecFailure(error) {
|
|
480
|
+
if (error instanceof Error) {
|
|
481
|
+
const execError = error;
|
|
482
|
+
return execError.stderr ?? execError.stdout ?? execError.message;
|
|
483
|
+
}
|
|
484
|
+
return String(error);
|
|
485
|
+
}
|
|
486
|
+
async function execSkillsAdd(repoPath, manifest, installReference, options) {
|
|
487
|
+
const childEnv = { ...options.env ?? process.env };
|
|
488
|
+
for (const key of NPM_ENV_KEYS_TO_UNSET) delete childEnv[key];
|
|
489
|
+
childEnv.INIT_CWD = repoPath;
|
|
490
|
+
await execFile$1("npx", buildNpxArgs(manifest, "add", [
|
|
491
|
+
installReference,
|
|
492
|
+
"--agent",
|
|
493
|
+
manifest.installAgent,
|
|
494
|
+
"--copy",
|
|
495
|
+
"-y"
|
|
496
|
+
]), {
|
|
497
|
+
cwd: repoPath,
|
|
498
|
+
env: childEnv,
|
|
499
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
500
|
+
timeout: INSTALL_TIMEOUT_MS
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
async function installIntoCurrentRepository(repoPath, manifest, entry, options = {}) {
|
|
504
|
+
const installReference = toInstallReference(entry);
|
|
505
|
+
const installPath = path.join(repoPath, ".agents", "skills", entry.originalName);
|
|
506
|
+
const lockPath = path.join(repoPath, "skills-lock.json");
|
|
507
|
+
const snapshot = {
|
|
508
|
+
installPath,
|
|
509
|
+
lockPath,
|
|
510
|
+
installTree: await snapshotTree(installPath),
|
|
511
|
+
lockText: await readFileIfExists(lockPath),
|
|
512
|
+
installReference
|
|
513
|
+
};
|
|
514
|
+
const attempts = entry.remoteSource ? REMOTE_INSTALL_RETRIES : 1;
|
|
515
|
+
const failures = [];
|
|
516
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) try {
|
|
517
|
+
await execSkillsAdd(repoPath, manifest, installReference, options);
|
|
518
|
+
failures.length = 0;
|
|
519
|
+
break;
|
|
520
|
+
} catch (error) {
|
|
521
|
+
failures.push(`attempt ${attempt}/${attempts}: ${renderExecFailure(error)}`);
|
|
522
|
+
await restoreSnapshot(snapshot);
|
|
523
|
+
if (attempt === attempts) break;
|
|
524
|
+
}
|
|
525
|
+
if (failures.length > 0) throw new CliError(`skills install failed for ${entry.originalName}.`, { details: failures });
|
|
526
|
+
if (!await pathExists(installPath)) throw new CliError(`skills install did not create ${path.relative(repoPath, installPath)}.`, { details: ["The `npx skills` install output was not in the expected current-repository shape."] });
|
|
527
|
+
return {
|
|
528
|
+
installPath,
|
|
529
|
+
lockPath,
|
|
530
|
+
installReference,
|
|
531
|
+
snapshot
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
async function cleanupInstalledSkill(repoPath, _manifest, entry, installState, _options = {}) {
|
|
535
|
+
try {
|
|
536
|
+
await restoreSnapshot(installState.snapshot);
|
|
537
|
+
await removeIfEmptyUpward(path.join(repoPath, ".agents", "skills"), repoPath);
|
|
538
|
+
} catch (restoreError) {
|
|
539
|
+
const restoreMessage = restoreError instanceof Error ? restoreError.message : String(restoreError);
|
|
540
|
+
throw new CliError(`Cleanup failed for ${entry.originalName}.`, { details: [restoreMessage] });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
async function snapshotTree(rootPath) {
|
|
544
|
+
if (!await pathExists(rootPath)) return null;
|
|
545
|
+
const entries = await promises.readdir(rootPath, { withFileTypes: true });
|
|
546
|
+
const tree = /* @__PURE__ */ new Map();
|
|
547
|
+
for (const entry of entries) {
|
|
548
|
+
const absolutePath = path.join(rootPath, entry.name);
|
|
549
|
+
if (entry.isDirectory()) {
|
|
550
|
+
const nested = await snapshotTree(absolutePath);
|
|
551
|
+
if (!nested) continue;
|
|
552
|
+
for (const [relativePath, content] of nested.entries()) tree.set(path.join(entry.name, relativePath), content);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (entry.isFile()) tree.set(entry.name, await promises.readFile(absolutePath));
|
|
556
|
+
}
|
|
557
|
+
return tree;
|
|
558
|
+
}
|
|
559
|
+
async function restoreSnapshot(snapshot) {
|
|
560
|
+
if (snapshot.installTree == null) await promises.rm(snapshot.installPath, {
|
|
561
|
+
recursive: true,
|
|
562
|
+
force: true
|
|
563
|
+
});
|
|
564
|
+
else {
|
|
565
|
+
await promises.rm(snapshot.installPath, {
|
|
566
|
+
recursive: true,
|
|
567
|
+
force: true
|
|
568
|
+
});
|
|
569
|
+
await promises.mkdir(snapshot.installPath, { recursive: true });
|
|
570
|
+
for (const [relativePath, content] of snapshot.installTree.entries()) {
|
|
571
|
+
const targetPath = path.join(snapshot.installPath, relativePath);
|
|
572
|
+
await promises.mkdir(path.dirname(targetPath), { recursive: true });
|
|
573
|
+
await promises.writeFile(targetPath, content);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (snapshot.lockText == null) await promises.rm(snapshot.lockPath, { force: true });
|
|
577
|
+
else await promises.writeFile(snapshot.lockPath, snapshot.lockText, "utf8");
|
|
578
|
+
await removeIfEmptyUpward(path.dirname(snapshot.installPath), path.dirname(snapshot.lockPath));
|
|
579
|
+
}
|
|
580
|
+
//#endregion
|
|
581
|
+
//#region src/lib/skill-converter.ts
|
|
582
|
+
function rewriteSkillName(content, targetName) {
|
|
583
|
+
const frontmatterMatch = /^---\n([\s\S]*?)\n---/.exec(content);
|
|
584
|
+
if (!frontmatterMatch) throw new CliError("Installed SKILL.md is missing YAML frontmatter.", { details: ["The upstream skill must contain a valid `name` field."] });
|
|
585
|
+
if (!/^name:\s*.+$/m.test(frontmatterMatch[1])) throw new CliError("Installed SKILL.md frontmatter is missing a `name` field.");
|
|
586
|
+
const updatedFrontmatter = frontmatterMatch[1].replace(/^name:\s*.+$/m, `name: ${targetName}`);
|
|
587
|
+
return content.replace(frontmatterMatch[0], `---\n${updatedFrontmatter}\n---`);
|
|
588
|
+
}
|
|
589
|
+
async function convertInstalledSkill(_manifest, entry, installState) {
|
|
590
|
+
const installedPath = installState.installPath;
|
|
591
|
+
const skillPath = path.join(installedPath, "SKILL.md");
|
|
592
|
+
try {
|
|
593
|
+
await promises.access(skillPath);
|
|
594
|
+
} catch {
|
|
595
|
+
throw new CliError(`Installed skill is missing SKILL.md: ${entry.originalName}`);
|
|
596
|
+
}
|
|
597
|
+
const filePaths = await collectRelativeFiles(installedPath);
|
|
598
|
+
const outputTree = /* @__PURE__ */ new Map();
|
|
599
|
+
for (const relativePath of filePaths) {
|
|
600
|
+
if (relativePath === "hagicode-skill.json" || relativePath === ".skill-source.json") continue;
|
|
601
|
+
const absolutePath = path.join(installedPath, relativePath);
|
|
602
|
+
if (relativePath === "SKILL.md") {
|
|
603
|
+
const content = await promises.readFile(absolutePath, "utf8");
|
|
604
|
+
outputTree.set(relativePath, Buffer.from(rewriteSkillName(content, entry.targetName), "utf8"));
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
outputTree.set(relativePath, await promises.readFile(absolutePath));
|
|
608
|
+
}
|
|
609
|
+
const installedMetadataPath = path.join(installedPath, "hagicode-skill.json");
|
|
610
|
+
let installedMetadata;
|
|
611
|
+
try {
|
|
612
|
+
installedMetadata = JSON.parse(await promises.readFile(installedMetadataPath, "utf8"));
|
|
613
|
+
} catch {
|
|
614
|
+
installedMetadata = {
|
|
615
|
+
schemaVersion: 1,
|
|
616
|
+
source: entry.sourceRoot,
|
|
617
|
+
skillSlug: entry.originalName,
|
|
618
|
+
installReference: installState.installReference,
|
|
619
|
+
synthesizedBy: "skillsbase"
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
files: [...outputTree.keys()].sort((left, right) => left.localeCompare(right)),
|
|
624
|
+
outputTree,
|
|
625
|
+
installedMetadata,
|
|
626
|
+
installReference: installState.installReference,
|
|
627
|
+
targetName: entry.targetName,
|
|
628
|
+
targetPath: entry.targetPath,
|
|
629
|
+
targetPathRelative: entry.targetPathRelative
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
//#endregion
|
|
633
|
+
//#region src/lib/sync-engine.ts
|
|
634
|
+
async function assertSourceState(entry, allowMissingSources) {
|
|
635
|
+
if (entry.remoteSource) return { skip: false };
|
|
636
|
+
if (!await pathExists(entry.resolvedSourceRoot)) {
|
|
637
|
+
if (allowMissingSources) return {
|
|
638
|
+
skip: true,
|
|
639
|
+
reason: `missing source root: ${entry.resolvedSourceRoot}`
|
|
640
|
+
};
|
|
641
|
+
throw new CliError(`Managed source root does not exist: ${entry.resolvedSourceRoot}`, { details: ["Use `skillsbase sync --allow-missing-sources` to skip missing roots."] });
|
|
642
|
+
}
|
|
643
|
+
if (!await pathExists(entry.resolvedSourcePath)) throw new CliError(`Managed skill is missing from source root: ${entry.resolvedSourcePath}`, { details: [`source: ${entry.sourceKey}`, `skill: ${entry.originalName}`] });
|
|
644
|
+
return { skip: false };
|
|
645
|
+
}
|
|
646
|
+
async function assertManagedTargetWritable(manifest, entry) {
|
|
647
|
+
if (!await pathExists(entry.targetPath)) return;
|
|
648
|
+
const metadataPath = path.join(entry.targetPath, manifest.metadataFile);
|
|
649
|
+
if (!await pathExists(metadataPath)) throw new CliError(`Refusing to overwrite unmanaged directory: ${entry.targetPathRelative}`, { details: ["Add metadata manually or remove the conflicting directory first."] });
|
|
650
|
+
const metadata = JSON.parse(await promises.readFile(metadataPath, "utf8"));
|
|
651
|
+
if (!metadata.managed || metadata.managedBy !== manifest.managedBy) throw new CliError(`Refusing to overwrite unmanaged directory: ${entry.targetPathRelative}`, { details: [`Found metadata managedBy=${JSON.stringify(metadata.managedBy)}`] });
|
|
652
|
+
}
|
|
653
|
+
async function snapshotTargetDirectory(targetPath) {
|
|
654
|
+
if (!await pathExists(targetPath)) return null;
|
|
655
|
+
return {
|
|
656
|
+
path: targetPath,
|
|
657
|
+
tree: await readTree(targetPath)
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
async function restoreTargetDirectory(snapshot) {
|
|
661
|
+
if (snapshot == null) return;
|
|
662
|
+
await writeTree(snapshot.path, snapshot.tree);
|
|
663
|
+
}
|
|
664
|
+
async function compareTarget(manifest, entry, desiredTree, desiredMetadata) {
|
|
665
|
+
if (!await pathExists(entry.targetPath)) return [`missing target directory: ${entry.targetPathRelative}`];
|
|
666
|
+
const actualFiles = (await collectRelativeFiles(entry.targetPath)).sort((left, right) => left.localeCompare(right));
|
|
667
|
+
const desiredFiles = [...desiredTree.keys(), manifest.metadataFile].sort((left, right) => left.localeCompare(right));
|
|
668
|
+
if (JSON.stringify(actualFiles) !== JSON.stringify(desiredFiles)) return [`file set drift: ${entry.targetPathRelative}`];
|
|
669
|
+
const actualTree = await readTree(entry.targetPath);
|
|
670
|
+
for (const [relativePath, buffer] of desiredTree.entries()) {
|
|
671
|
+
const actual = actualTree.get(relativePath);
|
|
672
|
+
if (!actual || !actual.equals(buffer)) return [`file content drift: ${entry.targetPathRelative}/${relativePath}`];
|
|
673
|
+
}
|
|
674
|
+
const metadataPath = path.join(entry.targetPath, manifest.metadataFile);
|
|
675
|
+
if (await promises.readFile(metadataPath, "utf8") !== stableJson(desiredMetadata)) return [`metadata drift: ${entry.targetPathRelative}/${manifest.metadataFile}`];
|
|
676
|
+
return [];
|
|
677
|
+
}
|
|
678
|
+
async function reconcileStaleTargets(repoPath, manifest, declaredTargets, check) {
|
|
679
|
+
const changes = [];
|
|
680
|
+
const skillsRootPath = path.join(repoPath, manifest.skillsRoot);
|
|
681
|
+
const existingDirectories = await listDirectories(skillsRootPath);
|
|
682
|
+
for (const directoryName of existingDirectories) {
|
|
683
|
+
if (declaredTargets.has(directoryName)) continue;
|
|
684
|
+
const candidatePath = path.join(skillsRootPath, directoryName);
|
|
685
|
+
const metadataPath = path.join(candidatePath, manifest.metadataFile);
|
|
686
|
+
if (!await pathExists(metadataPath)) continue;
|
|
687
|
+
const metadata = JSON.parse(await promises.readFile(metadataPath, "utf8"));
|
|
688
|
+
if (!metadata.managed || metadata.managedBy !== manifest.managedBy) continue;
|
|
689
|
+
if (check) {
|
|
690
|
+
changes.push(`stale managed directory: ${path.posix.join(manifest.skillsRoot, directoryName)}`);
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
await promises.rm(candidatePath, {
|
|
694
|
+
recursive: true,
|
|
695
|
+
force: true
|
|
696
|
+
});
|
|
697
|
+
changes.push(`removed stale: ${path.posix.join(manifest.skillsRoot, directoryName)}`);
|
|
698
|
+
}
|
|
699
|
+
return changes;
|
|
700
|
+
}
|
|
701
|
+
async function executeSync(options) {
|
|
702
|
+
const { repoPath, manifest, check, allowMissingSources, env } = options;
|
|
703
|
+
const entries = buildManifestEntries(manifest, repoPath);
|
|
704
|
+
const items = [];
|
|
705
|
+
const skipped = [];
|
|
706
|
+
const declaredTargets = new Set(entries.map((entry) => entry.targetName));
|
|
707
|
+
const preparedEntries = [];
|
|
708
|
+
const checkSnapshots = /* @__PURE__ */ new Map();
|
|
709
|
+
await ensureDirectory(path.join(repoPath, manifest.skillsRoot));
|
|
710
|
+
if (check) for (const entry of entries) checkSnapshots.set(entry.targetPath, await snapshotTargetDirectory(entry.targetPath));
|
|
711
|
+
for (const entry of entries) {
|
|
712
|
+
if ((await assertSourceState(entry, allowMissingSources)).skip) {
|
|
713
|
+
skipped.push(`${entry.sourceKey}: ${entry.originalName}`);
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
const installState = await installIntoCurrentRepository(repoPath, manifest, entry, { env });
|
|
717
|
+
try {
|
|
718
|
+
const converted = await convertInstalledSkill(manifest, entry, installState);
|
|
719
|
+
const metadata = buildMetadata(manifest, entry, {
|
|
720
|
+
installReference: converted.installReference,
|
|
721
|
+
installedMetadata: converted.installedMetadata,
|
|
722
|
+
files: converted.files
|
|
723
|
+
});
|
|
724
|
+
preparedEntries.push({
|
|
725
|
+
entry,
|
|
726
|
+
converted,
|
|
727
|
+
metadata
|
|
728
|
+
});
|
|
729
|
+
} finally {
|
|
730
|
+
await cleanupInstalledSkill(repoPath, manifest, entry, installState, { env });
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (check) for (const prepared of preparedEntries) {
|
|
734
|
+
await restoreTargetDirectory(checkSnapshots.get(prepared.entry.targetPath) ?? null);
|
|
735
|
+
items.push(...await compareTarget(manifest, prepared.entry, prepared.converted.outputTree, prepared.metadata));
|
|
736
|
+
}
|
|
737
|
+
else for (const prepared of preparedEntries) {
|
|
738
|
+
await assertManagedTargetWritable(manifest, prepared.entry);
|
|
739
|
+
const nextTree = new Map(prepared.converted.outputTree);
|
|
740
|
+
nextTree.set(manifest.metadataFile, Buffer.from(stableJson(prepared.metadata), "utf8"));
|
|
741
|
+
await writeTree(prepared.entry.targetPath, nextTree);
|
|
742
|
+
items.push(`synced: ${prepared.entry.targetPathRelative}`);
|
|
743
|
+
}
|
|
744
|
+
items.push(...await reconcileStaleTargets(repoPath, manifest, declaredTargets, check));
|
|
745
|
+
if (skipped.length > 0) items.push(`skipped missing sources: ${skipped.join(", ")}`);
|
|
746
|
+
const driftDetected = check && items.some((item) => !item.startsWith("skipped "));
|
|
747
|
+
return {
|
|
748
|
+
command: "sync",
|
|
749
|
+
title: check ? "skillsbase sync --check" : "skillsbase sync",
|
|
750
|
+
repository: repoPath,
|
|
751
|
+
exitCode: driftDetected ? 1 : 0,
|
|
752
|
+
schema: "spec-driven",
|
|
753
|
+
items: items.length > 0 ? items : [check ? "no drift detected" : "nothing to sync"],
|
|
754
|
+
nextSteps: driftDetected ? ["skillsbase sync"] : []
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
//#endregion
|
|
758
|
+
//#region src/commands/add.ts
|
|
759
|
+
async function runAddCommand(context) {
|
|
760
|
+
const repoFlag = typeof context.flags.repo === "string" ? context.flags.repo : void 0;
|
|
761
|
+
const sourceFlag = typeof context.flags.source === "string" ? context.flags.source : void 0;
|
|
762
|
+
const repoPath = path.resolve(repoFlag ?? context.cwd);
|
|
763
|
+
const skillName = context.args[0];
|
|
764
|
+
if (!skillName) throw new CliError("`skillsbase add` requires a skill name.", { details: ["Usage: `skillsbase add <skill-name> [--source <key>]`."] });
|
|
765
|
+
const nextManifest = addSkillToManifest(await loadManifest(repoPath), skillName, { sourceKey: sourceFlag });
|
|
766
|
+
await saveManifest(nextManifest);
|
|
767
|
+
const result = await executeSync({
|
|
768
|
+
repoPath,
|
|
769
|
+
manifest: nextManifest,
|
|
770
|
+
env: context.env,
|
|
771
|
+
check: false,
|
|
772
|
+
allowMissingSources: context.flags["allow-missing-sources"] === true
|
|
773
|
+
});
|
|
774
|
+
return {
|
|
775
|
+
...result,
|
|
776
|
+
title: `skillsbase add ${skillName}`,
|
|
777
|
+
items: [`manifest updated: ${path.relative(repoPath, nextManifest.manifestPath) || "sources.yaml"}`, ...result.items]
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
//#endregion
|
|
781
|
+
//#region src/lib/templates.ts
|
|
782
|
+
var moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
783
|
+
var templateRootCandidates = [path.resolve(moduleDir, "..", "..", "templates"), path.resolve(moduleDir, "..", "templates")];
|
|
784
|
+
async function resolveTemplateRoot() {
|
|
785
|
+
for (const candidate of templateRootCandidates) if (await pathExists(candidate)) return candidate;
|
|
786
|
+
throw new CliError("Unable to locate the bundled templates directory.");
|
|
787
|
+
}
|
|
788
|
+
async function renderTemplate(relativePath, variables) {
|
|
789
|
+
const templateRoot = await resolveTemplateRoot();
|
|
790
|
+
const templatePath = path.join(templateRoot, relativePath);
|
|
791
|
+
let content = await promises.readFile(templatePath, "utf8");
|
|
792
|
+
for (const [key, value] of Object.entries(variables)) content = content.replaceAll(`{{${key}}}`, value);
|
|
793
|
+
return content;
|
|
794
|
+
}
|
|
795
|
+
function managedMarkerFor(targetPath) {
|
|
796
|
+
if (targetPath.endsWith(".md")) return `<!-- ${MANAGED_SIGNATURE}. -->`;
|
|
797
|
+
return `# ${MANAGED_SIGNATURE}.`;
|
|
798
|
+
}
|
|
799
|
+
function isManagedContent(targetPath, content) {
|
|
800
|
+
return content.includes(managedMarkerFor(targetPath));
|
|
801
|
+
}
|
|
802
|
+
async function writeManagedFile(targetPath, content, options = {}) {
|
|
803
|
+
const marker = managedMarkerFor(targetPath);
|
|
804
|
+
if (!content.includes(marker)) throw new CliError(`Managed template is missing its marker: ${targetPath}`);
|
|
805
|
+
const current = await pathExists(targetPath) ? await promises.readFile(targetPath, "utf8") : null;
|
|
806
|
+
if (current === content) return {
|
|
807
|
+
status: "unchanged",
|
|
808
|
+
path: targetPath
|
|
809
|
+
};
|
|
810
|
+
if (current != null && !isManagedContent(targetPath, current) && !options.force) throw new CliError(`Refusing to overwrite unmanaged file: ${targetPath}`, { details: ["Use `--force` to replace the conflicting file."] });
|
|
811
|
+
await promises.mkdir(path.dirname(targetPath), { recursive: true });
|
|
812
|
+
await promises.writeFile(targetPath, content, "utf8");
|
|
813
|
+
return {
|
|
814
|
+
status: current == null ? "created" : "updated",
|
|
815
|
+
path: targetPath
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
async function writeGithubActions(repoPath, options = {}) {
|
|
819
|
+
const kind = options.kind ?? "workflow";
|
|
820
|
+
const variables = { NODE_VERSION: DEFAULT_NODE_VERSION };
|
|
821
|
+
const targets = [];
|
|
822
|
+
if (kind === "workflow" || kind === "all") targets.push({
|
|
823
|
+
relativePath: path.join(".github", "workflows", "skills-sync.yml"),
|
|
824
|
+
template: path.join("workflows", "skills-sync.yml")
|
|
825
|
+
}, {
|
|
826
|
+
relativePath: path.join(".github", "workflows", "skills-manage.yml"),
|
|
827
|
+
template: path.join("workflows", "skills-manage.yml")
|
|
828
|
+
});
|
|
829
|
+
if (kind === "workflow" || kind === "action" || kind === "all") targets.push({
|
|
830
|
+
relativePath: path.join(".github", "actions", "skillsbase-sync", "action.yml"),
|
|
831
|
+
template: path.join("actions", "skillsbase-sync", "action.yml")
|
|
832
|
+
}, {
|
|
833
|
+
relativePath: path.join(".github", "actions", "skillsbase-manage", "action.yml"),
|
|
834
|
+
template: path.join("actions", "skillsbase-manage", "action.yml")
|
|
835
|
+
});
|
|
836
|
+
if (targets.length === 0) throw new CliError(`Unsupported github_action kind: ${kind}`, { details: ["Supported values: workflow, action, all."] });
|
|
837
|
+
const items = [];
|
|
838
|
+
for (const target of targets) {
|
|
839
|
+
const content = await renderTemplate(target.template, variables);
|
|
840
|
+
const status = await writeManagedFile(path.join(repoPath, target.relativePath), content, { force: Boolean(options.force) });
|
|
841
|
+
items.push(`${status.status}: ${target.relativePath}`);
|
|
842
|
+
}
|
|
843
|
+
return {
|
|
844
|
+
command: "github_action",
|
|
845
|
+
title: `skillsbase github_action --kind ${kind}`,
|
|
846
|
+
repository: repoPath,
|
|
847
|
+
exitCode: 0,
|
|
848
|
+
items,
|
|
849
|
+
nextSteps: []
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
//#endregion
|
|
853
|
+
//#region src/commands/github-action.ts
|
|
854
|
+
async function runGithubActionCommand(context) {
|
|
855
|
+
const repoFlag = typeof context.flags.repo === "string" ? context.flags.repo : void 0;
|
|
856
|
+
const kindFlag = typeof context.flags.kind === "string" ? context.flags.kind : void 0;
|
|
857
|
+
return writeGithubActions(path.resolve(repoFlag ?? context.cwd), {
|
|
858
|
+
kind: kindFlag ?? "workflow",
|
|
859
|
+
force: context.flags.force === true
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
//#endregion
|
|
863
|
+
//#region src/lib/repo-init.ts
|
|
864
|
+
function defaultRoots(options = {}) {
|
|
865
|
+
const home = os.homedir();
|
|
866
|
+
return {
|
|
867
|
+
firstPartyRoot: path.resolve(options.firstPartyRoot ?? path.join(home, ".agents", "skills")),
|
|
868
|
+
systemRoot: path.resolve(options.systemRoot ?? path.join(home, ".codex", "skills", ".system"))
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
async function createDefaultSource(key, label, kind, root, targetPrefix) {
|
|
872
|
+
return {
|
|
873
|
+
key,
|
|
874
|
+
label,
|
|
875
|
+
kind,
|
|
876
|
+
root,
|
|
877
|
+
targetPrefix,
|
|
878
|
+
include: await pathExists(root) ? await listDirectories(root) : []
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
async function initialiseRepository(repoPath, options = {}) {
|
|
882
|
+
await ensureDirectory(repoPath);
|
|
883
|
+
const roots = defaultRoots(options);
|
|
884
|
+
const manifestPath = path.join(repoPath, "sources.yaml");
|
|
885
|
+
const createdItems = [];
|
|
886
|
+
const preservedItems = [];
|
|
887
|
+
if (!await pathExists(manifestPath)) {
|
|
888
|
+
const sources = [await createDefaultSource("first-party", "First-party local skills", "first-party", roots.firstPartyRoot, ""), await createDefaultSource("system", "Mirrored system skills", "mirrored-system", roots.systemRoot, "system-")];
|
|
889
|
+
await saveManifest(createManifest(repoPath, {
|
|
890
|
+
remoteRepository: options.remoteRepository ?? path.basename(repoPath),
|
|
891
|
+
sources
|
|
892
|
+
}));
|
|
893
|
+
createdItems.push("sources.yaml");
|
|
894
|
+
} else preservedItems.push("sources.yaml");
|
|
895
|
+
await ensureDirectory(path.join(repoPath, "skills"));
|
|
896
|
+
await ensureDirectory(path.join(repoPath, "docs"));
|
|
897
|
+
await ensureDirectory(path.join(repoPath, ".github", "actions", "skillsbase-sync"));
|
|
898
|
+
await ensureDirectory(path.join(repoPath, ".github", "workflows"));
|
|
899
|
+
const writeStatuses = [];
|
|
900
|
+
for (const file of [{
|
|
901
|
+
relativePath: path.join("skills", "README.md"),
|
|
902
|
+
template: "skills/README.md",
|
|
903
|
+
variables: {}
|
|
904
|
+
}, {
|
|
905
|
+
relativePath: path.join("docs", "maintainer-workflow.md"),
|
|
906
|
+
template: "docs/maintainer-workflow.md",
|
|
907
|
+
variables: {}
|
|
908
|
+
}]) {
|
|
909
|
+
const content = await renderTemplate(file.template, file.variables);
|
|
910
|
+
const status = await writeManagedFile(path.join(repoPath, file.relativePath), content, { force: Boolean(options.force) });
|
|
911
|
+
writeStatuses.push(`${status.status}: ${file.relativePath}`);
|
|
912
|
+
}
|
|
913
|
+
const actionResult = await writeGithubActions(repoPath, {
|
|
914
|
+
kind: "all",
|
|
915
|
+
force: Boolean(options.force)
|
|
916
|
+
});
|
|
917
|
+
return {
|
|
918
|
+
command: "init",
|
|
919
|
+
title: "skillsbase init",
|
|
920
|
+
repository: repoPath,
|
|
921
|
+
exitCode: 0,
|
|
922
|
+
schema: "spec-driven",
|
|
923
|
+
items: [
|
|
924
|
+
...createdItems.map((item) => `created: ${item}`),
|
|
925
|
+
...preservedItems.map((item) => `preserved: ${item}`),
|
|
926
|
+
...writeStatuses,
|
|
927
|
+
...actionResult.items ?? []
|
|
928
|
+
],
|
|
929
|
+
nextSteps: ["skillsbase sync"]
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
//#endregion
|
|
933
|
+
//#region src/commands/init.ts
|
|
934
|
+
async function runInitCommand(context) {
|
|
935
|
+
const repoFlag = typeof context.flags.repo === "string" ? context.flags.repo : void 0;
|
|
936
|
+
return initialiseRepository(path.resolve(repoFlag ?? context.cwd), {
|
|
937
|
+
firstPartyRoot: typeof context.flags["first-party-root"] === "string" ? context.flags["first-party-root"] : void 0,
|
|
938
|
+
systemRoot: typeof context.flags["system-root"] === "string" ? context.flags["system-root"] : void 0,
|
|
939
|
+
remoteRepository: typeof context.flags["remote-repository"] === "string" ? context.flags["remote-repository"] : void 0,
|
|
940
|
+
force: context.flags.force === true
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
//#endregion
|
|
944
|
+
//#region src/commands/remove.ts
|
|
945
|
+
async function runRemoveCommand(context) {
|
|
946
|
+
const repoFlag = typeof context.flags.repo === "string" ? context.flags.repo : void 0;
|
|
947
|
+
const sourceFlag = typeof context.flags.source === "string" ? context.flags.source : void 0;
|
|
948
|
+
const repoPath = path.resolve(repoFlag ?? context.cwd);
|
|
949
|
+
const skillName = context.args[0];
|
|
950
|
+
if (!skillName) throw new CliError("`skillsbase remove` requires a skill name.", { details: ["Usage: `skillsbase remove <skill-name> [--source <key>]`."] });
|
|
951
|
+
const nextManifest = removeSkillFromManifest(await loadManifest(repoPath), skillName, { sourceKey: sourceFlag });
|
|
952
|
+
await saveManifest(nextManifest);
|
|
953
|
+
const result = await executeSync({
|
|
954
|
+
repoPath,
|
|
955
|
+
manifest: nextManifest,
|
|
956
|
+
env: context.env,
|
|
957
|
+
check: false,
|
|
958
|
+
allowMissingSources: context.flags["allow-missing-sources"] === true
|
|
959
|
+
});
|
|
960
|
+
return {
|
|
961
|
+
...result,
|
|
962
|
+
command: "remove",
|
|
963
|
+
title: `skillsbase remove ${skillName}`,
|
|
964
|
+
items: [`manifest updated: ${path.relative(repoPath, nextManifest.manifestPath) || "sources.yaml"}`, ...result.items]
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
//#endregion
|
|
968
|
+
//#region src/commands/sync.ts
|
|
969
|
+
async function runSyncCommand(context) {
|
|
970
|
+
const repoFlag = typeof context.flags.repo === "string" ? context.flags.repo : void 0;
|
|
971
|
+
const repoPath = path.resolve(repoFlag ?? context.cwd);
|
|
972
|
+
return executeSync({
|
|
973
|
+
repoPath,
|
|
974
|
+
manifest: await loadManifest(repoPath),
|
|
975
|
+
check: context.flags.check === true,
|
|
976
|
+
allowMissingSources: context.flags["allow-missing-sources"] === true,
|
|
977
|
+
env: context.env
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
//#endregion
|
|
981
|
+
//#region src/lib/parse-argv.ts
|
|
982
|
+
var booleanFlags = new Set([
|
|
983
|
+
"help",
|
|
984
|
+
"version",
|
|
985
|
+
"check",
|
|
986
|
+
"allow-missing-sources",
|
|
987
|
+
"force"
|
|
988
|
+
]);
|
|
989
|
+
function parseArgv(argv) {
|
|
990
|
+
const result = {
|
|
991
|
+
command: null,
|
|
992
|
+
args: [],
|
|
993
|
+
flags: {},
|
|
994
|
+
help: false,
|
|
995
|
+
version: false
|
|
996
|
+
};
|
|
997
|
+
let index = 0;
|
|
998
|
+
while (index < argv.length) {
|
|
999
|
+
const token = argv[index];
|
|
1000
|
+
if (result.command == null && !token.startsWith("-")) {
|
|
1001
|
+
result.command = token;
|
|
1002
|
+
index += 1;
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
if (token === "--help" || token === "-h") {
|
|
1006
|
+
result.help = true;
|
|
1007
|
+
index += 1;
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
if (token === "--version" || token === "-v") {
|
|
1011
|
+
result.version = true;
|
|
1012
|
+
index += 1;
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
if (token.startsWith("--")) {
|
|
1016
|
+
const [flagName, inlineValue] = token.slice(2).split("=", 2);
|
|
1017
|
+
if (booleanFlags.has(flagName)) {
|
|
1018
|
+
result.flags[flagName] = inlineValue == null ? true : inlineValue !== "false";
|
|
1019
|
+
index += 1;
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
const nextValue = inlineValue ?? argv[index + 1];
|
|
1023
|
+
if (nextValue == null) throw new CliError(`Missing value for --${flagName}.`);
|
|
1024
|
+
result.flags[flagName] = nextValue;
|
|
1025
|
+
index += inlineValue == null ? 2 : 1;
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
if (token.startsWith("-")) throw new CliError(`Unsupported short option: ${token}`, { details: ["Use long-form flags for command options."] });
|
|
1029
|
+
if (result.command != null) result.args.push(token);
|
|
1030
|
+
index += 1;
|
|
1031
|
+
}
|
|
1032
|
+
return result;
|
|
1033
|
+
}
|
|
1034
|
+
//#endregion
|
|
1035
|
+
//#region src/cli.ts
|
|
1036
|
+
var commandMap = new Map([
|
|
1037
|
+
["init", runInitCommand],
|
|
1038
|
+
["sync", runSyncCommand],
|
|
1039
|
+
["add", runAddCommand],
|
|
1040
|
+
["remove", runRemoveCommand],
|
|
1041
|
+
["github_action", runGithubActionCommand],
|
|
1042
|
+
["github-action", runGithubActionCommand]
|
|
1043
|
+
]);
|
|
1044
|
+
function getErrorMessage(error) {
|
|
1045
|
+
if (error instanceof Error) return error.message;
|
|
1046
|
+
return String(error);
|
|
1047
|
+
}
|
|
1048
|
+
async function runCli(argv, environment = {}) {
|
|
1049
|
+
const io = {
|
|
1050
|
+
stdout: environment.stdout ?? process.stdout,
|
|
1051
|
+
stderr: environment.stderr ?? process.stderr
|
|
1052
|
+
};
|
|
1053
|
+
const cwd = path.resolve(environment.cwd ?? process.cwd());
|
|
1054
|
+
const env = {
|
|
1055
|
+
...process.env,
|
|
1056
|
+
...environment.env ?? {}
|
|
1057
|
+
};
|
|
1058
|
+
try {
|
|
1059
|
+
const parsed = parseArgv(argv);
|
|
1060
|
+
if (parsed.help || !parsed.command) {
|
|
1061
|
+
printCommandUsage(io.stdout);
|
|
1062
|
+
return 0;
|
|
1063
|
+
}
|
|
1064
|
+
if (parsed.version) {
|
|
1065
|
+
io.stdout.write("skillsbase 0.1.0\n");
|
|
1066
|
+
return 0;
|
|
1067
|
+
}
|
|
1068
|
+
const command = commandMap.get(parsed.command);
|
|
1069
|
+
if (!command) throw new CliError(`Unknown command: ${parsed.command}`, {
|
|
1070
|
+
exitCode: 1,
|
|
1071
|
+
details: ["Use `skillsbase --help` to view supported commands."]
|
|
1072
|
+
});
|
|
1073
|
+
const result = await command({
|
|
1074
|
+
cwd,
|
|
1075
|
+
env,
|
|
1076
|
+
io,
|
|
1077
|
+
command: parsed.command,
|
|
1078
|
+
args: parsed.args,
|
|
1079
|
+
flags: parsed.flags,
|
|
1080
|
+
rawArgv: argv
|
|
1081
|
+
});
|
|
1082
|
+
printCommandResult(result, io.stdout);
|
|
1083
|
+
return result.exitCode ?? 0;
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
if (error instanceof CliError) {
|
|
1086
|
+
printCommandResult({
|
|
1087
|
+
command: "error",
|
|
1088
|
+
title: error.message,
|
|
1089
|
+
repository: cwd,
|
|
1090
|
+
exitCode: error.exitCode ?? 1,
|
|
1091
|
+
items: error.details ?? [],
|
|
1092
|
+
nextSteps: error.nextSteps ?? []
|
|
1093
|
+
}, io.stderr);
|
|
1094
|
+
return error.exitCode ?? 1;
|
|
1095
|
+
}
|
|
1096
|
+
printCommandResult({
|
|
1097
|
+
command: "error",
|
|
1098
|
+
title: getErrorMessage(error),
|
|
1099
|
+
repository: cwd,
|
|
1100
|
+
exitCode: 1
|
|
1101
|
+
}, io.stderr);
|
|
1102
|
+
return 1;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
//#endregion
|
|
1106
|
+
//#region src/cli-entry.ts
|
|
1107
|
+
var exitCode = await runCli(process.argv.slice(2));
|
|
1108
|
+
process.exit(exitCode);
|
|
1109
|
+
//#endregion
|