agent-skillboard 0.2.17 → 0.3.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 (77) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +128 -260
  3. package/bin/postinstall.mjs +2 -2
  4. package/docs/adapters.md +37 -113
  5. package/docs/ai-skill-routing-goal.md +35 -109
  6. package/docs/capabilities.md +17 -104
  7. package/docs/install.md +39 -493
  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 +117 -356
  12. package/docs/rollout-runbook.md +23 -25
  13. package/docs/routing.md +23 -90
  14. package/docs/user-flow.md +60 -292
  15. package/docs/value-proof.md +23 -181
  16. package/docs/variant-lifecycle.md +47 -67
  17. package/docs/versioning.md +31 -264
  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 +13 -4
  29. package/src/agent-integration-content.mjs +22 -12
  30. package/src/agent-integration-files.mjs +1 -1
  31. package/src/agent-inventory-platforms.mjs +10 -0
  32. package/src/agent-inventory.mjs +23 -1
  33. package/src/agent-skill-import.mjs +2 -2
  34. package/src/audit-paths.mjs +42 -0
  35. package/src/brief-cli.mjs +3 -2
  36. package/src/brief-renderer.mjs +1 -0
  37. package/src/cli.mjs +398 -127
  38. package/src/compatibility.mjs +24 -0
  39. package/src/control/can-use-guard.mjs +21 -1
  40. package/src/control/config-write.mjs +32 -2
  41. package/src/control/skill-crud.mjs +5 -0
  42. package/src/control/v2-guard.mjs +175 -0
  43. package/src/control/v2-skill-crud.mjs +32 -0
  44. package/src/control/v2-skill-forget.mjs +38 -0
  45. package/src/control/variant-status.mjs +47 -1
  46. package/src/control.mjs +55 -0
  47. package/src/doctor.mjs +65 -6
  48. package/src/domain/v2-policy.mjs +111 -0
  49. package/src/hook-plan.mjs +33 -3
  50. package/src/impact.mjs +52 -29
  51. package/src/index.mjs +25 -1
  52. package/src/init.mjs +50 -34
  53. package/src/inventory-install-units.mjs +63 -0
  54. package/src/inventory-json.mjs +279 -0
  55. package/src/inventory-refresh.mjs +163 -18
  56. package/src/lifecycle-cli.mjs +40 -12
  57. package/src/lifecycle-content.mjs +52 -67
  58. package/src/migration/v1-to-v2.mjs +212 -0
  59. package/src/migration/v2-files.mjs +211 -0
  60. package/src/migration/v2-journal.mjs +169 -0
  61. package/src/migration/v2-projection.mjs +108 -0
  62. package/src/migration/v2-transaction.mjs +205 -0
  63. package/src/policy.mjs +3 -0
  64. package/src/reconcile.mjs +139 -111
  65. package/src/report.mjs +168 -148
  66. package/src/review.mjs +2 -0
  67. package/src/route-advisory.mjs +47 -2
  68. package/src/route-selection.mjs +38 -2
  69. package/src/route.mjs +62 -2
  70. package/src/shared-skill.mjs +301 -0
  71. package/src/source-digest.mjs +42 -0
  72. package/src/source-profiles.mjs +27 -0
  73. package/src/source-verification.mjs +32 -48
  74. package/src/uninstall.mjs +22 -0
  75. package/src/user-state-paths.mjs +19 -0
  76. package/src/user-uninstall.mjs +146 -0
  77. package/src/workspace.mjs +41 -1
