agent-skillboard 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.
Files changed (94) hide show
  1. package/CONTRIBUTING.md +83 -0
  2. package/LICENSE +21 -0
  3. package/README.md +645 -0
  4. package/bin/skillboard.mjs +4 -0
  5. package/docs/adapters.md +127 -0
  6. package/docs/capabilities.md +107 -0
  7. package/docs/install.md +346 -0
  8. package/docs/plans/20260625-080025-skillboard-mvp-review.md +189 -0
  9. package/docs/plans/README.md +20 -0
  10. package/docs/policy-model.md +251 -0
  11. package/docs/positioning.md +94 -0
  12. package/docs/profiles.md +166 -0
  13. package/docs/rollout-runbook.md +60 -0
  14. package/docs/user-flow.md +231 -0
  15. package/docs/versioning.md +201 -0
  16. package/examples/multi-source-skills/anthropic/docx/SKILL.md +8 -0
  17. package/examples/multi-source-skills/anthropic/skill-creator/SKILL.md +8 -0
  18. package/examples/multi-source-skills/matt/grill-me/SKILL.md +8 -0
  19. package/examples/multi-source-skills/matt/tdd/SKILL.md +8 -0
  20. package/examples/multi-source-skills/omo/review-work/SKILL.md +8 -0
  21. package/examples/multi-source-skills/omo/ulw-plan/SKILL.md +8 -0
  22. package/examples/multi-source-skills/private/tdd-work-continuity/SKILL.md +8 -0
  23. package/examples/multi-source-skills/private/workflow-router/SKILL.md +8 -0
  24. package/examples/multi-source-skills/wshobson/python-testing/SKILL.md +8 -0
  25. package/examples/multi-source-skills/wshobson/security-review/SKILL.md +8 -0
  26. package/examples/multi-source.config.yaml +271 -0
  27. package/examples/skillboard.config.yaml +194 -0
  28. package/examples/skills/grill-me/SKILL.md +9 -0
  29. package/examples/skills/grill-with-docs/SKILL.md +9 -0
  30. package/examples/skills/requirement-intake/SKILL.md +9 -0
  31. package/examples/skills/tdd/SKILL.md +8 -0
  32. package/package.json +58 -0
  33. package/profiles/anthropics-skills.yaml +17 -0
  34. package/profiles/mattpocock-skills.yaml +26 -0
  35. package/profiles/oh-my-openagent.yaml +32 -0
  36. package/profiles/voltagent-awesome-agent-skills.yaml +18 -0
  37. package/profiles/wshobson-agents.yaml +19 -0
  38. package/src/advisor/action-core.mjs +74 -0
  39. package/src/advisor/actions.mjs +256 -0
  40. package/src/advisor/application-commands.mjs +59 -0
  41. package/src/advisor/apply-action.mjs +183 -0
  42. package/src/advisor/schema.mjs +112 -0
  43. package/src/advisor/setup-actions.mjs +42 -0
  44. package/src/advisor/skills.mjs +191 -0
  45. package/src/advisor/sort.mjs +15 -0
  46. package/src/advisor/sources.mjs +160 -0
  47. package/src/advisor/trust-policy.mjs +65 -0
  48. package/src/advisor/workflow.mjs +41 -0
  49. package/src/advisor.mjs +134 -0
  50. package/src/agent-inventory-platforms.mjs +100 -0
  51. package/src/agent-inventory.mjs +804 -0
  52. package/src/brief-cli.mjs +40 -0
  53. package/src/brief-renderer.mjs +362 -0
  54. package/src/change-plan.mjs +169 -0
  55. package/src/cli.mjs +1171 -0
  56. package/src/config-helpers.mjs +72 -0
  57. package/src/control/can-use-guard.mjs +91 -0
  58. package/src/control/config-write.mjs +163 -0
  59. package/src/control/skill-crud.mjs +394 -0
  60. package/src/control/source-trust.mjs +161 -0
  61. package/src/control/workflow-crud.mjs +104 -0
  62. package/src/control.mjs +197 -0
  63. package/src/doctor.mjs +299 -0
  64. package/src/domain/constants.mjs +47 -0
  65. package/src/domain/indexes.mjs +29 -0
  66. package/src/domain/rules/capabilities.mjs +33 -0
  67. package/src/domain/rules/harnesses.mjs +16 -0
  68. package/src/domain/rules/install-units.mjs +95 -0
  69. package/src/domain/rules/skills.mjs +105 -0
  70. package/src/domain/rules/workflows.mjs +105 -0
  71. package/src/domain/skill-state-matrix.mjs +79 -0
  72. package/src/domain/source-classes.mjs +99 -0
  73. package/src/hook-plan.mjs +152 -0
  74. package/src/impact.mjs +41 -0
  75. package/src/index.mjs +82 -0
  76. package/src/init.mjs +136 -0
  77. package/src/install-output-detector.mjs +337 -0
  78. package/src/install-units.mjs +62 -0
  79. package/src/inventory-refresh.mjs +45 -0
  80. package/src/lifecycle-cli.mjs +166 -0
  81. package/src/lifecycle-content.mjs +87 -0
  82. package/src/policy.mjs +42 -0
  83. package/src/reconcile.mjs +111 -0
  84. package/src/report.mjs +151 -0
  85. package/src/review.mjs +88 -0
  86. package/src/rollout.mjs +453 -0
  87. package/src/skill-paths.mjs +17 -0
  88. package/src/source-cache.mjs +178 -0
  89. package/src/source-profile-loader.mjs +160 -0
  90. package/src/source-profiles.mjs +299 -0
  91. package/src/source-verification.mjs +252 -0
  92. package/src/uninstall.mjs +258 -0
  93. package/src/workspace.mjs +219 -0
  94. package/tsconfig.lsp.json +15 -0
