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.
- package/CONTRIBUTING.md +83 -0
- package/LICENSE +21 -0
- package/README.md +645 -0
- package/bin/skillboard.mjs +4 -0
- package/docs/adapters.md +127 -0
- package/docs/capabilities.md +107 -0
- package/docs/install.md +346 -0
- package/docs/plans/20260625-080025-skillboard-mvp-review.md +189 -0
- package/docs/plans/README.md +20 -0
- package/docs/policy-model.md +251 -0
- package/docs/positioning.md +94 -0
- package/docs/profiles.md +166 -0
- package/docs/rollout-runbook.md +60 -0
- package/docs/user-flow.md +231 -0
- package/docs/versioning.md +201 -0
- package/examples/multi-source-skills/anthropic/docx/SKILL.md +8 -0
- package/examples/multi-source-skills/anthropic/skill-creator/SKILL.md +8 -0
- package/examples/multi-source-skills/matt/grill-me/SKILL.md +8 -0
- package/examples/multi-source-skills/matt/tdd/SKILL.md +8 -0
- package/examples/multi-source-skills/omo/review-work/SKILL.md +8 -0
- package/examples/multi-source-skills/omo/ulw-plan/SKILL.md +8 -0
- package/examples/multi-source-skills/private/tdd-work-continuity/SKILL.md +8 -0
- package/examples/multi-source-skills/private/workflow-router/SKILL.md +8 -0
- package/examples/multi-source-skills/wshobson/python-testing/SKILL.md +8 -0
- package/examples/multi-source-skills/wshobson/security-review/SKILL.md +8 -0
- package/examples/multi-source.config.yaml +271 -0
- package/examples/skillboard.config.yaml +194 -0
- package/examples/skills/grill-me/SKILL.md +9 -0
- package/examples/skills/grill-with-docs/SKILL.md +9 -0
- package/examples/skills/requirement-intake/SKILL.md +9 -0
- package/examples/skills/tdd/SKILL.md +8 -0
- package/package.json +58 -0
- package/profiles/anthropics-skills.yaml +17 -0
- package/profiles/mattpocock-skills.yaml +26 -0
- package/profiles/oh-my-openagent.yaml +32 -0
- package/profiles/voltagent-awesome-agent-skills.yaml +18 -0
- package/profiles/wshobson-agents.yaml +19 -0
- package/src/advisor/action-core.mjs +74 -0
- package/src/advisor/actions.mjs +256 -0
- package/src/advisor/application-commands.mjs +59 -0
- package/src/advisor/apply-action.mjs +183 -0
- package/src/advisor/schema.mjs +112 -0
- package/src/advisor/setup-actions.mjs +42 -0
- package/src/advisor/skills.mjs +191 -0
- package/src/advisor/sort.mjs +15 -0
- package/src/advisor/sources.mjs +160 -0
- package/src/advisor/trust-policy.mjs +65 -0
- package/src/advisor/workflow.mjs +41 -0
- package/src/advisor.mjs +134 -0
- package/src/agent-inventory-platforms.mjs +100 -0
- package/src/agent-inventory.mjs +804 -0
- package/src/brief-cli.mjs +40 -0
- package/src/brief-renderer.mjs +362 -0
- package/src/change-plan.mjs +169 -0
- package/src/cli.mjs +1171 -0
- package/src/config-helpers.mjs +72 -0
- package/src/control/can-use-guard.mjs +91 -0
- package/src/control/config-write.mjs +163 -0
- package/src/control/skill-crud.mjs +394 -0
- package/src/control/source-trust.mjs +161 -0
- package/src/control/workflow-crud.mjs +104 -0
- package/src/control.mjs +197 -0
- package/src/doctor.mjs +299 -0
- package/src/domain/constants.mjs +47 -0
- package/src/domain/indexes.mjs +29 -0
- package/src/domain/rules/capabilities.mjs +33 -0
- package/src/domain/rules/harnesses.mjs +16 -0
- package/src/domain/rules/install-units.mjs +95 -0
- package/src/domain/rules/skills.mjs +105 -0
- package/src/domain/rules/workflows.mjs +105 -0
- package/src/domain/skill-state-matrix.mjs +79 -0
- package/src/domain/source-classes.mjs +99 -0
- package/src/hook-plan.mjs +152 -0
- package/src/impact.mjs +41 -0
- package/src/index.mjs +82 -0
- package/src/init.mjs +136 -0
- package/src/install-output-detector.mjs +337 -0
- package/src/install-units.mjs +62 -0
- package/src/inventory-refresh.mjs +45 -0
- package/src/lifecycle-cli.mjs +166 -0
- package/src/lifecycle-content.mjs +87 -0
- package/src/policy.mjs +42 -0
- package/src/reconcile.mjs +111 -0
- package/src/report.mjs +151 -0
- package/src/review.mjs +88 -0
- package/src/rollout.mjs +453 -0
- package/src/skill-paths.mjs +17 -0
- package/src/source-cache.mjs +178 -0
- package/src/source-profile-loader.mjs +160 -0
- package/src/source-profiles.mjs +299 -0
- package/src/source-verification.mjs +252 -0
- package/src/uninstall.mjs +258 -0
- package/src/workspace.mjs +219 -0
- package/tsconfig.lsp.json +15 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { lstat, readFile, readdir, readlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { createHash, verify as verifySignature } from "node:crypto";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import YAML from "yaml";
|
|
6
|
+
import { installUnitSourceClass, isLocalSourceReference } from "./domain/source-classes.mjs";
|
|
7
|
+
|
|
8
|
+
const DIGEST_PREFIX = "sha256:";
|
|
9
|
+
|
|
10
|
+
export async function verifySources(workspace, options = {}) {
|
|
11
|
+
const configDir = options.configPath === undefined ? process.cwd() : dirname(resolve(options.configPath));
|
|
12
|
+
const rootDir = options.rootDir === undefined ? process.cwd() : resolve(options.rootDir);
|
|
13
|
+
const units = [];
|
|
14
|
+
for (const unit of workspace.installUnits) {
|
|
15
|
+
units.push(await verifyInstallUnit(unit, { configDir, rootDir, restrictToRoot: options.restrictToRoot === true }));
|
|
16
|
+
}
|
|
17
|
+
const errors = units.flatMap((unit) => unit.findings
|
|
18
|
+
.filter((finding) => finding.severity === "error")
|
|
19
|
+
.map((finding) => `${unit.id}: ${finding.message}`));
|
|
20
|
+
const warnings = units.flatMap((unit) => unit.findings
|
|
21
|
+
.filter((finding) => finding.severity === "warning")
|
|
22
|
+
.map((finding) => `${unit.id}: ${finding.message}`));
|
|
23
|
+
return { ok: errors.length === 0, units, errors, warnings };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function writeLockfile(workspace, options) {
|
|
27
|
+
const verified = await verifySources(workspace, { configPath: options.configPath, rootDir: options.rootDir });
|
|
28
|
+
if (!verified.ok && options.allowUnverified !== true) {
|
|
29
|
+
throw new Error(`Cannot write lockfile because source verification failed:\n${verified.errors.join("\n")}`);
|
|
30
|
+
}
|
|
31
|
+
const text = await renderLockfile(workspace, { ...options, verified });
|
|
32
|
+
await writeFile(options.out, text, { encoding: "utf8", flag: options.replace === true ? "w" : "wx" });
|
|
33
|
+
return { path: options.out, bytes: Buffer.byteLength(text) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function renderLockfile(workspace, options = {}) {
|
|
37
|
+
const verified = options.verified ?? await verifySources(workspace, { configPath: options.configPath, rootDir: options.rootDir });
|
|
38
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
39
|
+
const skillDigests = await skillContentDigests(workspace, options.skillsRoot);
|
|
40
|
+
return YAML.stringify({
|
|
41
|
+
version: 1,
|
|
42
|
+
generated_at: generatedAt,
|
|
43
|
+
config_schema_version: workspace.version,
|
|
44
|
+
install_units: Object.fromEntries(verified.units.map((unit) => [unit.id, {
|
|
45
|
+
kind: unit.kind,
|
|
46
|
+
source: unit.source,
|
|
47
|
+
cache_path: unit.cachePath,
|
|
48
|
+
verified_path: unit.verifiedPath,
|
|
49
|
+
verified_field: unit.verifiedField,
|
|
50
|
+
source_class: unit.sourceClass,
|
|
51
|
+
trust_level: unit.trustLevel,
|
|
52
|
+
permission_risk: unit.permissionRisk,
|
|
53
|
+
source_digest: unit.actualDigest ?? unit.expectedDigest ?? null,
|
|
54
|
+
digest_verified: unit.digestVerified,
|
|
55
|
+
signature_verified: unit.signatureVerified,
|
|
56
|
+
status: unit.status
|
|
57
|
+
}])),
|
|
58
|
+
skills: Object.fromEntries(workspace.skills.map((skill) => [skill.id, {
|
|
59
|
+
path: skill.path,
|
|
60
|
+
status: skill.status,
|
|
61
|
+
invocation: skill.invocation,
|
|
62
|
+
exposure: skill.exposure,
|
|
63
|
+
owner_install_unit: skill.ownerInstallUnit ?? null,
|
|
64
|
+
content_digest: skillDigests.get(skill.id) ?? null
|
|
65
|
+
}])),
|
|
66
|
+
workflows: Object.fromEntries(workspace.workflows.map((workflow) => [workflow.name, {
|
|
67
|
+
harness: workflow.harness,
|
|
68
|
+
active_skills: workflow.activeSkills,
|
|
69
|
+
blocked_skills: workflow.blockedSkills,
|
|
70
|
+
required_capabilities: Object.fromEntries(workflow.requiredCapabilities.map((capability) => [capability.name, {
|
|
71
|
+
preferred: capability.preferred,
|
|
72
|
+
fallback: capability.fallback,
|
|
73
|
+
policy: capability.policy
|
|
74
|
+
}]))
|
|
75
|
+
}]))
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function verifyInstallUnit(unit, options) {
|
|
80
|
+
const target = await verificationTarget(unit, options);
|
|
81
|
+
const localPath = target.path;
|
|
82
|
+
const findings = [];
|
|
83
|
+
let actualDigest = null;
|
|
84
|
+
let digestVerified = false;
|
|
85
|
+
let signatureVerified = false;
|
|
86
|
+
let status = "metadata-only";
|
|
87
|
+
|
|
88
|
+
if (localPath !== null && options.restrictToRoot === true && !isPathInside(options.rootDir, localPath)) {
|
|
89
|
+
status = "unverified";
|
|
90
|
+
findings.push({ severity: "error", message: `${target.field} is outside the allowed root: ${localPath}` });
|
|
91
|
+
} else if (localPath === null) {
|
|
92
|
+
if (unit.sourceDigest === undefined) {
|
|
93
|
+
findings.push({ severity: "warning", message: "remote or command source has no source_digest pin" });
|
|
94
|
+
}
|
|
95
|
+
if (unit.signature !== undefined && unit.publicKey === undefined) {
|
|
96
|
+
findings.push({ severity: "error", message: "signature is configured without public_key" });
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
try {
|
|
100
|
+
actualDigest = await sourceDigest(localPath);
|
|
101
|
+
status = "verified-local";
|
|
102
|
+
if (unit.sourceDigest === undefined) {
|
|
103
|
+
findings.push({ severity: "warning", message: `${target.field} is not pinned; computed ${actualDigest}` });
|
|
104
|
+
} else if (unit.sourceDigest !== actualDigest) {
|
|
105
|
+
findings.push({ severity: "error", message: `source_digest mismatch: expected ${unit.sourceDigest}, got ${actualDigest}` });
|
|
106
|
+
} else {
|
|
107
|
+
digestVerified = true;
|
|
108
|
+
}
|
|
109
|
+
if (unit.signature !== undefined) {
|
|
110
|
+
if (unit.publicKey === undefined) {
|
|
111
|
+
findings.push({ severity: "error", message: "signature is configured without public_key" });
|
|
112
|
+
} else if (verifyDigestSignature(actualDigest, unit.signature, unit.publicKey)) {
|
|
113
|
+
signatureVerified = true;
|
|
114
|
+
} else {
|
|
115
|
+
findings.push({ severity: "error", message: "signature does not verify source_digest" });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
status = "unverified";
|
|
120
|
+
findings.push({ severity: "error", message: `cannot verify local source: ${error instanceof Error ? error.message : String(error)}` });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
id: unit.id,
|
|
126
|
+
kind: unit.kind,
|
|
127
|
+
source: unit.source,
|
|
128
|
+
cachePath: unit.cachePath.length === 0 ? null : unit.cachePath,
|
|
129
|
+
verifiedPath: localPath,
|
|
130
|
+
verifiedField: target.field,
|
|
131
|
+
sourceClass: installUnitSourceClass(unit),
|
|
132
|
+
trustLevel: unit.trustLevel,
|
|
133
|
+
permissionRisk: unit.permissionRisk,
|
|
134
|
+
expectedDigest: unit.sourceDigest ?? null,
|
|
135
|
+
actualDigest,
|
|
136
|
+
digestVerified,
|
|
137
|
+
signatureVerified,
|
|
138
|
+
status,
|
|
139
|
+
findings
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isPathInside(root, path) {
|
|
144
|
+
const relativePath = relative(root, path);
|
|
145
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function verificationTarget(unit, options) {
|
|
149
|
+
const cachePath = unit.cachePath.trim();
|
|
150
|
+
if (cachePath.length > 0) {
|
|
151
|
+
const path = await localSourcePath(cachePath, options, { allowBareRelative: true });
|
|
152
|
+
if (path !== null) {
|
|
153
|
+
return { path, field: "cache_path" };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { path: await localSourcePath(unit.source, options, { allowBareRelative: false }), field: "source" };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function sourceDigest(path) {
|
|
160
|
+
const hash = createHash("sha256");
|
|
161
|
+
hash.update("skillboard-source-digest-v1\n");
|
|
162
|
+
await addPathDigest(hash, path, path);
|
|
163
|
+
return `${DIGEST_PREFIX}${hash.digest("hex")}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function addPathDigest(hash, root, path) {
|
|
167
|
+
const stats = await lstat(path);
|
|
168
|
+
const rel = relative(root, path).replaceAll("\\", "/") || ".";
|
|
169
|
+
if (stats.isSymbolicLink()) {
|
|
170
|
+
hash.update(`symlink\0${rel}\0${await readlink(path)}\n`);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (stats.isDirectory()) {
|
|
174
|
+
hash.update(`dir\0${rel}\n`);
|
|
175
|
+
const entries = (await readdir(path, { withFileTypes: true }))
|
|
176
|
+
.filter((entry) => entry.name !== ".git" && entry.name !== "node_modules")
|
|
177
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
178
|
+
for (const entry of entries) {
|
|
179
|
+
await addPathDigest(hash, root, join(path, entry.name));
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (!stats.isFile()) {
|
|
184
|
+
hash.update(`other\0${rel}\n`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
hash.update(`file\0${rel}\0${stats.size}\0`);
|
|
188
|
+
hash.update(await readFile(path));
|
|
189
|
+
hash.update("\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function verifyDigestSignature(digest, signature, publicKey) {
|
|
193
|
+
try {
|
|
194
|
+
return verifySignature("sha256", Buffer.from(digest), publicKey, Buffer.from(signature, "base64"));
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function localSourcePath(source, paths, options) {
|
|
201
|
+
const value = source.trim();
|
|
202
|
+
if (!isLocalSourceReference(value, options)) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
if (value.startsWith("~/")) {
|
|
206
|
+
return resolve(homedir(), value.slice(2));
|
|
207
|
+
}
|
|
208
|
+
if (isAbsolute(value)) {
|
|
209
|
+
return value;
|
|
210
|
+
}
|
|
211
|
+
const candidates = uniquePaths([
|
|
212
|
+
resolve(paths.configDir, value),
|
|
213
|
+
resolve(paths.rootDir, value)
|
|
214
|
+
]);
|
|
215
|
+
return await firstExistingPath(candidates) ?? candidates[0] ?? null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function firstExistingPath(paths) {
|
|
219
|
+
for (const path of paths) {
|
|
220
|
+
try {
|
|
221
|
+
await lstat(path);
|
|
222
|
+
return path;
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error?.code !== "ENOENT") {
|
|
225
|
+
return path;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function uniquePaths(paths) {
|
|
233
|
+
return [...new Set(paths)];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function skillContentDigests(workspace, skillsRoot) {
|
|
237
|
+
const digests = new Map();
|
|
238
|
+
if (skillsRoot === undefined) {
|
|
239
|
+
return digests;
|
|
240
|
+
}
|
|
241
|
+
for (const skill of workspace.skills) {
|
|
242
|
+
const skillPath = join(skillsRoot, skill.path, "SKILL.md");
|
|
243
|
+
try {
|
|
244
|
+
const hash = createHash("sha256");
|
|
245
|
+
hash.update(await readFile(skillPath));
|
|
246
|
+
digests.set(skill.id, `${DIGEST_PREFIX}${hash.digest("hex")}`);
|
|
247
|
+
} catch {
|
|
248
|
+
digests.set(skill.id, null);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return digests;
|
|
252
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { access, lstat, readFile, readdir, rm, rmdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { BRIDGE_END, BRIDGE_START, defaultConfig, hookReadme, profileReadme } from "./lifecycle-content.mjs";
|
|
4
|
+
|
|
5
|
+
export async function uninstallProject(options) {
|
|
6
|
+
const root = options.root;
|
|
7
|
+
const dryRun = options.dryRun === true;
|
|
8
|
+
const removed = [];
|
|
9
|
+
const updated = [];
|
|
10
|
+
const preserved = [];
|
|
11
|
+
const plannedRemovedPaths = new Set();
|
|
12
|
+
|
|
13
|
+
for (const filename of ["AGENTS.md", "CLAUDE.md"]) {
|
|
14
|
+
const path = join(root, filename);
|
|
15
|
+
const result = await removeBridge(path, dryRun);
|
|
16
|
+
if (result === "removed") {
|
|
17
|
+
plannedRemovedPaths.add(path);
|
|
18
|
+
}
|
|
19
|
+
recordFileResult(filename, result, { removed, updated, preserved });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for (const entry of generatedFiles(root)) {
|
|
23
|
+
const result = await removeGeneratedFile(entry.path, entry.expected, dryRun);
|
|
24
|
+
if (result === "removed") {
|
|
25
|
+
plannedRemovedPaths.add(entry.path);
|
|
26
|
+
}
|
|
27
|
+
recordFileResult(entry.label, result, { removed, updated, preserved });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (options.resetConfig === true) {
|
|
31
|
+
const path = join(root, "skillboard.config.yaml");
|
|
32
|
+
const result = await removeConfigFile(path, dryRun);
|
|
33
|
+
if (result === "removed") {
|
|
34
|
+
plannedRemovedPaths.add(path);
|
|
35
|
+
}
|
|
36
|
+
recordFileResult("skillboard.config.yaml", result, { removed, updated, preserved });
|
|
37
|
+
} else if (options.removeConfig === true) {
|
|
38
|
+
const path = join(root, "skillboard.config.yaml");
|
|
39
|
+
const result = await removeGeneratedFile(path, defaultConfig(), dryRun);
|
|
40
|
+
if (result === "removed") {
|
|
41
|
+
plannedRemovedPaths.add(path);
|
|
42
|
+
}
|
|
43
|
+
recordFileResult("skillboard.config.yaml", result, { removed, updated, preserved });
|
|
44
|
+
} else if (await exists(join(root, "skillboard.config.yaml"))) {
|
|
45
|
+
preserved.push("skillboard.config.yaml");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (options.removeReports === true) {
|
|
49
|
+
const path = join(root, ".skillboard", "reports");
|
|
50
|
+
const result = await removeGeneratedDir(path, dryRun);
|
|
51
|
+
if (result === "removed") {
|
|
52
|
+
plannedRemovedPaths.add(path);
|
|
53
|
+
}
|
|
54
|
+
recordFileResult(".skillboard/reports", result, { removed, updated, preserved });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (options.removeHooks === true) {
|
|
58
|
+
const path = join(root, ".skillboard", "hooks");
|
|
59
|
+
const result = await removeGeneratedDir(path, dryRun);
|
|
60
|
+
if (result === "removed") {
|
|
61
|
+
plannedRemovedPaths.add(path);
|
|
62
|
+
}
|
|
63
|
+
recordFileResult(".skillboard/hooks", result, { removed, updated, preserved });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (options.removeEmptyDirs !== false) {
|
|
67
|
+
for (const dir of emptyDirs(root, {
|
|
68
|
+
skipReports: options.removeReports === true,
|
|
69
|
+
skipHooks: options.removeHooks === true
|
|
70
|
+
})) {
|
|
71
|
+
const result = await removeEmptyDir(dir.path, dryRun, plannedRemovedPaths);
|
|
72
|
+
if (result === "removed") {
|
|
73
|
+
plannedRemovedPaths.add(dir.path);
|
|
74
|
+
}
|
|
75
|
+
recordFileResult(dir.label, result, { removed, updated, preserved });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { dryRun, removed, updated, preserved };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function generatedFiles(root) {
|
|
83
|
+
return [
|
|
84
|
+
{
|
|
85
|
+
label: ".skillboard/profiles/README.md",
|
|
86
|
+
path: join(root, ".skillboard", "profiles", "README.md"),
|
|
87
|
+
expected: profileReadme()
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
label: ".skillboard/hooks/README.md",
|
|
91
|
+
path: join(root, ".skillboard", "hooks", "README.md"),
|
|
92
|
+
expected: hookReadme()
|
|
93
|
+
}
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function emptyDirs(root, options = {}) {
|
|
98
|
+
const dirs = [
|
|
99
|
+
{ label: ".skillboard/profiles", path: join(root, ".skillboard", "profiles") },
|
|
100
|
+
{ label: ".skillboard/hooks", path: join(root, ".skillboard", "hooks") },
|
|
101
|
+
{ label: ".skillboard/reports", path: join(root, ".skillboard", "reports") },
|
|
102
|
+
{ label: ".skillboard", path: join(root, ".skillboard") },
|
|
103
|
+
{ label: "skills", path: join(root, "skills") }
|
|
104
|
+
];
|
|
105
|
+
return dirs.filter((dir) => {
|
|
106
|
+
if (options.skipReports === true && dir.label === ".skillboard/reports") {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (options.skipHooks === true && dir.label === ".skillboard/hooks") {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function removeBridge(path, dryRun) {
|
|
117
|
+
const stats = await pathStats(path);
|
|
118
|
+
if (stats === null) {
|
|
119
|
+
return "absent";
|
|
120
|
+
}
|
|
121
|
+
if (stats.isSymbolicLink() || !stats.isFile()) {
|
|
122
|
+
return "preserved";
|
|
123
|
+
}
|
|
124
|
+
const current = await readFile(path, "utf8");
|
|
125
|
+
const next = withoutBridgeBlock(current);
|
|
126
|
+
if (next === current) {
|
|
127
|
+
return "preserved";
|
|
128
|
+
}
|
|
129
|
+
if (next.trim().length === 0) {
|
|
130
|
+
if (!dryRun) {
|
|
131
|
+
await rm(path);
|
|
132
|
+
}
|
|
133
|
+
return "removed";
|
|
134
|
+
}
|
|
135
|
+
if (!dryRun) {
|
|
136
|
+
await writeFile(path, next, "utf8");
|
|
137
|
+
}
|
|
138
|
+
return "updated";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function withoutBridgeBlock(text) {
|
|
142
|
+
const start = text.indexOf(BRIDGE_START);
|
|
143
|
+
if (start === -1) {
|
|
144
|
+
return text;
|
|
145
|
+
}
|
|
146
|
+
const end = text.indexOf(BRIDGE_END, start);
|
|
147
|
+
if (end === -1) {
|
|
148
|
+
return text;
|
|
149
|
+
}
|
|
150
|
+
const afterBlock = end + BRIDGE_END.length;
|
|
151
|
+
let before = text.slice(0, start);
|
|
152
|
+
let after = text.slice(afterBlock).replace(/^\r?\n/u, "");
|
|
153
|
+
if (before.endsWith("\r\n\r\n")) {
|
|
154
|
+
before = before.slice(0, -2);
|
|
155
|
+
} else if (before.endsWith("\n\n")) {
|
|
156
|
+
before = before.slice(0, -1);
|
|
157
|
+
}
|
|
158
|
+
if (before.trim().length === 0 && after.trim().length === 0) {
|
|
159
|
+
return "";
|
|
160
|
+
}
|
|
161
|
+
if (before.length > 0 && after.length > 0 && !before.endsWith("\n")) {
|
|
162
|
+
after = `${lineEnding(text)}${after}`;
|
|
163
|
+
}
|
|
164
|
+
return `${before}${after}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function removeGeneratedFile(path, expected, dryRun) {
|
|
168
|
+
const stats = await pathStats(path);
|
|
169
|
+
if (stats === null) {
|
|
170
|
+
return "absent";
|
|
171
|
+
}
|
|
172
|
+
if (stats.isSymbolicLink() || !stats.isFile()) {
|
|
173
|
+
return "preserved";
|
|
174
|
+
}
|
|
175
|
+
const current = await readFile(path, "utf8");
|
|
176
|
+
if (current !== expected) {
|
|
177
|
+
return "preserved";
|
|
178
|
+
}
|
|
179
|
+
if (!dryRun) {
|
|
180
|
+
await rm(path);
|
|
181
|
+
}
|
|
182
|
+
return "removed";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function removeConfigFile(path, dryRun) {
|
|
186
|
+
const stats = await pathStats(path);
|
|
187
|
+
if (stats === null) {
|
|
188
|
+
return "absent";
|
|
189
|
+
}
|
|
190
|
+
if (stats.isSymbolicLink() || !stats.isFile()) {
|
|
191
|
+
return "preserved";
|
|
192
|
+
}
|
|
193
|
+
if (!dryRun) {
|
|
194
|
+
await rm(path);
|
|
195
|
+
}
|
|
196
|
+
return "removed";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function removeGeneratedDir(path, dryRun) {
|
|
200
|
+
const stats = await pathStats(path);
|
|
201
|
+
if (stats === null) {
|
|
202
|
+
return "absent";
|
|
203
|
+
}
|
|
204
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
205
|
+
return "preserved";
|
|
206
|
+
}
|
|
207
|
+
if (!dryRun) {
|
|
208
|
+
await rm(path, { recursive: true });
|
|
209
|
+
}
|
|
210
|
+
return "removed";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function removeEmptyDir(path, dryRun, plannedRemovedPaths) {
|
|
214
|
+
const stats = await pathStats(path);
|
|
215
|
+
if (stats === null) {
|
|
216
|
+
return "absent";
|
|
217
|
+
}
|
|
218
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
219
|
+
return "preserved";
|
|
220
|
+
}
|
|
221
|
+
const entries = (await readdir(path)).filter((entry) => !plannedRemovedPaths.has(join(path, entry)));
|
|
222
|
+
if (entries.length > 0) {
|
|
223
|
+
return "preserved";
|
|
224
|
+
}
|
|
225
|
+
if (!dryRun) {
|
|
226
|
+
await rmdir(path);
|
|
227
|
+
}
|
|
228
|
+
return "removed";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function recordFileResult(label, result, output) {
|
|
232
|
+
if (result === "removed") {
|
|
233
|
+
output.removed.push(label);
|
|
234
|
+
} else if (result === "updated") {
|
|
235
|
+
output.updated.push(label);
|
|
236
|
+
} else if (result === "preserved") {
|
|
237
|
+
output.preserved.push(label);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function lineEnding(text) {
|
|
242
|
+
return text.includes("\r\n") ? "\r\n" : "\n";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function exists(path) {
|
|
246
|
+
return access(path).then(() => true, () => false);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function pathStats(path) {
|
|
250
|
+
try {
|
|
251
|
+
return await lstat(path);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (error?.code === "ENOENT") {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|