@@ -0,0 +1,63 @@
1
+ const TRUST_RANK = new Map([["blocked", 0], ["unreviewed", 1], ["reviewed", 2], ["trusted", 3]]);
2
+ const RISK_RANK = new Map([["high", 0], ["medium", 1], ["unknown", 2], ["low", 3]]);
3
+
4
+ export function coalesceInventoryInstallUnits(records) {
5
+ const groups = new Map();
6
+ for (const record of records) {
7
+ const group = groups.get(record.id) ?? [];
8
+ group.push(record);
9
+ groups.set(record.id, group);
10
+ }
11
+ return [...groups.values()].map(coalesceGroup).sort(byId);
12
+ }
13
+
14
+ function coalesceGroup(records) {
15
+ const sorted = [...records].sort((left, right) => stableKey(left).localeCompare(stableKey(right)));
16
+ const first = sorted[0];
17
+ const runtime = sorted.map((record) => record.runtime_components ?? {});
18
+ return compact({
19
+ ...first,
20
+ trust_observation: rankedValue(sorted.map((record) => record.trust_observation), TRUST_RANK),
21
+ permission_risk: rankedValue(sorted.map((record) => record.permission_risk), RISK_RANK),
22
+ signature_observed: sorted.some((record) => record.signature_observed === true),
23
+ runtime_components: {
24
+ commands: combined(runtime.map((value) => value.commands)),
25
+ hooks: combined(runtime.map((value) => value.hooks)),
26
+ mcp_servers: combined(runtime.map((value) => value.mcp_servers))
27
+ },
28
+ skills: combined(sorted.map((record) => record.skills)),
29
+ alias_skills: combined(sorted.map((record) => record.alias_skills)),
30
+ source_observations: observed(sorted, "source"),
31
+ manifest_path_observations: observed(sorted, "manifest_path"),
32
+ cache_path_observations: observed(sorted, "cache_path"),
33
+ source_digest_observations: observed(sorted, "source_digest")
34
+ });
35
+ }
36
+
37
+ function rankedValue(values, ranks) {
38
+ return values
39
+ .filter((value) => typeof value === "string" && value.length > 0)
40
+ .sort((left, right) => (ranks.get(left) ?? Number.MAX_SAFE_INTEGER) - (ranks.get(right) ?? Number.MAX_SAFE_INTEGER)
41
+ || left.localeCompare(right))[0];
42
+ }
43
+
44
+ function observed(records, key) {
45
+ const values = combined(records.map((record) => [record[key]]));
46
+ return values.length > 1 ? values : undefined;
47
+ }
48
+
49
+ function combined(groups) {
50
+ return [...new Set(groups.flat().filter((value) => typeof value === "string" && value.length > 0))].sort();
51
+ }
52
+
53
+ function compact(value) {
54
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
55
+ }
56
+
57
+ function stableKey(value) {
58
+ return JSON.stringify(value, Object.keys(value).sort());
59
+ }
60
+
61
+ function byId(left, right) {
62
+ return left.id.localeCompare(right.id);
63
+ }
@@ -0,0 +1,279 @@
1
+ import { basename, isAbsolute, relative, resolve } from "node:path";
2
+ import YAML from "yaml";
3
+ import { coalesceInventoryInstallUnits } from "./inventory-install-units.mjs";
4
+ import { normalizeSkillPath } from "./skill-paths.mjs";
5
+ import { skillContentDigest } from "./source-verification.mjs";
6
+
7
+ const FORMAT_VERSION = 1;
8
+
9
+ export async function buildGeneratedInventory(inventory, options = {}) {
10
+ const redactions = redactionContext(options);
11
+ const integrityErrors = [];
12
+ const skills = [];
13
+ for (const skill of [...(inventory.skills ?? [])].sort(byId)) {
14
+ try {
15
+ skills.push(await inventorySkillRecord(skill, redactions));
16
+ } catch (error) {
17
+ integrityErrors.push(`${skill?.id || "<missing-id>"}: ${errorMessage(error)}`);
18
+ }
19
+ }
20
+ if (integrityErrors.length > 0) {
21
+ throw new Error(`Inventory integrity error:\n${integrityErrors.join("\n")}`);
22
+ }
23
+ const installUnits = coalesceInventoryInstallUnits([...(inventory.installUnits ?? [])]
24
+ .sort(byId)
25
+ .map((unit) => inventoryInstallUnitRecord(unit, redactions)));
26
+ return {
27
+ format_version: FORMAT_VERSION,
28
+ generated: true,
29
+ authoritative_for_availability: false,
30
+ skills,
31
+ install_units: installUnits,
32
+ redactions: {
33
+ path_count: redactions.pathCount,
34
+ warnings: [...redactions.warnings].sort()
35
+ }
36
+ };
37
+ }
38
+
39
+ export function renderGeneratedInventory(value) {
40
+ return `${JSON.stringify(value, null, 2)}\n`;
41
+ }
42
+
43
+ export function mergeGeneratedInventory(existingText, added, options = {}) {
44
+ const existing = parseExistingInventory(existingText);
45
+ const replace = options.replace === true;
46
+ const skills = mergeRecords(existing.skills, added.skills ?? [], replace);
47
+ const installUnits = mergeRecords(existing.install_units ?? [], added.install_units ?? [], replace);
48
+ const warnings = uniqueStrings([
49
+ ...(existing.redactions?.warnings ?? []),
50
+ ...(added.redactions?.warnings ?? [])
51
+ ]);
52
+ return renderGeneratedInventory({
53
+ ...existing,
54
+ format_version: FORMAT_VERSION,
55
+ generated: true,
56
+ authoritative_for_availability: false,
57
+ skills,
58
+ install_units: installUnits,
59
+ redactions: {
60
+ path_count: countPortablePaths(skills, installUnits),
61
+ warnings
62
+ }
63
+ });
64
+ }
65
+
66
+ export function mergeV2InventoryPolicy(configText, inventory) {
67
+ const document = YAML.parseDocument(configText);
68
+ if (document.errors.length > 0) {
69
+ throw new Error(`Invalid YAML config: ${document.errors.map((error) => error.message).join("; ")}`);
70
+ }
71
+ if (document.get("version") !== 2) {
72
+ throw new Error("v2 inventory policy projection requires config version 2");
73
+ }
74
+ let skills = document.get("skills", true);
75
+ if (skills === undefined) {
76
+ skills = document.createNode({});
77
+ document.set("skills", skills);
78
+ }
79
+ if (!YAML.isMap(skills)) {
80
+ throw new Error("skills must be a mapping");
81
+ }
82
+ const addedSkills = [];
83
+ for (const skill of [...(inventory.skills ?? [])].sort(byId)) {
84
+ validateSkillIdentity(skill);
85
+ if (skills.get(skill.id, true) !== undefined) {
86
+ continue;
87
+ }
88
+ skills.set(skill.id, document.createNode({ enabled: true, shared: false }));
89
+ addedSkills.push(skill.id);
90
+ }
91
+ const parsed = document.toJS();
92
+ const policyProjection = Object.fromEntries(
93
+ Object.entries(parsed.skills ?? {}).map(([id, policy]) => [id, policy])
94
+ );
95
+ const text = preserveLineEndings(String(document), configText);
96
+ return { text, changed: text !== configText, addedSkills, policyProjection };
97
+ }
98
+
99
+ async function inventorySkillRecord(skill, redactions) {
100
+ validateSkillIdentity(skill);
101
+ const aliases = uniqueAliases(skill.sourceAliases ?? []);
102
+ let contentDigest = null;
103
+ if (typeof skill.skillFile === "string" && skill.skillFile.length > 0) {
104
+ if (!isAbsolute(skill.skillFile)) {
105
+ throw new Error("skill file must be absolute");
106
+ }
107
+ contentDigest = await skillContentDigest(skill.skillFile);
108
+ }
109
+ return compact({
110
+ id: skill.id,
111
+ path: normalizeSkillPath(skill.path, `inventory skill ${skill.id} path`),
112
+ owner_install_unit: nonEmptyString(skill.ownerInstallUnit, `inventory skill ${skill.id} owner install unit`),
113
+ source: portableSource(skill.source, redactions),
114
+ category: skill.category,
115
+ description: skill.description,
116
+ content_digest: contentDigest,
117
+ installed_on: installedAgents(skill),
118
+ aliases
119
+ });
120
+ }
121
+
122
+ function installedAgents(skill) {
123
+ return uniqueStrings([
124
+ agentForInstallUnit(skill.ownerInstallUnit),
125
+ ...(skill.sourceAliases ?? []).map((alias) => agentForInstallUnit(alias.ownerInstallUnit))
126
+ ]);
127
+ }
128
+
129
+ function agentForInstallUnit(unitId) {
130
+ if (typeof unitId !== "string") return "";
131
+ for (const agent of ["codex", "claude", "opencode", "hermes"]) {
132
+ if (unitId === agent || unitId.startsWith(`${agent}.`)) return agent;
133
+ }
134
+ return "";
135
+ }
136
+
137
+ function inventoryInstallUnitRecord(unit, redactions) {
138
+ return compact({
139
+ id: nonEmptyString(unit.id, "inventory install unit id"),
140
+ kind: unit.kind,
141
+ source: portableSource(unit.source, redactions),
142
+ source_class: unit.sourceClass,
143
+ manifest_path: portablePath(unit.manifestPath, redactions),
144
+ cache_path: portablePath(unit.cachePath, redactions),
145
+ trust_observation: unit.trustLevel,
146
+ permission_risk: unit.permissionRisk,
147
+ source_digest: unit.sourceDigest,
148
+ signature_observed: typeof unit.signature === "string" && unit.signature.length > 0,
149
+ runtime_components: {
150
+ commands: uniqueStrings(unit.commands ?? unit.components?.commands ?? []),
151
+ hooks: uniqueStrings(unit.hooks ?? unit.components?.hooks ?? []),
152
+ mcp_servers: uniqueStrings(unit.mcpServers ?? unit.components?.mcpServers ?? [])
153
+ },
154
+ skills: uniqueStrings(unit.skills ?? unit.components?.skills ?? []),
155
+ alias_skills: uniqueStrings(unit.sourceAliasSkills ?? [])
156
+ });
157
+ }
158
+
159
+ function validateSkillIdentity(skill) {
160
+ if (skill === null || typeof skill !== "object") {
161
+ throw new Error("inventory skill must be an object");
162
+ }
163
+ nonEmptyString(skill.id, "inventory skill id");
164
+ normalizeSkillPath(skill.path, `inventory skill ${skill.id} path`);
165
+ nonEmptyString(skill.ownerInstallUnit, `inventory skill ${skill.id} owner install unit`);
166
+ }
167
+
168
+ function uniqueAliases(aliases) {
169
+ const seen = new Set();
170
+ const result = [];
171
+ for (const alias of aliases) {
172
+ const owner = nonEmptyString(alias.ownerInstallUnit, "inventory alias owner install unit");
173
+ const path = normalizeSkillPath(alias.path, "inventory alias path");
174
+ const key = `${owner}\0${path}`;
175
+ if (!seen.has(key)) {
176
+ seen.add(key);
177
+ result.push({ owner_install_unit: owner, path });
178
+ }
179
+ }
180
+ return result.sort((left, right) => `${left.owner_install_unit}\0${left.path}`.localeCompare(`${right.owner_install_unit}\0${right.path}`));
181
+ }
182
+
183
+ function uniqueStrings(values) {
184
+ return [...new Set(values.filter((value) => typeof value === "string" && value.length > 0))].sort();
185
+ }
186
+
187
+ function mergeRecords(existing, added, replace) {
188
+ const records = new Map(existing.map((record) => [record.id, record]));
189
+ for (const record of added) {
190
+ if (replace || !records.has(record.id)) records.set(record.id, record);
191
+ }
192
+ return [...records.values()].sort(byId);
193
+ }
194
+
195
+ function parseExistingInventory(text) {
196
+ if (text === null || text === undefined || text.trim() === "") {
197
+ return { skills: [], install_units: [] };
198
+ }
199
+ const value = JSON.parse(text);
200
+ if (value?.format_version !== FORMAT_VERSION || value.generated !== true || value.authoritative_for_availability !== false) {
201
+ throw new Error("Existing generated inventory has an unsupported format");
202
+ }
203
+ if (!Array.isArray(value.skills) || (value.install_units !== undefined && !Array.isArray(value.install_units))) {
204
+ throw new Error("Existing generated inventory records must be arrays");
205
+ }
206
+ return value;
207
+ }
208
+
209
+ function countPortablePaths(skills, installUnits) {
210
+ return [
211
+ ...skills.map((skill) => skill.source),
212
+ ...installUnits.flatMap((unit) => [
213
+ ...(unit.source_observations ?? [unit.source]),
214
+ ...(unit.manifest_path_observations ?? [unit.manifest_path]),
215
+ ...(unit.cache_path_observations ?? [unit.cache_path])
216
+ ])
217
+ ].filter((value) => typeof value === "string" && /^(?:\$\{PROJECT\}|\$\{HOME\}|<external>)(?:\/|$)/u.test(value)).length;
218
+ }
219
+
220
+ function redactionContext(options) {
221
+ return {
222
+ root: options.root === undefined ? undefined : resolve(options.root),
223
+ home: options.home === undefined ? undefined : resolve(options.home),
224
+ pathCount: 0,
225
+ warnings: new Set()
226
+ };
227
+ }
228
+
229
+ function portableSource(value, context) {
230
+ return typeof value === "string" && (isAbsolute(value) || value.startsWith("~/"))
231
+ ? portablePath(value, context)
232
+ : value;
233
+ }
234
+
235
+ function portablePath(value, context) {
236
+ if (typeof value !== "string" || value.length === 0) return value;
237
+ if (value.startsWith("~/")) {
238
+ context.pathCount += 1;
239
+ return `\${HOME}/${value.slice(2)}`;
240
+ }
241
+ if (!isAbsolute(value)) return value;
242
+ context.pathCount += 1;
243
+ const projectPath = tokenizedPath(value, context.root, "PROJECT");
244
+ if (projectPath !== null) return projectPath;
245
+ const homePath = tokenizedPath(value, context.home, "HOME");
246
+ if (homePath !== null) return homePath;
247
+ context.warnings.add("Generated inventory redacted one or more external absolute paths.");
248
+ return `<external>/${basename(value)}`;
249
+ }
250
+
251
+ function tokenizedPath(path, base, token) {
252
+ if (base === undefined) return null;
253
+ const rel = relative(base, resolve(path));
254
+ if (rel.startsWith("..") || isAbsolute(rel)) return null;
255
+ return rel === "" ? `\${${token}}` : `\${${token}}/${rel.replace(/\\/g, "/")}`;
256
+ }
257
+
258
+ function compact(value) {
259
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
260
+ }
261
+
262
+ function nonEmptyString(value, label) {
263
+ if (typeof value !== "string" || value.trim() === "") {
264
+ throw new Error(`${label} must be a non-empty string`);
265
+ }
266
+ return value;
267
+ }
268
+
269
+ function byId(left, right) {
270
+ return String(left?.id ?? "").localeCompare(String(right?.id ?? ""));
271
+ }
272
+
273
+ function preserveLineEndings(text, original) {
274
+ return original.includes("\r\n") ? text.replace(/(?<!\r)\n/g, "\r\n") : text;
275
+ }
276
+
277
+ function errorMessage(error) {
278
+ return error instanceof Error ? error.message : String(error);
279
+ }
@@ -1,45 +1,190 @@
1
- import { readFile, writeFile } from "node:fs/promises";
2
- import { isAbsolute, resolve } from "node:path";
1
+ import { chmod, lstat, mkdir, open, readFile, realpath, rm } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import YAML from "yaml";
3
4
  import { discoverAgentSkillInventory, mergeAgentSkillInventory } from "./agent-inventory.mjs";
