agent-skillboard 0.2.18 → 0.3.1

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 (82) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +161 -255
  3. package/bin/postinstall.mjs +2 -1
  4. package/docs/adapters.md +37 -113
  5. package/docs/ai-skill-routing-goal.md +41 -108
  6. package/docs/capabilities.md +17 -104
  7. package/docs/install.md +70 -473
  8. package/docs/policy-model.md +50 -280
  9. package/docs/positioning.md +19 -86
  10. package/docs/profiles.md +21 -153
  11. package/docs/reference.md +133 -362
  12. package/docs/rollout-runbook.md +23 -25
  13. package/docs/routing.md +23 -90
  14. package/docs/user-flow.md +68 -279
  15. package/docs/value-proof.md +23 -181
  16. package/docs/variant-lifecycle.md +47 -67
  17. package/docs/versioning.md +49 -269
  18. package/examples/v2-multi-source.config.yaml +35 -0
  19. package/examples/v2-policy-error.config.yaml +6 -0
  20. package/package.json +1 -1
  21. package/src/advisor/actions.mjs +102 -6
  22. package/src/advisor/application-commands.mjs +10 -9
  23. package/src/advisor/apply-action.mjs +74 -1
  24. package/src/advisor/guidance.mjs +24 -16
  25. package/src/advisor/schema.mjs +17 -6
  26. package/src/advisor/skills.mjs +18 -5
  27. package/src/advisor.mjs +27 -9
  28. package/src/agent-integration-cli.mjs +96 -13
  29. package/src/agent-integration-content.mjs +21 -11
  30. package/src/agent-integration-files.mjs +1 -1
  31. package/src/agent-integration-home.mjs +14 -1
  32. package/src/agent-inventory-platforms.mjs +21 -8
  33. package/src/agent-inventory.mjs +44 -16
  34. package/src/agent-root-registry.mjs +127 -0
  35. package/src/agent-skill-import.mjs +2 -2
  36. package/src/agent-skill-roots.mjs +70 -13
  37. package/src/audit-paths.mjs +42 -0
  38. package/src/brief-cli.mjs +3 -2
  39. package/src/brief-renderer.mjs +1 -0
  40. package/src/cli.mjs +521 -235
  41. package/src/compatibility.mjs +24 -0
  42. package/src/control/can-use-guard.mjs +21 -1
  43. package/src/control/config-write.mjs +32 -2
  44. package/src/control/skill-crud.mjs +5 -0
  45. package/src/control/v2-guard.mjs +175 -0
  46. package/src/control/v2-skill-crud.mjs +32 -0
  47. package/src/control/v2-skill-forget.mjs +38 -0
  48. package/src/control/variant-status.mjs +47 -1
  49. package/src/control.mjs +55 -0
  50. package/src/doctor.mjs +71 -5
  51. package/src/domain/v2-policy.mjs +111 -0
  52. package/src/hook-plan.mjs +33 -3
  53. package/src/impact.mjs +52 -29
  54. package/src/index.mjs +25 -1
  55. package/src/init.mjs +50 -34
  56. package/src/install-health.mjs +177 -0
  57. package/src/inventory-install-units.mjs +63 -0
  58. package/src/inventory-json.mjs +279 -0
  59. package/src/inventory-refresh.mjs +168 -19
  60. package/src/lifecycle-cli.mjs +40 -12
  61. package/src/lifecycle-content.mjs +52 -67
  62. package/src/migration/v1-to-v2.mjs +212 -0
  63. package/src/migration/v2-files.mjs +211 -0
  64. package/src/migration/v2-journal.mjs +169 -0
  65. package/src/migration/v2-projection.mjs +108 -0
  66. package/src/migration/v2-transaction.mjs +205 -0
  67. package/src/policy.mjs +3 -0
  68. package/src/reconcile.mjs +139 -111
  69. package/src/report.mjs +168 -148
  70. package/src/review.mjs +2 -0
  71. package/src/route-advisory.mjs +47 -2
  72. package/src/route-selection.mjs +38 -2
  73. package/src/route.mjs +62 -2
  74. package/src/shared-skill-reconcile.mjs +97 -0
  75. package/src/shared-skill.mjs +325 -0
  76. package/src/source-digest.mjs +42 -0
  77. package/src/source-profiles.mjs +171 -144
  78. package/src/source-verification.mjs +32 -48
  79. package/src/uninstall.mjs +22 -0
  80. package/src/user-state-paths.mjs +19 -0
  81. package/src/user-uninstall.mjs +161 -0
  82. package/src/workspace.mjs +119 -79