@@ -0,0 +1,453 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { basename, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { auditSources } from "./control.mjs";
5
+ import { checkPolicy } from "./policy.mjs";
6
+ import { loadWorkspace } from "./workspace.mjs";
7
+ import { hasRuntimeComponents, installUnitSourceClass, isModelSelectableInvocation } from "./domain/source-classes.mjs";
8
+
9
+ const REDACTED = "[REDACTED]";
10
+ const ROLLOUT_DIR = ".skillboard/rollouts";
11
+ const STATUS_EXIT_CODES = {
12
+ healthy: 0,
13
+ "safe-mode": 1,
14
+ "strict-failed": 2,
15
+ "apply-failed": 3,
16
+ "rollback-needed": 4
17
+ };
18
+
19
+ export async function rolloutAudit(options = {}) {
20
+ return await buildRolloutResult("rollout audit", options, {
21
+ mutation: { planned: false, applied: false }
22
+ });
23
+ }
24
+
25
+ export async function rolloutPlan(options = {}) {
26
+ return await buildRolloutResult("rollout plan", options, {
27
+ mutation: { planned: false, applied: false },
28
+ transaction: { required: true, state: "not-started" }
29
+ });
30
+ }
31
+
32
+ export async function rolloutReport(options = {}) {
33
+ const result = await buildRolloutResult("rollout report", options, {
34
+ mutation: { planned: false, applied: false }
35
+ });
36
+ return {
37
+ ...result,
38
+ fleet: fleetSummary(result.status)
39
+ };
40
+ }
41
+
42
+ export async function rolloutApply(options = {}) {
43
+ const initial = await buildRolloutResult("rollout apply", options, {
44
+ mutation: { planned: false, applied: false },
45
+ transaction: { required: true, state: "not-started" }
46
+ });
47
+ if (initial.status !== "healthy") {
48
+ return withStatus(
49
+ {
50
+ ...initial,
51
+ command: "rollout apply",
52
+ transaction: { ...initial.transaction, state: "blocked" }
53
+ },
54
+ "apply-failed"
55
+ );
56
+ }
57
+
58
+ try {
59
+ const paths = rolloutPaths(options);
60
+ const transaction = await createTransaction(paths, initial);
61
+ return redactResult({
62
+ ...initial,
63
+ command: "rollout apply",
64
+ mutation: { planned: false, applied: true },
65
+ transaction: {
66
+ required: true,
67
+ id: transaction.id,
68
+ state: "committed",
69
+ manifestPath: transaction.manifestPath
70
+ }
71
+ }, paths);
72
+ } catch (error) {
73
+ return withStatus(
74
+ {
75
+ ...initial,
76
+ command: "rollout apply",
77
+ errors: [...initial.errors, messageFor(error)],
78
+ transaction: { required: true, state: "rollback-needed" }
79
+ },
80
+ "rollback-needed"
81
+ );
82
+ }
83
+ }
84
+
85
+ export async function rolloutRollback(options = {}) {
86
+ const paths = rolloutPaths(options);
87
+ const transactionId = options.transaction;
88
+ if (transactionId === undefined || transactionId.trim() === "") {
89
+ throw new Error("Usage: skillboard rollout rollback --transaction <rollout-id>");
90
+ }
91
+ let safeId;
92
+ try {
93
+ safeId = safeTransactionId(transactionId);
94
+ } catch (error) {
95
+ return rolloutFailureResult("rollout rollback", paths, "rollback-needed", [messageFor(error)], {
96
+ required: true,
97
+ state: "rollback-needed"
98
+ });
99
+ }
100
+ const transactionDir = join(paths.rolloutsDir, safeId);
101
+ const manifestPath = join(transactionDir, "manifest.json");
102
+ try {
103
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
104
+ await validateRollbackManifest(manifest, { paths, transactionDir, expectedId: safeId });
105
+
106
+ for (const file of manifest.files) {
107
+ if (file.restore === "copy") {
108
+ await writeFile(file.path, await readFile(file.backupPath));
109
+ }
110
+ }
111
+
112
+ const result = await buildRolloutResult("rollout rollback", options, {
113
+ mutation: { planned: false, applied: false },
114
+ transaction: {
115
+ required: true,
116
+ id: manifest.id,
117
+ state: "rolled-back",
118
+ manifestPath
119
+ }
120
+ });
121
+ return redactResult({
122
+ ...result,
123
+ command: "rollout rollback",
124
+ status: result.status === "healthy" ? "healthy" : result.status,
125
+ exitCode: result.status === "healthy" ? STATUS_EXIT_CODES.healthy : result.exitCode,
126
+ transaction: {
127
+ required: true,
128
+ id: manifest.id,
129
+ state: "rolled-back",
130
+ manifestPath
131
+ }
132
+ }, paths);
133
+ } catch (error) {
134
+ return rolloutFailureResult("rollout rollback", paths, "rollback-needed", [messageFor(error)], {
135
+ required: true,
136
+ id: safeId,
137
+ state: "rollback-needed",
138
+ manifestPath
139
+ });
140
+ }
141
+ }
142
+
143
+ async function buildRolloutResult(command, options, extra = {}) {
144
+ const paths = rolloutPaths(options);
145
+ let workspace;
146
+ try {
147
+ workspace = await loadWorkspace({ configPath: paths.configPath, skillsRoot: paths.skillsRoot });
148
+ } catch (error) {
149
+ return redactResult({
150
+ command,
151
+ status: "strict-failed",
152
+ exitCode: STATUS_EXIT_CODES["strict-failed"],
153
+ nonInteractive: true,
154
+ paths: resultPaths(paths),
155
+ summary: {
156
+ policyErrors: 1,
157
+ sourceErrors: 0,
158
+ sourceWarnings: 0,
159
+ blockingWarnings: 0
160
+ },
161
+ policy: { ok: false, errors: [messageFor(error)], warnings: [] },
162
+ sources: { ok: false, errors: [], warnings: [], blockingWarnings: [], units: [] },
163
+ fleet: fleetSummary("strict-failed"),
164
+ errors: [messageFor(error)],
165
+ ...extra
166
+ }, paths);
167
+ }
168
+
169
+ const policy = checkPolicy(workspace);
170
+ const sources = auditSources(workspace);
171
+ const blockingWarnings = blockingSourceWarnings(sources.warnings);
172
+ const sourceGateErrors = sourceGateFailures(workspace, sources.units);
173
+ const status = classifyStatus({ policy, sources, blockingWarnings, sourceGateErrors });
174
+ const result = {
175
+ command,
176
+ status,
177
+ exitCode: STATUS_EXIT_CODES[status],
178
+ nonInteractive: true,
179
+ paths: redactedResultPaths(paths),
180
+ summary: {
181
+ policyErrors: policy.errors.length,
182
+ sourceErrors: sources.errors.length + sourceGateErrors.length,
183
+ sourceWarnings: sources.warnings.length,
184
+ blockingWarnings: blockingWarnings.length + sourceGateErrors.length
185
+ },
186
+ policy,
187
+ sources: {
188
+ ok: sources.ok && sourceGateErrors.length === 0,
189
+ errors: [...sources.errors, ...sourceGateErrors],
190
+ warnings: sources.warnings,
191
+ blockingWarnings: [...blockingWarnings, ...sourceGateErrors],
192
+ units: sources.units.map(redactSourceUnit)
193
+ },
194
+ fleet: fleetSummary(status),
195
+ errors: [...policy.errors, ...sources.errors, ...sourceGateErrors],
196
+ ...extra
197
+ };
198
+ return redactResult(result, paths);
199
+ }
200
+
201
+ function rolloutPaths(options) {
202
+ const root = resolve(options.root ?? ".");
203
+ const configPath = resolve(root, options.configPath ?? "skillboard.config.yaml");
204
+ const skillsRoot = options.skillsRoot === undefined ? undefined : resolve(root, options.skillsRoot);
205
+ const rolloutsDir = resolve(root, options.rolloutsDir ?? ROLLOUT_DIR);
206
+ return { root, configPath, skillsRoot, rolloutsDir };
207
+ }
208
+
209
+ function redactedResultPaths(paths) {
210
+ return {
211
+ root: REDACTED,
212
+ config: REDACTED,
213
+ skills: paths.skillsRoot === undefined ? null : REDACTED,
214
+ rollouts: REDACTED
215
+ };
216
+ }
217
+
218
+ function resultPaths(paths) {
219
+ return {
220
+ root: paths.root,
221
+ config: paths.configPath,
222
+ skills: paths.skillsRoot ?? null,
223
+ rollouts: paths.rolloutsDir
224
+ };
225
+ }
226
+
227
+ function classifyStatus({ policy, sources, blockingWarnings, sourceGateErrors }) {
228
+ if (!policy.ok || !sources.ok || blockingWarnings.length > 0 || sourceGateErrors.length > 0) {
229
+ return "strict-failed";
230
+ }
231
+ return "healthy";
232
+ }
233
+
234
+ function blockingSourceWarnings(warnings) {
235
+ return warnings.filter((warning) => {
236
+ return warning.includes("high-risk source is not reviewed or trusted")
237
+ || warning.includes("runtime extension source is unreviewed");
238
+ });
239
+ }
240
+
241
+ function sourceGateFailures(workspace, sourceUnits) {
242
+ const unitsById = new Map(sourceUnits.map((unit) => [unit.id, unit]));
243
+ const skillsByOwner = new Map();
244
+ for (const skill of workspace.skills) {
245
+ if (skill.ownerInstallUnit === undefined) {
246
+ continue;
247
+ }
248
+ const skills = skillsByOwner.get(skill.ownerInstallUnit) ?? [];
249
+ skills.push(skill);
250
+ skillsByOwner.set(skill.ownerInstallUnit, skills);
251
+ }
252
+
253
+ const failures = [];
254
+ for (const unit of workspace.installUnits) {
255
+ const sourceClass = installUnitSourceClass(unit);
256
+ const sourceAudit = unitsById.get(unit.id);
257
+ const automaticSkills = skillsByOwner.get(unit.id)
258
+ ?.filter((skill) => skill.status === "active" && isModelSelectableInvocation(skill.invocation))
259
+ .map((skill) => skill.id)
260
+ .sort((left, right) => left.localeCompare(right)) ?? [];
261
+ const runtimeLike = hasRuntimeComponents(unit) || sourceClass === "external-package" || sourceClass === "runtime-extension";
262
+ if (unit.enabled && runtimeLike && unit.trustLevel !== "reviewed" && unit.trustLevel !== "trusted" && automaticSkills.length > 0) {
263
+ failures.push(`${unit.id}: runtime/plugin/external source cannot activate model-selectable skills without reviewed policy`);
264
+ continue;
265
+ }
266
+ if (unit.enabled && runtimeLike && sourceAudit?.permissionRisk === "high" && unit.trustLevel !== "reviewed" && unit.trustLevel !== "trusted") {
267
+ failures.push(`${unit.id}: high-risk runtime/plugin/external source must be reviewed before rollout`);
268
+ }
269
+ }
270
+ return failures.sort((left, right) => left.localeCompare(right));
271
+ }
272
+
273
+ function withStatus(result, status) {
274
+ return {
275
+ ...result,
276
+ status,
277
+ exitCode: STATUS_EXIT_CODES[status],
278
+ fleet: fleetSummary(status)
279
+ };
280
+ }
281
+
282
+ function rolloutFailureResult(command, paths, status, errors, transaction) {
283
+ return redactResult({
284
+ command,
285
+ status,
286
+ exitCode: STATUS_EXIT_CODES[status],
287
+ nonInteractive: true,
288
+ paths: redactedResultPaths(paths),
289
+ summary: {
290
+ policyErrors: 0,
291
+ sourceErrors: 0,
292
+ sourceWarnings: 0,
293
+ blockingWarnings: 0
294
+ },
295
+ policy: { ok: false, errors, warnings: [] },
296
+ sources: { ok: false, errors: [], warnings: [], blockingWarnings: [], units: [] },
297
+ fleet: fleetSummary(status),
298
+ errors,
299
+ mutation: { planned: false, applied: false },
300
+ transaction
301
+ }, paths);
302
+ }
303
+
304
+ async function validateRollbackManifest(manifest, { paths, transactionDir, expectedId }) {
305
+ if (manifest === null || typeof manifest !== "object" || manifest.id !== expectedId || !Array.isArray(manifest.files)) {
306
+ throw new Error("Rollback manifest is invalid or does not match the requested transaction");
307
+ }
308
+ if (!isPathWithin(paths.rolloutsDir, transactionDir)) {
309
+ throw new Error("Rollback manifest transaction is outside the rollout directory");
310
+ }
311
+
312
+ let configRestoreCount = 0;
313
+ for (const file of manifest.files) {
314
+ if (file === null || typeof file !== "object" || typeof file.restore !== "string") {
315
+ throw new Error("Rollback manifest file entry is invalid");
316
+ }
317
+ if (file.restore !== "copy") {
318
+ continue;
319
+ }
320
+ if (file.role !== "config") {
321
+ throw new Error("Rollback manifest can only copy-restore the config file");
322
+ }
323
+ const restorePath = resolve(file.path);
324
+ const backupPath = resolve(file.backupPath);
325
+ if (restorePath !== paths.configPath) {
326
+ throw new Error("Rollback manifest target is not the expected config file");
327
+ }
328
+ if (!isPathWithin(transactionDir, backupPath)) {
329
+ throw new Error("Rollback manifest backup is outside the transaction directory");
330
+ }
331
+ const targetStat = await lstat(restorePath);
332
+ const backupStat = await lstat(backupPath);
333
+ if (!targetStat.isFile() || targetStat.isSymbolicLink() || !backupStat.isFile() || backupStat.isSymbolicLink()) {
334
+ throw new Error("Rollback manifest file entries must be regular files");
335
+ }
336
+ configRestoreCount += 1;
337
+ }
338
+ if (configRestoreCount !== 1) {
339
+ throw new Error("Rollback manifest must contain exactly one config restore entry");
340
+ }
341
+ }
342
+
343
+ function isPathWithin(parent, child) {
344
+ const relativePath = relative(resolve(parent), resolve(child));
345
+ return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
346
+ }
347
+
348
+ async function createTransaction(paths, result) {
349
+ const timestamp = new Date().toISOString().replace(/[-:.TZ]/gu, "").slice(0, 14);
350
+ const id = `rollout-${timestamp}-${randomUUID().slice(0, 8)}`;
351
+ await mkdir(paths.rolloutsDir, { recursive: true });
352
+ const transactionDir = join(paths.rolloutsDir, id);
353
+ await mkdir(transactionDir, { recursive: false });
354
+ const backupsDir = join(transactionDir, "backups");
355
+ await mkdir(backupsDir, { recursive: false });
356
+ const configBackupPath = join(backupsDir, basename(paths.configPath));
357
+ await writeFile(configBackupPath, await readFile(paths.configPath), { flag: "wx" });
358
+
359
+ const reportPath = join(transactionDir, "report.json");
360
+ await writeFile(reportPath, `${JSON.stringify(redactResult(result, paths), null, 2)}\n`, { encoding: "utf8", flag: "wx" });
361
+ const statePath = join(transactionDir, "state.json");
362
+ await writeFile(statePath, `${JSON.stringify({ id, status: result.status, committed: true }, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
363
+
364
+ const manifestPath = join(transactionDir, "manifest.json");
365
+ const manifest = {
366
+ id,
367
+ createdAt: new Date().toISOString(),
368
+ state: "committed",
369
+ files: [
370
+ {
371
+ role: "config",
372
+ path: paths.configPath,
373
+ backupPath: configBackupPath,
374
+ restore: "copy"
375
+ },
376
+ {
377
+ role: "rollout-report",
378
+ path: reportPath,
379
+ backupPath: reportPath,
380
+ restore: "preserve"
381
+ },
382
+ {
383
+ role: "rollout-state",
384
+ path: statePath,
385
+ backupPath: statePath,
386
+ restore: "preserve"
387
+ }
388
+ ]
389
+ };
390
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
391
+ return { id, manifestPath };
392
+ }
393
+
394
+ function fleetSummary(status) {
395
+ return {
396
+ total: 1,
397
+ byStatus: {
398
+ healthy: status === "healthy" ? 1 : 0,
399
+ "safe-mode": status === "safe-mode" ? 1 : 0,
400
+ "strict-failed": status === "strict-failed" ? 1 : 0,
401
+ "apply-failed": status === "apply-failed" ? 1 : 0,
402
+ "rollback-needed": status === "rollback-needed" ? 1 : 0
403
+ }
404
+ };
405
+ }
406
+
407
+ function safeTransactionId(id) {
408
+ if (!/^rollout-[A-Za-z0-9_-]+$/u.test(id)) {
409
+ throw new Error("Invalid rollout transaction id");
410
+ }
411
+ return id;
412
+ }
413
+
414
+ function redactSourceUnit(unit) {
415
+ return {
416
+ ...unit,
417
+ findings: unit.findings.map((finding) => ({ ...finding, message: redactString(finding.message) }))
418
+ };
419
+ }
420
+
421
+ function redactResult(value, paths) {
422
+ return redactValue(value, [paths.root, paths.configPath, paths.skillsRoot, paths.rolloutsDir].filter(Boolean));
423
+ }
424
+
425
+ function redactValue(value, pathValues) {
426
+ if (typeof value === "string") {
427
+ return redactString(value, pathValues);
428
+ }
429
+ if (Array.isArray(value)) {
430
+ return value.map((item) => redactValue(item, pathValues));
431
+ }
432
+ if (value !== null && typeof value === "object") {
433
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactValue(item, pathValues)]));
434
+ }
435
+ return value;
436
+ }
437
+
438
+ function redactString(value, pathValues = []) {
439
+ let redacted = value
440
+ .replace(/(token|password|secret|api[_-]?key)=([^\s&]+)/giu, "$1=[REDACTED]")
441
+ .replace(/(ghp|github_pat|sk|xox[baprs])-[-_A-Za-z0-9]+/gu, "[REDACTED]")
442
+ .replace(/SECRET/gu, "[REDACTED]");
443
+ for (const pathValue of pathValues) {
444
+ if (pathValue !== undefined && pathValue.length > 0) {
445
+ redacted = redacted.split(pathValue).join(REDACTED);
446
+ }
447
+ }
448
+ return redacted;
449
+ }
450
+
451
+ function messageFor(error) {
452
+ return error instanceof Error ? error.message : String(error);
453
+ }
@@ -0,0 +1,17 @@
1
+ export function normalizeSkillPath(value, label = "skill path") {
2
+ if (typeof value !== "string" || value.trim() === "") {
3
+ throw new Error(`${label} must be a non-empty relative path`);
4
+ }
5
+ if (value.includes("\0")) {
6
+ throw new Error(`${label} must not contain null bytes`);
7
+ }
8
+ const normalized = value.replaceAll("\\", "/");
9
+ if (normalized.startsWith("/") || normalized.startsWith("//") || /^[A-Za-z]:\//.test(normalized)) {
10
+ throw new Error(`${label} must be relative to the skills root`);
11
+ }
12
+ const segments = normalized.split("/");
13
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
14
+ throw new Error(`${label} must stay under the skills root`);
15
+ }
16
+ return normalized;
17
+ }
@@ -0,0 +1,178 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtemp, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import YAML from "yaml";
7
+ import { textChangePlan } from "./change-plan.mjs";
8
+ import { sourceDigest } from "./source-verification.mjs";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ export async function refreshSourcePins(options = {}) {
13
+ const root = resolve(options.root ?? ".");
14
+ const configPath = resolveUnderRoot(root, options.configPath ?? "skillboard.config.yaml");
15
+ const cacheRoot = resolveUnderRoot(root, options.cacheDir ?? ".skillboard/sources");
16
+ const dryRun = options.dryRun === true;
17
+ const unitFilter = new Set(options.units ?? []);
18
+ const originalText = await readFile(configPath, "utf8");
19
+ const document = YAML.parseDocument(originalText);
20
+ if (document.errors.length > 0) {
21
+ throw new Error(`Invalid YAML config: ${document.errors.map((error) => error.message).join("; ")}`);
22
+ }
23
+ const units = ensureMap(document, "install_units");
24
+ const refreshed = [];
25
+ const skipped = [];
26
+ const now = options.now ?? new Date().toISOString();
27
+
28
+ for (const pair of units.items) {
29
+ const id = String(pair.key?.value ?? "");
30
+ if (id.length === 0 || (unitFilter.size > 0 && !unitFilter.has(id))) {
31
+ continue;
32
+ }
33
+ const unitMap = requireYamlMap(pair.value, `install_units.${id}`);
34
+ const source = readMapString(unitMap, "source", "");
35
+ const gitUrl = gitUrlFromSource(source);
36
+ if (gitUrl === null) {
37
+ skipped.push({ id, reason: "source is not a fetchable git reference" });
38
+ continue;
39
+ }
40
+ const cachePath = join(cacheRoot, safeSegment(id));
41
+ await mkdir(dryRun ? tmpdir() : dirname(cachePath), { recursive: true });
42
+ const checkoutPath = dryRun
43
+ ? await mkdtemp(join(tmpdir(), "skillboard-source-refresh-"))
44
+ : await mkdtemp(join(dirname(cachePath), `.${basename(cachePath)}-`));
45
+ try {
46
+ await mkdir(dirname(checkoutPath), { recursive: true });
47
+ await cloneGit(gitUrl, checkoutPath);
48
+ const digest = await sourceDigest(checkoutPath);
49
+ const configCachePath = relativePath(root, cachePath);
50
+ unitMap.set("cache_path", configCachePath);
51
+ unitMap.set("source_digest", digest);
52
+ unitMap.set("verified_at", now);
53
+ refreshed.push({
54
+ id,
55
+ source,
56
+ gitUrl,
57
+ cachePath: configCachePath,
58
+ sourceDigest: digest,
59
+ verifiedAt: now
60
+ });
61
+ if (!dryRun) {
62
+ await rm(cachePath, { recursive: true, force: true });
63
+ await mkdir(dirname(cachePath), { recursive: true });
64
+ await rename(checkoutPath, cachePath);
65
+ }
66
+ } finally {
67
+ if (dryRun) {
68
+ await rm(checkoutPath, { recursive: true, force: true });
69
+ }
70
+ }
71
+ }
72
+
73
+ if (unitFilter.size > 0) {
74
+ for (const id of unitFilter) {
75
+ if (units.get(id, true) === undefined) {
76
+ skipped.push({ id, reason: "install unit not found" });
77
+ }
78
+ }
79
+ }
80
+
81
+ const nextText = preserveLineEndings(String(document), originalText);
82
+ const plan = textChangePlan(originalText, nextText);
83
+ if (plan.changed && !dryRun) {
84
+ await writeFile(configPath, nextText, "utf8");
85
+ }
86
+ return {
87
+ dryRun,
88
+ configPath,
89
+ cacheRoot,
90
+ changed: plan.changed,
91
+ plan,
92
+ refreshed,
93
+ skipped
94
+ };
95
+ }
96
+
97
+ export function gitUrlFromSource(source) {
98
+ const value = source.trim();
99
+ if (value.length === 0) {
100
+ return null;
101
+ }
102
+ const cloneMatch = /\bgit\s+clone\s+(?:--[^\s]+\s+)*(?<url>\S+)/u.exec(value);
103
+ if (cloneMatch?.groups?.url !== undefined) {
104
+ return normalizeGitUrl(cloneMatch.groups.url);
105
+ }
106
+ const directMatch = /(?<url>(?:https:\/\/|ssh:\/\/|git@|file:\/\/)\S+)/u.exec(value);
107
+ if (directMatch?.groups?.url !== undefined) {
108
+ return normalizeGitUrl(directMatch.groups.url);
109
+ }
110
+ const githubHostMatch = /(?:^|\s)(?<repo>github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?)(?:\s|$)/u.exec(value);
111
+ if (githubHostMatch?.groups?.repo !== undefined) {
112
+ return normalizeGitUrl(`https://${githubHostMatch.groups.repo}`);
113
+ }
114
+ const shorthandMatch = /(?:^|\s)(?<repo>[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?:\s|$)/u.exec(value);
115
+ if (shorthandMatch?.groups?.repo !== undefined && !shorthandMatch.groups.repo.startsWith("./") && !shorthandMatch.groups.repo.startsWith("../")) {
116
+ return normalizeGitUrl(`https://github.com/${shorthandMatch.groups.repo}`);
117
+ }
118
+ return null;
119
+ }
120
+
121
+ function normalizeGitUrl(value) {
122
+ const trimmed = value.replace(/^['"]|['"]$/gu, "");
123
+ if (trimmed.startsWith("git@") || trimmed.startsWith("ssh://") || trimmed.startsWith("file://")) {
124
+ return trimmed;
125
+ }
126
+ if (trimmed.startsWith("https://")) {
127
+ return trimmed.endsWith(".git") ? trimmed : `${trimmed}.git`;
128
+ }
129
+ return trimmed;
130
+ }
131
+
132
+ async function cloneGit(gitUrl, checkoutPath) {
133
+ await execFileAsync("git", ["clone", "--depth", "1", "--", gitUrl, checkoutPath], {
134
+ maxBuffer: 1024 * 1024 * 10
135
+ });
136
+ }
137
+
138
+ function ensureMap(document, key) {
139
+ const existing = document.get(key, true);
140
+ if (existing === undefined) {
141
+ const next = document.createNode({});
142
+ next.flow = false;
143
+ document.set(key, next);
144
+ return next;
145
+ }
146
+ return requireYamlMap(existing, key);
147
+ }
148
+
149
+ function requireYamlMap(value, label) {
150
+ if (!YAML.isMap(value)) {
151
+ throw new Error(`${label} must be a mapping`);
152
+ }
153
+ value.flow = false;
154
+ return value;
155
+ }
156
+
157
+ function readMapString(map, key, fallback) {
158
+ const raw = map.get(key);
159
+ return typeof raw === "string" ? raw : fallback;
160
+ }
161
+
162
+ function resolveUnderRoot(root, path) {
163
+ return isAbsolute(path) ? path : resolve(root, path);
164
+ }
165
+
166
+ function relativePath(root, path) {
167
+ const rel = relative(root, path).replaceAll("\\", "/");
168
+ return rel.startsWith("..") ? path : rel;
169
+ }
170
+
171
+ function safeSegment(value) {
172
+ const segment = value.toLowerCase().replace(/[^a-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "");
173
+ return segment.length === 0 ? "source" : segment;
174
+ }
175
+
176
+ function preserveLineEndings(text, reference) {
177
+ return reference.includes("\r\n") ? text.replace(/\n/g, "\r\n") : text;
178
+ }