4
5
  import { textChangePlan } from "./change-plan.mjs";
6
+ import { buildGeneratedInventory, mergeV2InventoryPolicy, renderGeneratedInventory } from "./inventory-json.mjs";
7
+ import { atomicWrite, optionalRead } from "./migration/v2-files.mjs";
8
+ import { loadWorkspace } from "./workspace.mjs";
9
+
10
+ const MINIMAL_V2_POLICY = "version: 2\nskills: {}\n";
5
11
 
6
12
  export async function refreshAgentInventory(options = {}) {
7
13
  const root = resolve(options.root ?? ".");
8
- const configPath = resolveUnderRoot(root, options.configPath ?? "skillboard.config.yaml");
9
- const inventory = await discoverAgentSkillInventory({
14
+ const configPath = resolveUnderRoot(root, options.configPath ?? "skillboard.config.yaml", "Inventory refresh config path");
15
+ return withRefreshLock(root, async () => refreshLocked({ ...options, root, configPath }));
16
+ }
17
+
18
+ async function refreshLocked(options) {
19
+ const { root, configPath } = options;
20
+ const configStats = await lstat(configPath).catch(missingOnly);
21
+ if (configStats?.isSymbolicLink()) {
22
+ throw new Error("Inventory refresh config path must not be a symbolic link.");
23
+ }
24
+ const bootstrappedV2 = configStats === undefined;
25
+ const current = bootstrappedV2 ? MINIMAL_V2_POLICY : await readFile(configPath, "utf8");
26
+ if (!bootstrappedV2) {
27
+ await loadWorkspace({ configPath });
28
+ }
29
+ const inventory = options.inventory ?? await discoverAgentSkillInventory({
10
30
  roots: options.roots,
11
31
  home: options.home,
12
32
  env: options.env
13
33
  });
14
- const current = await readFile(configPath, "utf8");
15
- const merged = mergeAgentSkillInventory(current, inventory);
34
+ const configVersion = YAML.parse(current)?.version;
35
+ const generatedInventory = configVersion === 2
36
+ ? await buildGeneratedInventory(inventory, { root, home: options.home ?? options.env?.HOME })
37
+ : null;
38
+ const inventoryPath = resolveUnderRoot(root, options.inventoryPath ?? ".skillboard/inventory.json", "Generated inventory target");
39
+ const inventoryText = generatedInventory === null ? null : renderGeneratedInventory(generatedInventory);
40
+ if (inventoryText !== null) {
41
+ await assertGeneratedInventoryTarget(root, inventoryPath);
42
+ }
43
+ const previousInventoryText = inventoryText === null
44
+ ? null
45
+ : await readFile(inventoryPath, "utf8").catch((error) => error?.code === "ENOENT" ? "" : Promise.reject(error));
46
+ const merged = configVersion === 2
47
+ ? mergeV2InventoryPolicy(current, inventory)
48
+ : mergeAgentSkillInventory(current, inventory);
16
49
  const plan = textChangePlan(current, merged.text);
17
50
  const dryRun = options.dryRun === true;
18
51
 
19
- if (plan.changed && !dryRun) {
20
- await writeFile(configPath, merged.text, "utf8");
52
+ const inventoryChanged = inventoryText !== null && inventoryText !== previousInventoryText;
53
+ if (!dryRun) {
54
+ const [previousConfig, previousInventory] = await Promise.all([
55
+ optionalRead(configPath),
56
+ inventoryText === null ? Promise.resolve(null) : optionalRead(inventoryPath)
57
+ ]);
58
+ try {
59
+ if (inventoryChanged) {
60
+ await mkdir(dirname(inventoryPath), { recursive: true });
61
+ const writeInventory = options.writeInventory ?? writeInventoryFile;
62
+ await writeInventory(inventoryPath, inventoryText);
63
+ }
64
+ if (bootstrappedV2) {
65
+ const writeConfig = options.writeConfig ?? atomicWrite;
66
+ await writeConfig(configPath, Buffer.from(merged.text));
67
+ } else if (plan.changed) {
68
+ const writeConfig = options.writeConfig ?? atomicWrite;
69
+ await writeConfig(configPath, Buffer.from(merged.text));
70
+ }
71
+ if (inventoryText !== null) await chmod(inventoryPath, 0o600);
72
+ } catch (error) {
73
+ await restoreFile(configPath, previousConfig);
74
+ if (inventoryText !== null) await restoreFile(inventoryPath, previousInventory);
75
+ throw error;
76
+ }
21
77
  }
22
78
 
23
79
  return {
24
80
  dryRun,
25
- configPath,
81
+ bootstrappedV2,
82
+ configPath: relativeArtifactPath(root, configPath),
83
+ inventoryPath: generatedInventory === null ? null : relativeArtifactPath(root, inventoryPath),
84
+ inventoryChanged,
26
85
  changed: plan.changed,
27
86
  plan,
28
87
  scan: {
29
88
  scannedSkills: inventory.scannedSkills ?? inventory.skills.length,
30
89
  scannedInstallUnits: inventory.installUnits.length,
31
90
  addedSkills: merged.addedSkills,
32
- addedInstallUnits: merged.addedInstallUnits,
33
- updatedInstallUnits: merged.updatedInstallUnits,
34
- addedWorkflows: merged.addedWorkflows,
35
- addedHarnesses: merged.addedHarnesses,
36
- skippedSkills: merged.skippedSkills,
37
- reviewNotes: merged.reviewNotes,
38
- warnings: inventory.warnings ?? []
91
+ addedInstallUnits: merged.addedInstallUnits ?? [],
92
+ updatedInstallUnits: merged.updatedInstallUnits ?? [],
93
+ addedWorkflows: merged.addedWorkflows ?? [],
94
+ addedHarnesses: merged.addedHarnesses ?? [],
95
+ skippedSkills: merged.skippedSkills ?? [],
96
+ reviewNotes: merged.reviewNotes ?? [],
97
+ warnings: [...(inventory.warnings ?? []), ...(generatedInventory?.redactions.warnings ?? [])],
98
+ redactedPaths: generatedInventory?.redactions.path_count ?? 0
39
99
  }
40
100
  };
41
101
  }
42
102
 
43
- function resolveUnderRoot(root, path) {
44
- return isAbsolute(path) ? path : resolve(root, path);
103
+ async function withRefreshLock(root, operation) {
104
+ const lockPath = join(root, ".skillboard-inventory-refresh.lock");
105
+ let handle;
106
+ try {
107
+ handle = await open(lockPath, "wx", 0o600);
108
+ } catch (error) {
109
+ if (error?.code === "EEXIST") {
110
+ throw new Error("Another inventory refresh is already using this project.");
111
+ }
112
+ throw error;
113
+ }
114
+ try {
115
+ await handle.writeFile(`${process.pid}\n`);
116
+ return await operation();
117
+ } finally {
118
+ await handle.close();
119
+ await rm(lockPath, { force: true });
120
+ }
121
+ }
122
+
123
+ function resolveUnderRoot(root, path, label) {
124
+ const target = isAbsolute(path) ? resolve(path) : resolve(root, path);
125
+ if (!isPathInside(root, target)) {
126
+ throw new Error(`${label} must remain inside the project root.`);
127
+ }
128
+ return target;
129
+ }
130
+
131
+ async function assertGeneratedInventoryTarget(root, target) {
132
+ if (!isPathInside(root, target)) {
133
+ throw new Error(`Generated inventory target is outside the project root: ${target}`);
134
+ }
135
+ const targetStats = await lstat(target).catch(missingOnly);
136
+ if (targetStats?.isSymbolicLink()) {
137
+ throw new Error(`Generated inventory target is a symlink and may resolve outside the project root: ${target}`);
138
+ }
139
+ const existingParent = await nearestExistingDirectory(dirname(target));
140
+ const [realRoot, realParent] = await Promise.all([realpath(root), realpath(existingParent)]);
141
+ if (!isPathInside(realRoot, realParent)) {
142
+ throw new Error(`Generated inventory target resolves outside the project root: ${target}`);
143
+ }
144
+ }
145
+
146
+ async function nearestExistingDirectory(start) {
147
+ let candidate = start;
148
+ while (true) {
149
+ const stats = await lstat(candidate).catch(missingOnly);
150
+ if (stats !== undefined) {
151
+ if (!stats.isDirectory() && !stats.isSymbolicLink()) {
152
+ throw new Error(`Generated inventory parent is not a directory: ${candidate}`);
153
+ }
154
+ return candidate;
155
+ }
156
+ const parent = dirname(candidate);
157
+ if (parent === candidate) {
158
+ throw new Error(`Generated inventory parent does not exist: ${start}`);
159
+ }
160
+ candidate = parent;
161
+ }
162
+ }
163
+
164
+ function missingOnly(error) {
165
+ if (error?.code === "ENOENT") {
166
+ return undefined;
167
+ }
168
+ throw error;
169
+ }
170
+
171
+ function isPathInside(root, candidate) {
172
+ const rel = relative(root, candidate);
173
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
174
+ }
175
+
176
+ async function writeInventoryFile(path, text) {
177
+ await atomicWrite(path, Buffer.from(text));
178
+ }
179
+
180
+ async function restoreFile(path, bytes) {
181
+ if (bytes === null) {
182
+ await rm(path, { force: true });
183
+ return;
184
+ }
185
+ await atomicWrite(path, bytes);
186
+ }
187
+
188
+ function relativeArtifactPath(root, path) {
189
+ return relative(root, path).replace(/\\/g, "/") || ".";
45
190
  }
@@ -2,6 +2,7 @@ import { isAbsolute, relative, resolve } from "node:path";
2
2
  import { runAgentLayerUninstallCommand } from "./agent-integration-cli.mjs";
3
3
  import { initProject } from "./init.mjs";
4
4
  import { uninstallProject } from "./uninstall.mjs";
5
+ import { uninstallUser } from "./user-uninstall.mjs";
5
6
 
6
7
  export { runSetupCommand } from "./agent-integration-cli.mjs";
7
8
 
@@ -40,6 +41,9 @@ export async function runInitCommand(options, stdout, runtime = defaultRuntime()
40
41
  }
41
42
 
42
43
  export async function runUninstallCommand(options, stdout, runtime = defaultRuntime()) {
44
+ if (options.get("user") === "true") {
45
+ return await runUserUninstallCommand(options, stdout, runtime);
46
+ }
43
47
  if (options.get("agent-layer") === "true") {
44
48
  return await runAgentLayerUninstallCommand(options, stdout, runtime);
45
49
  }
@@ -82,6 +86,36 @@ export async function runUninstallCommand(options, stdout, runtime = defaultRunt
82
86
  return 0;
83
87
  }
84
88
 
89
+ async function runUserUninstallCommand(options, stdout, runtime) {
90
+ const allowed = new Set(["user", "dry-run", "yes", "json"]);
91
+ for (const option of options.keys()) {
92
+ if (!allowed.has(option)) {
93
+ throw new Error(`Unknown option for uninstall --user: --${option}`);
94
+ }
95
+ }
96
+ const dryRun = options.get("dry-run") === "true";
97
+ if (!dryRun && options.get("yes") !== "true") {
98
+ throw new Error("skillboard uninstall --user requires --yes; preview first with --dry-run");
99
+ }
100
+ const result = await uninstallUser({
101
+ dryRun,
102
+ env: runtime.env ?? process.env,
103
+ runtime
104
+ });
105
+ if (options.get("json") === "true") {
106
+ stdout.write(`${JSON.stringify(result, null, 2)}\n`);
107
+ return 0;
108
+ }
109
+ stdout.write(`${dryRun ? "Dry run: " : ""}Uninstalled SkillBoard user state.\n`);
110
+ writeList(stdout, "Managed copies", result.managed_copies.map((copy) => copy.path));
111
+ writeList(stdout, "Guidance removed", result.guidance.removed);
112
+ writeList(stdout, "Guidance updated", result.guidance.updated);
113
+ writeList(stdout, "State", result.state_paths);
114
+ writeList(stdout, "Preserved", result.preserved);
115
+ if (dryRun) stdout.write("Run skillboard uninstall --user --yes to apply this plan.\n");
116
+ return 0;
117
+ }
118
+
85
119
  function writeList(stdout, label, values) {
86
120
  if (values.length > 0) {
87
121
  stdout.write(`${label}: ${formatList(values)}\n`);
@@ -104,18 +138,12 @@ function writeCountedList(stdout, label, values) {
104
138
 
105
139
  function writeSafetyDefault(stdout, safety) {
106
140
  stdout.write("Skill selection default:\n");
107
- stdout.write("- Installed user skills are usable unless runtime, user, or local instructions disable them.\n");
108
- if (safety.automatic === 0) {
109
- stdout.write("- No automatic model invocation was enabled.\n");
110
- } else {
111
- stdout.write(`- ${safety.automatic} automatic skills enabled by existing policy.\n`);
112
- }
113
- stdout.write("- Imported local skills are available on request in generated local policy.\n");
114
- stdout.write("- Runtime/plugin/system skills require source review before automatic invocation.\n");
115
- stdout.write(`- ${safety.automatic} automatic skills enabled\n`);
116
- stdout.write(`- ${safety.manualOnly} manual-only skills available\n`);
117
- stdout.write(`- ${safety.routerOnly} router-only skills available\n`);
118
- stdout.write(`- ${safety.blocked} blocked/quarantined for safety\n`);
141
+ stdout.write("- Valid installed skills default to enabled and agent-local.\n");
142
+ stdout.write("- Users may disable a skill or explicitly share that skill across agents.\n");
143
+ stdout.write("- Optional preference ranks candidates and never changes availability.\n");
144
+ stdout.write("- Source and provenance are audit metadata, never availability.\n");
145
+ stdout.write(`- ${safety.enabled} enabled skills\n`);
146
+ stdout.write(`- ${safety.disabled} disabled skills\n`);
119
147
  }
120
148
 
121
149
  function writeNextCommands(stdout, next) {