@@ -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);
@@ -30,6 +30,7 @@ export async function importSource(options) {
30
30
  skills.push({
31
31
  id,
32
32
  path,
33
+ skillFile: entry.file,
33
34
  status: pathRule?.status ?? profile.defaultStatus,
34
35
  invocation: safeDefaultInvocation(pathRule?.invocation ?? profile.defaultInvocation),
35
36
  exposure: pathRule?.exposure ?? profile.defaultExposure,
@@ -37,17 +38,17 @@ export async function importSource(options) {
37
38
  ownerInstallUnit: profile.id,
38
39
  description: frontmatter.description
39
40
  });
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: {
41
+ }
42
+
43
+ if (skills.length === 0) {
44
+ warnings.push(`No SKILL.md files matched profile ${profile.id} under ${sourceRoot}`);
45
+ }
46
+
47
+ return {
48
+ profile,
49
+ sourceRoot,
50
+ skills,
51
+ installUnit: {
51
52
  id: profile.id,
52
53
  kind: profile.kind,
53
54
  sourceClass: profile.sourceClass,
@@ -58,37 +59,40 @@ export async function importSource(options) {
58
59
  publicKey: profile.publicKey,
59
60
  verifiedAt: profile.verifiedAt,
60
61
  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
-
62
+ scope: profile.scope,
63
+ manifestPath: profile.manifestPath,
64
+ cachePath: profile.cachePath,
65
+ providedComponents: profile.providedComponents,
66
+ components: {
67
+ skills: skills.map((skill) => skill.id),
68
+ commands: profile.components.commands,
69
+ hooks: profile.components.hooks,
70
+ mcpServers: profile.components.mcpServers
71
+ },
72
+ modifiedConfigFiles: profile.modifiedConfigFiles,
73
+ autoUpdate: profile.autoUpdate,
74
+ enabled: profile.enabled,
75
+ workflowDependencies: profile.workflowDependencies,
76
+ permissionRisk: profile.permissionRisk,
77
+ rollback: profile.rollback
78
+ },
79
+ warnings
80
+ };
81
+ }
82
+
83
+ export function renderImportFragment(imported) {
84
+ return YAML.stringify(importFragment(imported));
85
+ }
86
+
86
87
  export function mergeImportFragment(configText, imported, options = {}) {
87
88
  const document = YAML.parseDocument(configText);
88
89
  if (document.errors.length > 0) {
89
90
  throw new Error(`Invalid YAML config: ${document.errors.map((error) => error.message).join("; ")}`);
90
91
  }
91
92
  requireYamlMap(document.contents, "config root");
93
+ if (document.get("version") === 2) {
94
+ return mergeV2ImportFragment(document, configText, imported);
95
+ }
92
96
  const fragment = importFragment(imported);
93
97
  const replace = options.replace === true;
94
98
  const skillIds = Object.keys(fragment.skills);
@@ -117,7 +121,30 @@ export function mergeImportFragment(configText, imported, options = {}) {
117
121
  addedSkills: skillIds,
118
122
  addedInstallUnits: unitIds,
119
123
  replacedSkills: duplicateSkills,
120
- replacedInstallUnits: duplicateUnits
124
+ replacedInstallUnits: duplicateUnits
125
+ };
126
+ }
127
+
128
+ function mergeV2ImportFragment(document, configText, imported) {
129
+ const existingSkills = ensureMap(document, "skills");
130
+ const addedSkills = [];
131
+ for (const skill of imported.skills) {
132
+ if (existingSkills.get(skill.id, true) !== undefined) {
133
+ continue;
134
+ }
135
+ existingSkills.set(skill.id, document.createNode({ enabled: true, shared: false }));
136
+ addedSkills.push(skill.id);
137
+ }
138
+ const text = preserveLineEndings(String(document), configText);
139
+ const plan = textChangePlan(configText, text);
140
+ return {
141
+ text,
142
+ changed: plan.changed,
143
+ plan,
144
+ addedSkills,
145
+ addedInstallUnits: [],
146
+ replacedSkills: [],
147
+ replacedInstallUnits: []
121
148
  };
122
149
  }
123
150
 
@@ -141,20 +168,20 @@ function requireYamlMap(value, label) {
141
168
  function preserveLineEndings(text, reference) {
142
169
  return reference.includes("\r\n") ? text.replace(/\n/g, "\r\n") : text;
143
170
  }
144
-
171
+
145
172
  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;
173
+ const skills = {};
174
+ for (const skill of imported.skills) {
175
+ skills[skill.id] = {
176
+ path: skill.path,
177
+ status: skill.status,
178
+ invocation: skill.invocation,
179
+ exposure: skill.exposure,
180
+ category: skill.category,
181
+ owner_install_unit: skill.ownerInstallUnit
182
+ };
183
+ }
184
+ const unit = imported.installUnit;
158
185
  return stripUndefined({
159
186
  skills,
160
187
  install_units: {
@@ -168,64 +195,64 @@ function importFragment(imported) {
168
195
  public_key: unit.publicKey,
169
196
  verified_at: unit.verifiedAt,
170
197
  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,
198
+ scope: unit.scope,
199
+ manifest_path: unit.manifestPath,
200
+ cache_path: unit.cachePath,
201
+ provided_components: unit.providedComponents,
202
+ components: {
203
+ skills: unit.components.skills,
204
+ commands: unit.components.commands,
205
+ hooks: unit.components.hooks,
206
+ mcp_servers: unit.components.mcpServers
207
+ },
208
+ modified_config_files: unit.modifiedConfigFiles,
209
+ auto_update: unit.autoUpdate,
210
+ enabled: unit.enabled,
211
+ workflow_dependencies: unit.workflowDependencies,
212
+ permission_risk: unit.permissionRisk,
186
213
  rollback: unit.rollback
187
214
  }
188
215
  }
189
216
  });
190
217
  }
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
-
218
+
219
+ function duplicateMessage(skillIds, unitIds) {
220
+ const parts = [];
221
+ if (skillIds.length > 0) {
222
+ parts.push(`skills already exist: ${skillIds.join(", ")}`);
223
+ }
224
+ if (unitIds.length > 0) {
225
+ parts.push(`install units already exist: ${unitIds.join(", ")}`);
226
+ }
227
+ return `${parts.join("; ")}. Re-run with --replace to overwrite.`;
228
+ }
229
+
230
+ async function findSkillFiles(root) {
231
+ const files = [];
232
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
233
+ for (const entry of entries) {
234
+ const path = join(root, entry.name);
235
+ if (entry.isDirectory()) {
236
+ files.push(...(await findSkillFiles(path)));
237
+ } else if (entry.isFile() && entry.name === "SKILL.md") {
238
+ files.push(path);
239
+ }
240
+ }
241
+ return files;
242
+ }
243
+
244
+ function parseSkillFrontmatter(text) {
245
+ const match = /^---[ \t]*\r?\n(?<body>[\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
246
+ if (match?.groups === undefined) {
247
+ throw new Error("SKILL.md is missing YAML frontmatter");
248
+ }
249
+ const raw = requireRecord(YAML.parse(match.groups.body), "SKILL.md frontmatter");
250
+ return {
251
+ name: readString(raw, "name", ""),
252
+ description: readString(raw, "description", "")
253
+ };
254
+ }
255
+
229
256
  function matchesAnyProfilePattern(file, patterns) {
230
257
  const activePatterns = patterns.length === 0 ? ["**/SKILL.md"] : patterns;
231
258
  return activePatterns.some((pattern) => patternToRegExp(pattern).test(file));
@@ -234,16 +261,16 @@ function matchesAnyProfilePattern(file, patterns) {
234
261
  function matchingPathRule(file, profile) {
235
262
  return (profile.pathRules ?? []).find((rule) => patternToRegExp(rule.pattern).test(file));
236
263
  }
237
-
238
- function patternToRegExp(pattern) {
239
- const escaped = pattern
240
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
264
+
265
+ function patternToRegExp(pattern) {
266
+ const escaped = pattern
267
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
241
268
  .split("**").join("\0")
242
269
  .split("*").join("[^/]*")
243
270
  .split("\0").join(".*");
244
- return new RegExp(`^${escaped}$`);
245
- }
246
-
271
+ return new RegExp(`^${escaped}$`);
272
+ }
273
+
247
274
  function skillSlug(relativeFile, frontmatter, profile) {
248
275
  if (profile.idStrategy === "frontmatter-name" && frontmatter.name.length > 0) {
249
276
  const segments = frontmatter.name.split(".");
@@ -251,14 +278,14 @@ function skillSlug(relativeFile, frontmatter, profile) {
251
278
  }
252
279
  return normalizeSlug(basename(dirname(relativeFile)));
253
280
  }
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
-
281
+
282
+ function skillId(slug, frontmatter, profile) {
283
+ if (profile.idStrategy === "frontmatter-name" && frontmatter.name.includes(".")) {
284
+ return frontmatter.name;
285
+ }
286
+ return profile.namespace.length === 0 ? slug : `${profile.namespace}.${slug}`;
287
+ }
288
+
262
289
  function skillTargetPath(slug, profile) {
263
290
  return profile.targetPathPrefix.length === 0 ? slug : `${profile.targetPathPrefix}/${slug}`;
264
291
  }
@@ -276,13 +303,13 @@ function categoryFromPath(relativeFile, profile) {
276
303
  }
277
304
 
278
305
  function normalizeSlug(value) {
279
- return value
280
- .trim()
281
- .toLowerCase()
282
- .replace(/[^a-z0-9._-]+/g, "-")
283
- .replace(/^-+|-+$/g, "");
284
- }
285
-
306
+ return value
307
+ .trim()
308
+ .toLowerCase()
309
+ .replace(/[^a-z0-9._-]+/g, "-")
310
+ .replace(/^-+|-+$/g, "");
311
+ }
312
+
286
313
  function safeDefaultInvocation(invocation) {
287
314
  return invocation === "global-auto" ? "blocked" : invocation;
288
315
  }
@@ -1,11 +1,14 @@
1
- import { lstat, readFile, readdir, readlink, writeFile } from "node:fs/promises";
2
- import { createHash, verify as verifySignature } from "node:crypto";
1
+ import { lstat, readFile, writeFile } from "node:fs/promises";
2
+ import { verify as verifySignature } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
5
  import YAML from "yaml";
6
+ import { auditPath, expandPortablePath, portableObservation, redactPathError } from "./audit-paths.mjs";
6
7
  import { installUnitSourceClass, isLocalSourceReference } from "./domain/source-classes.mjs";
8
+ import { V1_MUTATION_ERROR } from "./compatibility.mjs";
9
+ import { skillContentDigest, sourceDigest } from "./source-digest.mjs";
7
10
 
8
- const DIGEST_PREFIX = "sha256:";
11
+ export { skillContentDigest, sourceDigest };
9
12
 
10
13
  export async function verifySources(workspace, options = {}) {
11
14
  const configDir = options.configPath === undefined ? process.cwd() : dirname(resolve(options.configPath));
@@ -24,6 +27,7 @@ export async function verifySources(workspace, options = {}) {
24
27
  }
25
28
 
26
29
  export async function writeLockfile(workspace, options) {
30
+ if (workspace.version === 1) throw new Error(V1_MUTATION_ERROR);
27
31
  const verified = await verifySources(workspace, { configPath: options.configPath, rootDir: options.rootDir });
28
32
  if (!verified.ok && options.allowUnverified !== true) {
29
33
  throw new Error(`Cannot write lockfile because source verification failed:\n${verified.errors.join("\n")}`);
@@ -34,6 +38,7 @@ export async function writeLockfile(workspace, options) {
34
38
  }
35
39
 
36
40
  export async function renderLockfile(workspace, options = {}) {
41
+ if (workspace.version === 2) return renderV2Lockfile(workspace, options);
37
42
  const verified = options.verified ?? await verifySources(workspace, { configPath: options.configPath, rootDir: options.rootDir });
38
43
  const generatedAt = options.generatedAt ?? new Date().toISOString();
39
44
  const skillDigests = await skillContentDigests(workspace, options.skillsRoot);
@@ -76,6 +81,20 @@ export async function renderLockfile(workspace, options = {}) {
76
81
  });
77
82
  }
78
83
 
84
+ async function renderV2Lockfile(workspace, options) {
85
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
86
+ return YAML.stringify({
87
+ version: 2,
88
+ policy_projection_version: 2,
89
+ generated_at: generatedAt,
90
+ audit_authoritative_for_availability: false,
91
+ skills: Object.fromEntries(workspace.skills.map((skill) => [skill.id, {
92
+ enabled: skill.enabled,
93
+ shared: skill.shared
94
+ }]))
95
+ });
96
+ }
97
+
79
98
  async function verifyInstallUnit(unit, options) {
80
99
  const target = await verificationTarget(unit, options);
81
100
  const localPath = target.path;
@@ -87,7 +106,7 @@ async function verifyInstallUnit(unit, options) {
87
106
 
88
107
  if (localPath !== null && options.restrictToRoot === true && !isPathInside(options.rootDir, localPath)) {
89
108
  status = "unverified";
90
- findings.push({ severity: "error", message: `${target.field} is outside the allowed root: ${localPath}` });
109
+ findings.push({ severity: "error", message: `${target.field} is outside the allowed root: ${auditPath(localPath, options)}` });
91
110
  } else if (localPath === null) {
92
111
  if (unit.sourceDigest === undefined) {
93
112
  findings.push({ severity: "warning", message: "remote or command source has no source_digest pin" });
@@ -117,16 +136,16 @@ async function verifyInstallUnit(unit, options) {
117
136
  }
118
137
  } catch (error) {
119
138
  status = "unverified";
120
- findings.push({ severity: "error", message: `cannot verify local source: ${error instanceof Error ? error.message : String(error)}` });
139
+ findings.push({ severity: "error", message: `cannot verify local source: ${redactPathError(error, options)}` });
121
140
  }
122
141
  }
123
142
 
124
143
  return {
125
144
  id: unit.id,
126
145
  kind: unit.kind,
127
- source: unit.source,
128
- cachePath: unit.cachePath.length === 0 ? null : unit.cachePath,
129
- verifiedPath: localPath,
146
+ source: portableObservation(unit.source, options),
147
+ cachePath: unit.cachePath.length === 0 ? null : portableObservation(unit.cachePath, options),
148
+ verifiedPath: localPath === null ? null : auditPath(localPath, options),
130
149
  verifiedField: target.field,
131
150
  sourceClass: installUnitSourceClass(unit),
132
151
  trustLevel: unit.trustLevel,
@@ -156,39 +175,6 @@ async function verificationTarget(unit, options) {
156
175
  return { path: await localSourcePath(unit.source, options, { allowBareRelative: false }), field: "source" };
157
176
  }
158
177
 
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).replace(/\\/g, "/") || ".";
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
178
  function verifyDigestSignature(digest, signature, publicKey) {
193
179
  try {
194
180
  return verifySignature("sha256", Buffer.from(digest), publicKey, Buffer.from(signature, "base64"));
@@ -198,7 +184,10 @@ function verifyDigestSignature(digest, signature, publicKey) {
198
184
  }
199
185
 
200
186
  async function localSourcePath(source, paths, options) {
201
- const value = source.trim();
187
+ const value = expandPortablePath(source.trim(), paths);
188
+ if (value === null) {
189
+ return null;
190
+ }
202
191
  if (!isLocalSourceReference(value, options)) {
203
192
  return null;
204
193
  }
@@ -215,6 +204,7 @@ async function localSourcePath(source, paths, options) {
215
204
  return await firstExistingPath(candidates) ?? candidates[0] ?? null;
216
205
  }
217
206
 
207
+
218
208
  async function firstExistingPath(paths) {
219
209
  for (const path of paths) {
220
210
  try {
@@ -233,12 +223,6 @@ function uniquePaths(paths) {
233
223
  return [...new Set(paths)];
234
224
  }
235
225
 
236
- export async function skillContentDigest(skillFilePath) {
237
- const hash = createHash("sha256");
238
- hash.update(await readFile(skillFilePath));
239
- return `${DIGEST_PREFIX}${hash.digest("hex")}`;
240
- }
241
-
242
226
  async function skillContentDigests(workspace, skillsRoot) {
243
227
  const digests = new Map();
244
228
  if (skillsRoot === undefined) {
package/src/uninstall.mjs CHANGED
@@ -54,6 +54,12 @@ export async function uninstallProject(options) {
54
54
  }
55
55
  recordFileResult(entry.label, result, { removed, updated, preserved });
56
56
  }
57
+ if (!keepBridge) {
58
+ const inventoryPath = join(root, ".skillboard", "inventory.json");
59
+ const inventoryResult = await removeGeneratedInventory(inventoryPath, dryRun, root);
60
+ if (inventoryResult === "removed") plannedRemovedPaths.add(inventoryPath);
61
+ recordFileResult(".skillboard/inventory.json", inventoryResult, { removed, updated, preserved });
62
+ }
57
63
  }
58
64
 
59
65
  if (options.removeReports === true && options.removeProjectState !== true) {
@@ -200,6 +206,22 @@ async function removeGeneratedFile(path, expected, dryRun, root = null) {
200
206
  return "removed";
201
207
  }
202
208
 
209
+ async function removeGeneratedInventory(path, dryRun, root) {
210
+ if (await hasUnsafeAncestor(path, root)) return "preserved";
211
+ const stats = await pathStats(path);
212
+ if (stats === null) return "absent";
213
+ if (stats.isSymbolicLink() || !stats.isFile()) return "preserved";
214
+ let inventory;
215
+ try {
216
+ inventory = JSON.parse(await readFile(path, "utf8"));
217
+ } catch {
218
+ return "preserved";
219
+ }
220
+ if (inventory?.generated !== true || inventory?.authoritative_for_availability !== false) return "preserved";
221
+ if (!dryRun) await rm(path);
222
+ return "removed";
223
+ }
224
+
203
225
  async function removeConfigFile(path, dryRun) {
204
226
  const stats = await pathStats(path);
205
227
  if (stats === null) {
@@ -0,0 +1,19 @@
1
+ import { homedir } from "node:os";
2
+ import { dirname, isAbsolute, join, resolve } from "node:path";
3
+
4
+ export function resolveUserStatePaths(options = {}) {
5
+ const home = resolve(options.home ?? options.env?.HOME ?? options.env?.USERPROFILE ?? homedir());
6
+ const configPath = options.configPath === undefined
7
+ ? join(home, "skillboard.config.yaml")
8
+ : resolveFrom(options.configPath, options.cwd ?? process.cwd());
9
+ const inventoryPath = options.inventoryPath === undefined
10
+ ? options.configPath === undefined
11
+ ? join(home, ".skillboard", "inventory.json")
12
+ : join(dirname(configPath), ".skillboard", "inventory.json")
13
+ : resolveFrom(options.inventoryPath, options.cwd ?? process.cwd());
14
+ return { home, configPath, inventoryPath, stateRoot: dirname(inventoryPath) };
15
+ }
16
+
17
+ function resolveFrom(path, cwd) {
18
+ return isAbsolute(path) ? resolve(path) : resolve(cwd, path);
19
+ }