agent-skillboard 0.2.14 → 0.2.16

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.
@@ -1,26 +1,26 @@
1
- import { readdir, readFile } from "node:fs/promises";
2
- import { basename, dirname, join, relative, resolve } from "node:path";
3
- import YAML from "yaml";
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { basename, dirname, join, relative, resolve } from "node:path";
3
+ import YAML from "yaml";
4
4
  import { readString, requireRecord } from "./config-helpers.mjs";
5
5
  import { textChangePlan } from "./change-plan.mjs";
6
6
  import { loadSourceProfile } from "./source-profile-loader.mjs";
7
-
8
- export { loadSourceProfile };
9
-
10
- export async function importSource(options) {
11
- const sourceRoot = resolve(options.sourceRoot);
12
- const profile = options.profile;
13
- const files = await findSkillFiles(sourceRoot);
14
- const matchedFiles = files
15
- .map((file) => ({
16
- file,
7
+
8
+ export { loadSourceProfile };
9
+
10
+ export async function importSource(options) {
11
+ const sourceRoot = resolve(options.sourceRoot);
12
+ const profile = options.profile;
13
+ const files = await findSkillFiles(sourceRoot);
14
+ const matchedFiles = files
15
+ .map((file) => ({
16
+ file,
17
17
  relativeFile: relative(sourceRoot, file).replace(/\\/g, "/")
18
- }))
19
- .filter((entry) => matchesAnyProfilePattern(entry.relativeFile, profile.skillPaths))
20
- .sort((left, right) => left.relativeFile.localeCompare(right.relativeFile));
21
- const skills = [];
22
- const warnings = [];
23
-
18
+ }))
19
+ .filter((entry) => matchesAnyProfilePattern(entry.relativeFile, profile.skillPaths))
20
+ .sort((left, right) => left.relativeFile.localeCompare(right.relativeFile));
21
+ const skills = [];
22
+ const warnings = [];
23
+
24
24
  for (const entry of matchedFiles) {
25
25
  const frontmatter = parseSkillFrontmatter(await readFile(entry.file, "utf8"));
26
26
  const pathRule = matchingPathRule(entry.relativeFile, profile);
@@ -37,17 +37,17 @@ export async function importSource(options) {
37
37
  ownerInstallUnit: profile.id,
38
38
  description: frontmatter.description
39
39
  });
40
- }
41
-
42
- if (skills.length === 0) {
43
- warnings.push(`No SKILL.md files matched profile ${profile.id} under ${sourceRoot}`);
44
- }
45
-
46
- return {
47
- profile,
48
- sourceRoot,
49
- skills,
50
- installUnit: {
40
+ }
41
+
42
+ if (skills.length === 0) {
43
+ warnings.push(`No SKILL.md files matched profile ${profile.id} under ${sourceRoot}`);
44
+ }
45
+
46
+ return {
47
+ profile,
48
+ sourceRoot,
49
+ skills,
50
+ installUnit: {
51
51
  id: profile.id,
52
52
  kind: profile.kind,
53
53
  sourceClass: profile.sourceClass,
@@ -58,31 +58,31 @@ export async function importSource(options) {
58
58
  publicKey: profile.publicKey,
59
59
  verifiedAt: profile.verifiedAt,
60
60
  source: profile.source || sourceRoot,
61
- scope: profile.scope,
62
- manifestPath: profile.manifestPath,
63
- cachePath: profile.cachePath,
64
- providedComponents: profile.providedComponents,
65
- components: {
66
- skills: skills.map((skill) => skill.id),
67
- commands: profile.components.commands,
68
- hooks: profile.components.hooks,
69
- mcpServers: profile.components.mcpServers
70
- },
71
- modifiedConfigFiles: profile.modifiedConfigFiles,
72
- autoUpdate: profile.autoUpdate,
73
- enabled: profile.enabled,
74
- workflowDependencies: profile.workflowDependencies,
75
- permissionRisk: profile.permissionRisk,
76
- rollback: profile.rollback
77
- },
78
- warnings
79
- };
80
- }
81
-
82
- export function renderImportFragment(imported) {
83
- return YAML.stringify(importFragment(imported));
84
- }
85
-
61
+ scope: profile.scope,
62
+ manifestPath: profile.manifestPath,
63
+ cachePath: profile.cachePath,
64
+ providedComponents: profile.providedComponents,
65
+ components: {
66
+ skills: skills.map((skill) => skill.id),
67
+ commands: profile.components.commands,
68
+ hooks: profile.components.hooks,
69
+ mcpServers: profile.components.mcpServers
70
+ },
71
+ modifiedConfigFiles: profile.modifiedConfigFiles,
72
+ autoUpdate: profile.autoUpdate,
73
+ enabled: profile.enabled,
74
+ workflowDependencies: profile.workflowDependencies,
75
+ permissionRisk: profile.permissionRisk,
76
+ rollback: profile.rollback
77
+ },
78
+ warnings
79
+ };
80
+ }
81
+
82
+ export function renderImportFragment(imported) {
83
+ return YAML.stringify(importFragment(imported));
84
+ }
85
+
86
86
  export function mergeImportFragment(configText, imported, options = {}) {
87
87
  const document = YAML.parseDocument(configText);
88
88
  if (document.errors.length > 0) {
@@ -117,7 +117,7 @@ export function mergeImportFragment(configText, imported, options = {}) {
117
117
  addedSkills: skillIds,
118
118
  addedInstallUnits: unitIds,
119
119
  replacedSkills: duplicateSkills,
120
- replacedInstallUnits: duplicateUnits
120
+ replacedInstallUnits: duplicateUnits
121
121
  };
122
122
  }
123
123
 
@@ -141,20 +141,20 @@ function requireYamlMap(value, label) {
141
141
  function preserveLineEndings(text, reference) {
142
142
  return reference.includes("\r\n") ? text.replace(/\n/g, "\r\n") : text;
143
143
  }
144
-
144
+
145
145
  function importFragment(imported) {
146
- const skills = {};
147
- for (const skill of imported.skills) {
148
- skills[skill.id] = {
149
- path: skill.path,
150
- status: skill.status,
151
- invocation: skill.invocation,
152
- exposure: skill.exposure,
153
- category: skill.category,
154
- owner_install_unit: skill.ownerInstallUnit
155
- };
156
- }
157
- const unit = imported.installUnit;
146
+ const skills = {};
147
+ for (const skill of imported.skills) {
148
+ skills[skill.id] = {
149
+ path: skill.path,
150
+ status: skill.status,
151
+ invocation: skill.invocation,
152
+ exposure: skill.exposure,
153
+ category: skill.category,
154
+ owner_install_unit: skill.ownerInstallUnit
155
+ };
156
+ }
157
+ const unit = imported.installUnit;
158
158
  return stripUndefined({
159
159
  skills,
160
160
  install_units: {
@@ -168,64 +168,64 @@ function importFragment(imported) {
168
168
  public_key: unit.publicKey,
169
169
  verified_at: unit.verifiedAt,
170
170
  source: unit.source,
171
- scope: unit.scope,
172
- manifest_path: unit.manifestPath,
173
- cache_path: unit.cachePath,
174
- provided_components: unit.providedComponents,
175
- components: {
176
- skills: unit.components.skills,
177
- commands: unit.components.commands,
178
- hooks: unit.components.hooks,
179
- mcp_servers: unit.components.mcpServers
180
- },
181
- modified_config_files: unit.modifiedConfigFiles,
182
- auto_update: unit.autoUpdate,
183
- enabled: unit.enabled,
184
- workflow_dependencies: unit.workflowDependencies,
185
- permission_risk: unit.permissionRisk,
171
+ scope: unit.scope,
172
+ manifest_path: unit.manifestPath,
173
+ cache_path: unit.cachePath,
174
+ provided_components: unit.providedComponents,
175
+ components: {
176
+ skills: unit.components.skills,
177
+ commands: unit.components.commands,
178
+ hooks: unit.components.hooks,
179
+ mcp_servers: unit.components.mcpServers
180
+ },
181
+ modified_config_files: unit.modifiedConfigFiles,
182
+ auto_update: unit.autoUpdate,
183
+ enabled: unit.enabled,
184
+ workflow_dependencies: unit.workflowDependencies,
185
+ permission_risk: unit.permissionRisk,
186
186
  rollback: unit.rollback
187
187
  }
188
188
  }
189
189
  });
190
190
  }
191
-
192
- function duplicateMessage(skillIds, unitIds) {
193
- const parts = [];
194
- if (skillIds.length > 0) {
195
- parts.push(`skills already exist: ${skillIds.join(", ")}`);
196
- }
197
- if (unitIds.length > 0) {
198
- parts.push(`install units already exist: ${unitIds.join(", ")}`);
199
- }
200
- return `${parts.join("; ")}. Re-run with --replace to overwrite.`;
201
- }
202
-
203
- async function findSkillFiles(root) {
204
- const files = [];
205
- const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
206
- for (const entry of entries) {
207
- const path = join(root, entry.name);
208
- if (entry.isDirectory()) {
209
- files.push(...(await findSkillFiles(path)));
210
- } else if (entry.isFile() && entry.name === "SKILL.md") {
211
- files.push(path);
212
- }
213
- }
214
- return files;
215
- }
216
-
217
- function parseSkillFrontmatter(text) {
218
- const match = /^---[ \t]*\r?\n(?<body>[\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
219
- if (match?.groups === undefined) {
220
- throw new Error("SKILL.md is missing YAML frontmatter");
221
- }
222
- const raw = requireRecord(YAML.parse(match.groups.body), "SKILL.md frontmatter");
223
- return {
224
- name: readString(raw, "name", ""),
225
- description: readString(raw, "description", "")
226
- };
227
- }
228
-
191
+
192
+ function duplicateMessage(skillIds, unitIds) {
193
+ const parts = [];
194
+ if (skillIds.length > 0) {
195
+ parts.push(`skills already exist: ${skillIds.join(", ")}`);
196
+ }
197
+ if (unitIds.length > 0) {
198
+ parts.push(`install units already exist: ${unitIds.join(", ")}`);
199
+ }
200
+ return `${parts.join("; ")}. Re-run with --replace to overwrite.`;
201
+ }
202
+
203
+ async function findSkillFiles(root) {
204
+ const files = [];
205
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
206
+ for (const entry of entries) {
207
+ const path = join(root, entry.name);
208
+ if (entry.isDirectory()) {
209
+ files.push(...(await findSkillFiles(path)));
210
+ } else if (entry.isFile() && entry.name === "SKILL.md") {
211
+ files.push(path);
212
+ }
213
+ }
214
+ return files;
215
+ }
216
+
217
+ function parseSkillFrontmatter(text) {
218
+ const match = /^---[ \t]*\r?\n(?<body>[\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
219
+ if (match?.groups === undefined) {
220
+ throw new Error("SKILL.md is missing YAML frontmatter");
221
+ }
222
+ const raw = requireRecord(YAML.parse(match.groups.body), "SKILL.md frontmatter");
223
+ return {
224
+ name: readString(raw, "name", ""),
225
+ description: readString(raw, "description", "")
226
+ };
227
+ }
228
+
229
229
  function matchesAnyProfilePattern(file, patterns) {
230
230
  const activePatterns = patterns.length === 0 ? ["**/SKILL.md"] : patterns;
231
231
  return activePatterns.some((pattern) => patternToRegExp(pattern).test(file));
@@ -234,30 +234,31 @@ function matchesAnyProfilePattern(file, patterns) {
234
234
  function matchingPathRule(file, profile) {
235
235
  return (profile.pathRules ?? []).find((rule) => patternToRegExp(rule.pattern).test(file));
236
236
  }
237
-
238
- function patternToRegExp(pattern) {
239
- const escaped = pattern
240
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
237
+
238
+ function patternToRegExp(pattern) {
239
+ const escaped = pattern
240
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
241
241
  .split("**").join("\0")
242
242
  .split("*").join("[^/]*")
243
243
  .split("\0").join(".*");
244
- return new RegExp(`^${escaped}$`);
245
- }
246
-
247
- function skillSlug(relativeFile, frontmatter, profile) {
248
- if (profile.idStrategy === "frontmatter-name" && frontmatter.name.length > 0) {
249
- return normalizeSlug(frontmatter.name.split(".").at(-1));
250
- }
251
- return normalizeSlug(basename(dirname(relativeFile)));
252
- }
253
-
254
- function skillId(slug, frontmatter, profile) {
255
- if (profile.idStrategy === "frontmatter-name" && frontmatter.name.includes(".")) {
256
- return frontmatter.name;
257
- }
258
- return profile.namespace.length === 0 ? slug : `${profile.namespace}.${slug}`;
259
- }
260
-
244
+ return new RegExp(`^${escaped}$`);
245
+ }
246
+
247
+ function skillSlug(relativeFile, frontmatter, profile) {
248
+ if (profile.idStrategy === "frontmatter-name" && frontmatter.name.length > 0) {
249
+ const segments = frontmatter.name.split(".");
250
+ return normalizeSlug(segments[segments.length - 1]);
251
+ }
252
+ return normalizeSlug(basename(dirname(relativeFile)));
253
+ }
254
+
255
+ function skillId(slug, frontmatter, profile) {
256
+ if (profile.idStrategy === "frontmatter-name" && frontmatter.name.includes(".")) {
257
+ return frontmatter.name;
258
+ }
259
+ return profile.namespace.length === 0 ? slug : `${profile.namespace}.${slug}`;
260
+ }
261
+
261
262
  function skillTargetPath(slug, profile) {
262
263
  return profile.targetPathPrefix.length === 0 ? slug : `${profile.targetPathPrefix}/${slug}`;
263
264
  }
@@ -275,13 +276,13 @@ function categoryFromPath(relativeFile, profile) {
275
276
  }
276
277
 
277
278
  function normalizeSlug(value) {
278
- return value
279
- .trim()
280
- .toLowerCase()
281
- .replace(/[^a-z0-9._-]+/g, "-")
282
- .replace(/^-+|-+$/g, "");
283
- }
284
-
279
+ return value
280
+ .trim()
281
+ .toLowerCase()
282
+ .replace(/[^a-z0-9._-]+/g, "-")
283
+ .replace(/^-+|-+$/g, "");
284
+ }
285
+
285
286
  function safeDefaultInvocation(invocation) {
286
287
  return invocation === "global-auto" ? "blocked" : invocation;
287
288
  }
package/src/uninstall.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  // allow: SIZE_OK - uninstall lifecycle split is deferred from the 0.2.7 release gate.
2
2
  import { access, lstat, readFile, readdir, rm, rmdir, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { BRIDGE_END, BRIDGE_START, defaultConfig, hookReadme, profileReadme } from "./lifecycle-content.mjs";
5
5
 
6
6
  export async function uninstallProject(options) {
7
7
  const root = options.root;
8
8
  const dryRun = options.dryRun === true;
9
+ const keepBridge = options.keepBridge === true;
9
10
  const removed = [];
10
11
  const updated = [];
11
12
  const preserved = [];
@@ -13,7 +14,7 @@ export async function uninstallProject(options) {
13
14
 
14
15
  for (const filename of ["AGENTS.md", "CLAUDE.md"]) {
15
16
  const path = join(root, filename);
16
- const result = await removeBridge(path, dryRun);
17
+ const result = keepBridge ? await preserveExisting(path) : await removeBridge(path, dryRun);
17
18
  if (result === "removed") {
18
19
  plannedRemovedPaths.add(path);
19
20
  }
@@ -47,7 +48,7 @@ export async function uninstallProject(options) {
47
48
  recordFileResult(".skillboard", result, { removed, updated, preserved });
48
49
  } else {
49
50
  for (const entry of generatedFiles(root)) {
50
- const result = await removeGeneratedFile(entry.path, entry.expected, dryRun);
51
+ const result = await removeGeneratedFile(entry.path, entry.expected, dryRun, root);
51
52
  if (result === "removed") {
52
53
  plannedRemovedPaths.add(entry.path);
53
54
  }
@@ -57,7 +58,7 @@ export async function uninstallProject(options) {
57
58
 
58
59
  if (options.removeReports === true && options.removeProjectState !== true) {
59
60
  const path = join(root, ".skillboard", "reports");
60
- const result = await removeGeneratedDir(path, dryRun);
61
+ const result = await removeGeneratedDir(path, dryRun, root);
61
62
  if (result === "removed") {
62
63
  plannedRemovedPaths.add(path);
63
64
  }
@@ -66,7 +67,7 @@ export async function uninstallProject(options) {
66
67
 
67
68
  if (options.removeHooks === true && options.removeProjectState !== true) {
68
69
  const path = join(root, ".skillboard", "hooks");
69
- const result = await removeGeneratedDir(path, dryRun);
70
+ const result = await removeGeneratedDir(path, dryRun, root);
70
71
  if (result === "removed") {
71
72
  plannedRemovedPaths.add(path);
72
73
  }
@@ -79,7 +80,7 @@ export async function uninstallProject(options) {
79
80
  skipHooks: options.removeHooks === true,
80
81
  skipSkillboard: options.removeProjectState === true
81
82
  })) {
82
- const result = await removeEmptyDir(dir.path, dryRun, plannedRemovedPaths);
83
+ const result = await removeEmptyDir(dir.path, dryRun, plannedRemovedPaths, root);
83
84
  if (result === "removed") {
84
85
  plannedRemovedPaths.add(dir.path);
85
86
  }
@@ -178,7 +179,10 @@ function withoutBridgeBlock(text) {
178
179
  return `${before}${after}`;
179
180
  }
180
181
 
181
- async function removeGeneratedFile(path, expected, dryRun) {
182
+ async function removeGeneratedFile(path, expected, dryRun, root = null) {
183
+ if (root !== null && await hasUnsafeAncestor(path, root)) {
184
+ return "preserved";
185
+ }
182
186
  const stats = await pathStats(path);
183
187
  if (stats === null) {
184
188
  return "absent";
@@ -210,7 +214,10 @@ async function removeConfigFile(path, dryRun) {
210
214
  return "removed";
211
215
  }
212
216
 
213
- async function removeGeneratedDir(path, dryRun) {
217
+ async function removeGeneratedDir(path, dryRun, root = null) {
218
+ if (root !== null && await hasUnsafeAncestor(path, root)) {
219
+ return "preserved";
220
+ }
214
221
  const stats = await pathStats(path);
215
222
  if (stats === null) {
216
223
  return "absent";
@@ -244,7 +251,10 @@ async function removeProjectStateDir(path, dryRun) {
244
251
  return "removed";
245
252
  }
246
253
 
247
- async function removeEmptyDir(path, dryRun, plannedRemovedPaths) {
254
+ async function removeEmptyDir(path, dryRun, plannedRemovedPaths, root = null) {
255
+ if (root !== null && await hasUnsafeAncestor(path, root)) {
256
+ return "preserved";
257
+ }
248
258
  const stats = await pathStats(path);
249
259
  if (stats === null) {
250
260
  return "absent";
@@ -280,6 +290,43 @@ async function exists(path) {
280
290
  return access(path).then(() => true, () => false);
281
291
  }
282
292
 
293
+ async function preserveExisting(path) {
294
+ return await exists(path) ? "preserved" : "absent";
295
+ }
296
+
297
+ async function hasUnsafeAncestor(path, root) {
298
+ const resolvedRoot = resolve(root);
299
+ const resolvedPath = resolve(path);
300
+ if (resolvedPath !== resolvedRoot && !isInside(resolvedPath, resolvedRoot)) {
301
+ return true;
302
+ }
303
+ const ancestors = [];
304
+ let current = dirname(resolvedPath);
305
+ while (current !== resolvedRoot) {
306
+ ancestors.push(current);
307
+ const parent = dirname(current);
308
+ if (parent === current) {
309
+ break;
310
+ }
311
+ current = parent;
312
+ }
313
+ for (const ancestor of ancestors.reverse()) {
314
+ const stats = await pathStats(ancestor);
315
+ if (stats === null) {
316
+ return false;
317
+ }
318
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
319
+ return true;
320
+ }
321
+ }
322
+ return false;
323
+ }
324
+
325
+ function isInside(path, parent) {
326
+ const relativePath = relative(parent, path);
327
+ return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
328
+ }
329
+
283
330
  async function pathStats(path) {
284
331
  try {
285
332
  return await lstat(path);