agent-skillboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CONTRIBUTING.md +83 -0
  2. package/LICENSE +21 -0
  3. package/README.md +645 -0
  4. package/bin/skillboard.mjs +4 -0
  5. package/docs/adapters.md +127 -0
  6. package/docs/capabilities.md +107 -0
  7. package/docs/install.md +346 -0
  8. package/docs/plans/20260625-080025-skillboard-mvp-review.md +189 -0
  9. package/docs/plans/README.md +20 -0
  10. package/docs/policy-model.md +251 -0
  11. package/docs/positioning.md +94 -0
  12. package/docs/profiles.md +166 -0
  13. package/docs/rollout-runbook.md +60 -0
  14. package/docs/user-flow.md +231 -0
  15. package/docs/versioning.md +201 -0
  16. package/examples/multi-source-skills/anthropic/docx/SKILL.md +8 -0
  17. package/examples/multi-source-skills/anthropic/skill-creator/SKILL.md +8 -0
  18. package/examples/multi-source-skills/matt/grill-me/SKILL.md +8 -0
  19. package/examples/multi-source-skills/matt/tdd/SKILL.md +8 -0
  20. package/examples/multi-source-skills/omo/review-work/SKILL.md +8 -0
  21. package/examples/multi-source-skills/omo/ulw-plan/SKILL.md +8 -0
  22. package/examples/multi-source-skills/private/tdd-work-continuity/SKILL.md +8 -0
  23. package/examples/multi-source-skills/private/workflow-router/SKILL.md +8 -0
  24. package/examples/multi-source-skills/wshobson/python-testing/SKILL.md +8 -0
  25. package/examples/multi-source-skills/wshobson/security-review/SKILL.md +8 -0
  26. package/examples/multi-source.config.yaml +271 -0
  27. package/examples/skillboard.config.yaml +194 -0
  28. package/examples/skills/grill-me/SKILL.md +9 -0
  29. package/examples/skills/grill-with-docs/SKILL.md +9 -0
  30. package/examples/skills/requirement-intake/SKILL.md +9 -0
  31. package/examples/skills/tdd/SKILL.md +8 -0
  32. package/package.json +58 -0
  33. package/profiles/anthropics-skills.yaml +17 -0
  34. package/profiles/mattpocock-skills.yaml +26 -0
  35. package/profiles/oh-my-openagent.yaml +32 -0
  36. package/profiles/voltagent-awesome-agent-skills.yaml +18 -0
  37. package/profiles/wshobson-agents.yaml +19 -0
  38. package/src/advisor/action-core.mjs +74 -0
  39. package/src/advisor/actions.mjs +256 -0
  40. package/src/advisor/application-commands.mjs +59 -0
  41. package/src/advisor/apply-action.mjs +183 -0
  42. package/src/advisor/schema.mjs +112 -0
  43. package/src/advisor/setup-actions.mjs +42 -0
  44. package/src/advisor/skills.mjs +191 -0
  45. package/src/advisor/sort.mjs +15 -0
  46. package/src/advisor/sources.mjs +160 -0
  47. package/src/advisor/trust-policy.mjs +65 -0
  48. package/src/advisor/workflow.mjs +41 -0
  49. package/src/advisor.mjs +134 -0
  50. package/src/agent-inventory-platforms.mjs +100 -0
  51. package/src/agent-inventory.mjs +804 -0
  52. package/src/brief-cli.mjs +40 -0
  53. package/src/brief-renderer.mjs +362 -0
  54. package/src/change-plan.mjs +169 -0
  55. package/src/cli.mjs +1171 -0
  56. package/src/config-helpers.mjs +72 -0
  57. package/src/control/can-use-guard.mjs +91 -0
  58. package/src/control/config-write.mjs +163 -0
  59. package/src/control/skill-crud.mjs +394 -0
  60. package/src/control/source-trust.mjs +161 -0
  61. package/src/control/workflow-crud.mjs +104 -0
  62. package/src/control.mjs +197 -0
  63. package/src/doctor.mjs +299 -0
  64. package/src/domain/constants.mjs +47 -0
  65. package/src/domain/indexes.mjs +29 -0
  66. package/src/domain/rules/capabilities.mjs +33 -0
  67. package/src/domain/rules/harnesses.mjs +16 -0
  68. package/src/domain/rules/install-units.mjs +95 -0
  69. package/src/domain/rules/skills.mjs +105 -0
  70. package/src/domain/rules/workflows.mjs +105 -0
  71. package/src/domain/skill-state-matrix.mjs +79 -0
  72. package/src/domain/source-classes.mjs +99 -0
  73. package/src/hook-plan.mjs +152 -0
  74. package/src/impact.mjs +41 -0
  75. package/src/index.mjs +82 -0
  76. package/src/init.mjs +136 -0
  77. package/src/install-output-detector.mjs +337 -0
  78. package/src/install-units.mjs +62 -0
  79. package/src/inventory-refresh.mjs +45 -0
  80. package/src/lifecycle-cli.mjs +166 -0
  81. package/src/lifecycle-content.mjs +87 -0
  82. package/src/policy.mjs +42 -0
  83. package/src/reconcile.mjs +111 -0
  84. package/src/report.mjs +151 -0
  85. package/src/review.mjs +88 -0
  86. package/src/rollout.mjs +453 -0
  87. package/src/skill-paths.mjs +17 -0
  88. package/src/source-cache.mjs +178 -0
  89. package/src/source-profile-loader.mjs +160 -0
  90. package/src/source-profiles.mjs +299 -0
  91. package/src/source-verification.mjs +252 -0
  92. package/src/uninstall.mjs +258 -0
  93. package/src/workspace.mjs +219 -0
  94. package/tsconfig.lsp.json +15 -0
package/src/index.mjs ADDED
@@ -0,0 +1,82 @@
1
+ import { loadWorkspace } from "./workspace.mjs";
2
+ import { checkPolicy } from "./policy.mjs";
3
+ import { doctorProject } from "./doctor.mjs";
4
+ import { impactDisable } from "./impact.mjs";
5
+ import { renderDashboard, renderReconcilePlan } from "./report.mjs";
6
+ import { reconcileWorkspace } from "./reconcile.mjs";
7
+ import { initProject } from "./init.mjs";
8
+ import { agentInventoryDetectors, discoverAgentSkillInventory, mergeAgentSkillInventory } from "./agent-inventory.mjs";
9
+ import { refreshAgentInventory } from "./inventory-refresh.mjs";
10
+ import { uninstallProject } from "./uninstall.mjs";
11
+ import { importSource, loadSourceProfile, mergeImportFragment, renderImportFragment } from "./source-profiles.mjs";
12
+ import { verifySources, writeLockfile } from "./source-verification.mjs";
13
+ import { refreshSourcePins } from "./source-cache.mjs";
14
+ import { detectInstallOutput } from "./install-output-detector.mjs";
15
+ import { rolloutApply, rolloutAudit, rolloutPlan, rolloutReport, rolloutRollback } from "./rollout.mjs";
16
+ import { reviewInstallUnit } from "./review.mjs";
17
+ import { buildSkillBrief } from "./advisor.mjs";
18
+ import {
19
+ activateSkill,
20
+ addHarness,
21
+ addSkill,
22
+ addWorkflow,
23
+ auditSources,
24
+ blockSkill,
25
+ canUseSkill,
26
+ explainSkill,
27
+ installGuardHook,
28
+ listHarnesses,
29
+ listInstallUnits,
30
+ listSkills,
31
+ listWorkflows,
32
+ preferSkill,
33
+ quarantineSkill,
34
+ removeSkill
35
+ } from "./control.mjs";
36
+
37
+ export {
38
+ activateSkill,
39
+ addHarness,
40
+ addSkill,
41
+ addWorkflow,
42
+ agentInventoryDetectors,
43
+ auditSources,
44
+ blockSkill,
45
+ buildSkillBrief,
46
+ canUseSkill,
47
+ checkPolicy,
48
+ discoverAgentSkillInventory,
49
+ detectInstallOutput,
50
+ doctorProject,
51
+ explainSkill,
52
+ impactDisable,
53
+ importSource,
54
+ initProject,
55
+ installGuardHook,
56
+ listHarnesses,
57
+ listInstallUnits,
58
+ listSkills,
59
+ listWorkflows,
60
+ loadSourceProfile,
61
+ loadWorkspace,
62
+ mergeAgentSkillInventory,
63
+ mergeImportFragment,
64
+ preferSkill,
65
+ quarantineSkill,
66
+ removeSkill,
67
+ reconcileWorkspace,
68
+ refreshAgentInventory,
69
+ refreshSourcePins,
70
+ renderDashboard,
71
+ reviewInstallUnit,
72
+ rolloutApply,
73
+ rolloutAudit,
74
+ rolloutPlan,
75
+ rolloutReport,
76
+ rolloutRollback,
77
+ renderImportFragment,
78
+ renderReconcilePlan,
79
+ uninstallProject,
80
+ verifySources,
81
+ writeLockfile
82
+ };
package/src/init.mjs ADDED
@@ -0,0 +1,136 @@
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import YAML from "yaml";
4
+ import { NON_CALLABLE_WORKFLOW_INVOCATIONS, NON_CALLABLE_WORKFLOW_STATUSES } from "./domain/constants.mjs";
5
+ import { isValidSkillState } from "./domain/skill-state-matrix.mjs";
6
+ import { refreshAgentInventory } from "./inventory-refresh.mjs";
7
+ import { BRIDGE_START, bridgeBlock, defaultConfig, hookReadme, profileReadme } from "./lifecycle-content.mjs";
8
+
9
+ export async function initProject(options) {
10
+ const root = options.root;
11
+ const configPath = join(root, "skillboard.config.yaml");
12
+ const skillsRoot = join(root, "skills");
13
+ const reportRoot = join(root, ".skillboard", "reports");
14
+ const profileRoot = join(root, ".skillboard", "profiles");
15
+ const hookRoot = join(root, ".skillboard", "hooks");
16
+ const created = [];
17
+ const updated = [];
18
+ await mkdir(skillsRoot, { recursive: true });
19
+ await mkdir(reportRoot, { recursive: true });
20
+ await mkdir(profileRoot, { recursive: true });
21
+ await mkdir(hookRoot, { recursive: true });
22
+ const configCreated = !(await exists(configPath));
23
+ if (configCreated) {
24
+ await writeFile(configPath, defaultConfig(), "utf8");
25
+ created.push("skillboard.config.yaml");
26
+ }
27
+ const profileReadmePath = join(profileRoot, "README.md");
28
+ if (!(await exists(profileReadmePath))) {
29
+ await writeFile(profileReadmePath, profileReadme(), "utf8");
30
+ created.push(".skillboard/profiles/README.md");
31
+ }
32
+ const hookReadmePath = join(hookRoot, "README.md");
33
+ if (!(await exists(hookReadmePath))) {
34
+ await writeFile(hookReadmePath, hookReadme(), "utf8");
35
+ created.push(".skillboard/hooks/README.md");
36
+ }
37
+ for (const filename of ["AGENTS.md", "CLAUDE.md"]) {
38
+ const result = await ensureBridge(join(root, filename));
39
+ if (result === "created") {
40
+ created.push(filename);
41
+ } else if (result === "updated") {
42
+ updated.push(filename);
43
+ }
44
+ }
45
+ const scan = options.scanInstalled === false
46
+ ? { scannedSkills: 0, scannedInstallUnits: 0, changed: false, addedSkills: [], addedInstallUnits: [], updatedInstallUnits: [], addedWorkflows: [], addedHarnesses: [], skippedSkills: [], reviewNotes: [], warnings: [] }
47
+ : await mergeInstalledAgentSkills(configPath, {
48
+ roots: options.scanRoots,
49
+ home: options.home,
50
+ env: options.env
51
+ });
52
+ if (scan.changed && !configCreated) {
53
+ updated.push("skillboard.config.yaml");
54
+ }
55
+ const safety = await summarizeSafety(configPath);
56
+ return {
57
+ created,
58
+ updated,
59
+ scan,
60
+ safety,
61
+ alreadyInitialized: created.length === 0 && updated.length === 0 && !scan.changed
62
+ };
63
+ }
64
+
65
+ async function exists(path) {
66
+ return access(path).then(() => true, () => false);
67
+ }
68
+
69
+ async function ensureBridge(path) {
70
+ const block = bridgeBlock();
71
+ if (!(await exists(path))) {
72
+ await writeFile(path, `${block}\n`, "utf8");
73
+ return "created";
74
+ }
75
+ const current = await readFile(path, "utf8");
76
+ if (current.includes(BRIDGE_START)) {
77
+ return "unchanged";
78
+ }
79
+ const separator = current.endsWith("\n") ? "\n" : "\n\n";
80
+ await writeFile(path, `${current}${separator}${block}\n`, "utf8");
81
+ return "updated";
82
+ }
83
+
84
+ async function mergeInstalledAgentSkills(configPath, options) {
85
+ const result = await refreshAgentInventory({
86
+ root: dirname(configPath),
87
+ configPath,
88
+ roots: options.roots,
89
+ home: options.home,
90
+ env: options.env
91
+ });
92
+ return {
93
+ scannedSkills: result.scan.scannedSkills,
94
+ scannedInstallUnits: result.scan.scannedInstallUnits,
95
+ changed: result.changed,
96
+ addedSkills: result.scan.addedSkills,
97
+ addedInstallUnits: result.scan.addedInstallUnits,
98
+ updatedInstallUnits: result.scan.updatedInstallUnits,
99
+ addedWorkflows: result.scan.addedWorkflows,
100
+ addedHarnesses: result.scan.addedHarnesses,
101
+ skippedSkills: result.scan.skippedSkills,
102
+ reviewNotes: result.scan.reviewNotes,
103
+ warnings: result.scan.warnings
104
+ };
105
+ }
106
+
107
+ async function summarizeSafety(configPath) {
108
+ const config = YAML.parse(await readFile(configPath, "utf8")) ?? {};
109
+ const skills = config.skills && typeof config.skills === "object" ? Object.values(config.skills) : [];
110
+ let automatic = 0;
111
+ let manualOnly = 0;
112
+ let routerOnly = 0;
113
+ let blocked = 0;
114
+ for (const rawSkill of skills) {
115
+ const skill = rawSkill && typeof rawSkill === "object" ? rawSkill : {};
116
+ const status = typeof skill.status === "string" ? skill.status : "vendor";
117
+ const invocation = typeof skill.invocation === "string" ? skill.invocation : "manual-only";
118
+ if (NON_CALLABLE_WORKFLOW_STATUSES.has(status) || NON_CALLABLE_WORKFLOW_INVOCATIONS.has(invocation)) {
119
+ blocked += 1;
120
+ continue;
121
+ }
122
+ if (!isValidSkillState(status, invocation)) {
123
+ continue;
124
+ }
125
+ if (["workflow-auto", "global-auto"].includes(invocation)) {
126
+ automatic += 1;
127
+ }
128
+ if (invocation === "manual-only") {
129
+ manualOnly += 1;
130
+ }
131
+ if (invocation === "router-only") {
132
+ routerOnly += 1;
133
+ }
134
+ }
135
+ return { automatic, manualOnly, routerOnly, blocked };
136
+ }
@@ -0,0 +1,337 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
+ import YAML from "yaml";
4
+ import { textChangePlan } from "./change-plan.mjs";
5
+
6
+ export async function detectInstallOutput(options = {}) {
7
+ const root = resolve(options.root ?? ".");
8
+ const configPath = resolveUnderRoot(root, options.configPath ?? "skillboard.config.yaml");
9
+ const unitId = requireOption(options.unitId, "unit");
10
+ const dryRun = options.dryRun === true;
11
+ const originalText = await readFile(configPath, "utf8");
12
+ const document = YAML.parseDocument(originalText);
13
+ if (document.errors.length > 0) {
14
+ throw new Error(`Invalid YAML config: ${document.errors.map((error) => error.message).join("; ")}`);
15
+ }
16
+ const installOutput = options.installOutputPath === undefined ? "" : await readFile(options.installOutputPath, "utf8");
17
+ const configFiles = options.configFiles ?? [];
18
+ const configTexts = await Promise.all(configFiles.map(async (path) => {
19
+ return { path, text: await readFile(path, "utf8").catch(() => "") };
20
+ }));
21
+ const detected = detectRuntimeMetadata(installOutput, configTexts, root);
22
+ const unit = ensureInstallUnit(document, unitId, {
23
+ kind: options.kind ?? "plugin",
24
+ source: options.source ?? "",
25
+ scope: options.scope ?? "user-global"
26
+ });
27
+ const changedFields = mergeDetectedMetadata(unit, detected, document);
28
+ const nextText = preserveLineEndings(String(document), originalText);
29
+ const plan = textChangePlan(originalText, nextText);
30
+ if (plan.changed && !dryRun) {
31
+ await writeFile(configPath, nextText, "utf8");
32
+ }
33
+ return {
34
+ dryRun,
35
+ configPath,
36
+ unitId,
37
+ changed: plan.changed,
38
+ plan,
39
+ detected,
40
+ changedFields
41
+ };
42
+ }
43
+
44
+ export function detectRuntimeMetadata(installOutput, configTexts = [], root = process.cwd()) {
45
+ const commands = new Set();
46
+ const hooks = new Set();
47
+ const mcpServers = new Set();
48
+ const modifiedConfigFiles = new Set();
49
+
50
+ collectFromText(installOutput, { commands, hooks, mcpServers, modifiedConfigFiles }, root);
51
+ for (const entry of configTexts) {
52
+ if (entry.path !== undefined) {
53
+ modifiedConfigFiles.add(displayPath(entry.path, root));
54
+ }
55
+ collectFromConfigText(entry.text, { commands, hooks, mcpServers });
56
+ }
57
+
58
+ return {
59
+ commands: [...commands].sort((left, right) => left.localeCompare(right)),
60
+ hooks: [...hooks].sort((left, right) => left.localeCompare(right)),
61
+ mcpServers: [...mcpServers].sort((left, right) => left.localeCompare(right)),
62
+ modifiedConfigFiles: [...modifiedConfigFiles].sort((left, right) => left.localeCompare(right))
63
+ };
64
+ }
65
+
66
+ function collectFromText(text, output, root) {
67
+ for (const match of text.matchAll(/\$[A-Za-z][A-Za-z0-9_:-]*/gu)) {
68
+ output.commands.add(match[0]);
69
+ }
70
+ collectLabeledValues(text, /\bcommands?\b\s*[:=]\s*(?<values>[^\n]+)/giu, output.commands, normalizeCommand);
71
+ collectLabeledValues(text, /\bhooks?\b\s*[:=]\s*(?<values>[^\n]+)/giu, output.hooks, normalizeHook);
72
+ collectLabeledValues(text, /\bmcp[-_ ]?servers?\b\s*[:=]\s*(?<values>[^\n]+)/giu, output.mcpServers, normalizeIdentifier);
73
+ collectLabeledValues(text, /\b(?:updated|modified|patched|wrote|writing)\s+(?<values>[^\n]+(?:config|settings)[^\n]*)/giu, output.modifiedConfigFiles, (value) => normalizePath(value, root));
74
+ }
75
+
76
+ function collectFromConfigText(text, output) {
77
+ const parsed = parseJsonObject(text);
78
+ if (parsed !== null) {
79
+ collectFromObject(parsed, output);
80
+ }
81
+ collectLabeledValues(text, /\bcommands?\b\s*[:=]\s*\[?(?<values>[^\]\n]+)\]?/giu, output.commands, normalizeCommand);
82
+ collectLabeledValues(text, /\bhooks?\b\s*[:=]\s*\[?(?<values>[^\]\n]+)\]?/giu, output.hooks, normalizeHook);
83
+ for (const match of text.matchAll(/\[(?:mcp_servers|mcpServers)\.(?<name>[A-Za-z0-9_.:-]+)\]/gu)) {
84
+ output.mcpServers.add(match.groups.name);
85
+ }
86
+ for (const match of text.matchAll(/"(?<name>[A-Za-z0-9_.:-]+)"\s*:\s*\{[^{}]*"command"\s*:/gu)) {
87
+ output.mcpServers.add(match.groups.name);
88
+ }
89
+ }
90
+
91
+ function collectFromObject(value, output) {
92
+ if (value === null || typeof value !== "object") {
93
+ return;
94
+ }
95
+ if (Array.isArray(value)) {
96
+ for (const entry of value) {
97
+ collectFromObject(entry, output);
98
+ }
99
+ return;
100
+ }
101
+ for (const [key, entry] of Object.entries(value)) {
102
+ if (key === "commands") {
103
+ collectStringValues(entry, output.commands, normalizeCommand);
104
+ } else if (key === "hooks") {
105
+ collectStringValues(entry, output.hooks, normalizeHook);
106
+ } else if (key === "mcpServers" || key === "mcp_servers") {
107
+ collectObjectKeys(entry, output.mcpServers);
108
+ }
109
+ collectFromObject(entry, output);
110
+ }
111
+ }
112
+
113
+ function collectStringValues(value, target, normalize) {
114
+ if (typeof value === "string") {
115
+ addNormalized(target, normalize(value));
116
+ } else if (Array.isArray(value)) {
117
+ for (const entry of value) {
118
+ collectStringValues(entry, target, normalize);
119
+ }
120
+ } else if (value !== null && typeof value === "object") {
121
+ collectObjectKeys(value, target);
122
+ }
123
+ }
124
+
125
+ function collectObjectKeys(value, target) {
126
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
127
+ for (const key of Object.keys(value)) {
128
+ addNormalized(target, normalizeIdentifier(key));
129
+ }
130
+ }
131
+ }
132
+
133
+ function collectLabeledValues(text, pattern, target, normalize) {
134
+ for (const match of text.matchAll(pattern)) {
135
+ for (const value of splitValues(match.groups.values)) {
136
+ addNormalized(target, normalize(value));
137
+ }
138
+ }
139
+ }
140
+
141
+ function splitValues(value) {
142
+ return value
143
+ .split(/[,\s]+/u)
144
+ .map((entry) => entry.trim().replace(/^['"`\[]+|['"`\].:;]+$/gu, ""))
145
+ .filter((entry) => entry.length > 0);
146
+ }
147
+
148
+ function mergeDetectedMetadata(unit, detected, document) {
149
+ const changedFields = [];
150
+ const components = ensureNestedMap(unit, "components", document);
151
+ if (appendValues(components, "commands", detected.commands, document)) {
152
+ changedFields.push("components.commands");
153
+ }
154
+ if (appendValues(components, "hooks", detected.hooks, document)) {
155
+ changedFields.push("components.hooks");
156
+ }
157
+ if (appendValues(components, "mcp_servers", detected.mcpServers, document)) {
158
+ changedFields.push("components.mcp_servers");
159
+ }
160
+ const provided = componentNames(detected);
161
+ if (appendValues(unit, "provided_components", provided, document)) {
162
+ changedFields.push("provided_components");
163
+ }
164
+ if (appendValues(unit, "modified_config_files", detected.modifiedConfigFiles, document)) {
165
+ changedFields.push("modified_config_files");
166
+ }
167
+ const risk = detected.hooks.length > 0 || detected.mcpServers.length > 0
168
+ ? "high"
169
+ : detected.commands.length > 0 ? "medium" : "";
170
+ if (setIfHigherRisk(unit, risk)) {
171
+ changedFields.push("permission_risk");
172
+ }
173
+ return changedFields;
174
+ }
175
+
176
+ function ensureInstallUnit(document, id, defaults) {
177
+ const units = ensureMap(document, "install_units");
178
+ const existing = units.get(id, true);
179
+ if (existing !== undefined) {
180
+ return requireYamlMap(existing, `install_units.${id}`);
181
+ }
182
+ const unit = document.createNode({
183
+ kind: defaults.kind,
184
+ trust_level: "unreviewed",
185
+ source: defaults.source,
186
+ scope: defaults.scope,
187
+ provided_components: [],
188
+ components: {},
189
+ modified_config_files: [],
190
+ auto_update: false,
191
+ enabled: true,
192
+ permission_risk: "unknown",
193
+ rollback: "manual"
194
+ });
195
+ units.set(id, unit);
196
+ return requireYamlMap(unit, `install_units.${id}`);
197
+ }
198
+
199
+ function ensureMap(document, key) {
200
+ const existing = document.get(key, true);
201
+ if (existing === undefined) {
202
+ const next = document.createNode({});
203
+ next.flow = false;
204
+ document.set(key, next);
205
+ return next;
206
+ }
207
+ return requireYamlMap(existing, key);
208
+ }
209
+
210
+ function ensureNestedMap(parent, key, document) {
211
+ const existing = parent.get(key, true);
212
+ if (existing === undefined) {
213
+ const next = document.createNode({});
214
+ next.flow = false;
215
+ parent.set(key, next);
216
+ return next;
217
+ }
218
+ return requireYamlMap(existing, key);
219
+ }
220
+
221
+ function appendValues(parent, key, values, document) {
222
+ if (values.length === 0) {
223
+ return false;
224
+ }
225
+ const seq = ensureSeq(parent, key, document);
226
+ const before = seq.items.length;
227
+ for (const value of values) {
228
+ if (!seq.items.some((item) => item?.value === value)) {
229
+ seq.add(document.createNode(value));
230
+ }
231
+ }
232
+ return seq.items.length !== before;
233
+ }
234
+
235
+ function ensureSeq(parent, key, document) {
236
+ const existing = parent.get(key, true);
237
+ if (existing === undefined) {
238
+ const next = document.createNode([]);
239
+ next.flow = false;
240
+ parent.set(key, next);
241
+ return next;
242
+ }
243
+ if (!YAML.isSeq(existing)) {
244
+ throw new Error(`${key} must be a sequence`);
245
+ }
246
+ return existing;
247
+ }
248
+
249
+ function requireYamlMap(value, label) {
250
+ if (!YAML.isMap(value)) {
251
+ throw new Error(`${label} must be a mapping`);
252
+ }
253
+ value.flow = false;
254
+ return value;
255
+ }
256
+
257
+ function setIfHigherRisk(unit, risk) {
258
+ if (risk.length === 0) {
259
+ return false;
260
+ }
261
+ const rank = { unknown: 0, low: 1, medium: 2, high: 3 };
262
+ const current = unit.get("permission_risk");
263
+ if ((rank[risk] ?? 0) <= (rank[current] ?? 0)) {
264
+ return false;
265
+ }
266
+ unit.set("permission_risk", risk);
267
+ return true;
268
+ }
269
+
270
+ function componentNames(detected) {
271
+ const names = [];
272
+ if (detected.commands.length > 0) {
273
+ names.push("commands");
274
+ }
275
+ if (detected.hooks.length > 0) {
276
+ names.push("hook");
277
+ }
278
+ if (detected.mcpServers.length > 0) {
279
+ names.push("mcp-server");
280
+ }
281
+ return names;
282
+ }
283
+
284
+ function parseJsonObject(text) {
285
+ try {
286
+ const parsed = JSON.parse(text);
287
+ return parsed !== null && typeof parsed === "object" ? parsed : null;
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+
293
+ function addNormalized(target, value) {
294
+ if (value.length > 0) {
295
+ target.add(value);
296
+ }
297
+ }
298
+
299
+ function normalizeCommand(value) {
300
+ const trimmed = normalizeIdentifier(value);
301
+ return trimmed.startsWith("$") ? trimmed : `$${trimmed}`;
302
+ }
303
+
304
+ function normalizeHook(value) {
305
+ return normalizeIdentifier(value).replace(/\.json$/u, "");
306
+ }
307
+
308
+ function normalizeIdentifier(value) {
309
+ return value.trim().replace(/^['"`\[]+|['"`\].:;]+$/gu, "");
310
+ }
311
+
312
+ function normalizePath(value, root) {
313
+ return displayPath(normalizeIdentifier(value), root);
314
+ }
315
+
316
+ function displayPath(path, root) {
317
+ if (!isAbsolute(path)) {
318
+ return path.replaceAll("\\", "/");
319
+ }
320
+ const rel = relative(root, path).replaceAll("\\", "/");
321
+ return rel.startsWith("..") ? path : rel;
322
+ }
323
+
324
+ function resolveUnderRoot(root, path) {
325
+ return isAbsolute(path) ? path : resolve(root, path);
326
+ }
327
+
328
+ function preserveLineEndings(text, reference) {
329
+ return reference.includes("\r\n") ? text.replace(/\n/g, "\r\n") : text;
330
+ }
331
+
332
+ function requireOption(value, name) {
333
+ if (value === undefined || value.trim() === "") {
334
+ throw new Error(`Missing required option: --${name}`);
335
+ }
336
+ return value;
337
+ }
@@ -0,0 +1,62 @@
1
+ import { readBoolean, readOptionalNumber, readOptionalString, readString, readStringList, requireRecord } from "./config-helpers.mjs";
2
+ import { INSTALL_UNIT_KIND_VALUES, PERMISSION_RISK_VALUES, TRUST_LEVEL_VALUES } from "./domain/constants.mjs";
3
+
4
+ export function parseInstallUnits(value) {
5
+ const raw = requireRecord(value ?? {}, "install_units");
6
+ return Object.entries(raw).map(([id, entry]) => {
7
+ const unit = requireRecord(entry, `install_units.${id}`);
8
+ const kind = readString(unit, "kind", "skill");
9
+ const permissionRisk = readString(unit, "permission_risk", "unknown");
10
+ const trustLevel = readString(unit, "trust_level", "unreviewed");
11
+ const sourceDigest = readOptionalString(unit, "source_digest");
12
+ const signature = readOptionalString(unit, "signature");
13
+ const publicKey = readOptionalString(unit, "public_key");
14
+ const verifiedAt = readOptionalString(unit, "verified_at");
15
+ if (!INSTALL_UNIT_KIND_VALUES.has(kind)) {
16
+ throw new Error(`Unsupported install unit kind for ${id}: ${kind}`);
17
+ }
18
+ if (!PERMISSION_RISK_VALUES.has(permissionRisk)) {
19
+ throw new Error(`Unsupported permission risk for ${id}: ${permissionRisk}`);
20
+ }
21
+ if (!TRUST_LEVEL_VALUES.has(trustLevel)) {
22
+ throw new Error(`Unsupported trust level for ${id}: ${trustLevel}`);
23
+ }
24
+ return {
25
+ id,
26
+ kind,
27
+ sourceClass: readOptionalString(unit, "source_class"),
28
+ priority: readOptionalNumber(unit, "priority"),
29
+ trustLevel,
30
+ sourceDigest: nonEmpty(sourceDigest),
31
+ signature: nonEmpty(signature),
32
+ publicKey: nonEmpty(publicKey),
33
+ verifiedAt: nonEmpty(verifiedAt),
34
+ source: readString(unit, "source", ""),
35
+ scope: readString(unit, "scope", "local"),
36
+ manifestPath: readString(unit, "manifest_path", ""),
37
+ cachePath: readString(unit, "cache_path", ""),
38
+ providedComponents: readStringList(unit, "provided_components"),
39
+ components: parseComponents(unit.components, `install_units.${id}.components`),
40
+ modifiedConfigFiles: readStringList(unit, "modified_config_files"),
41
+ autoUpdate: readBoolean(unit, "auto_update", false),
42
+ enabled: readBoolean(unit, "enabled", false),
43
+ workflowDependencies: readStringList(unit, "workflow_dependencies"),
44
+ permissionRisk,
45
+ rollback: readString(unit, "rollback", "unknown")
46
+ };
47
+ });
48
+ }
49
+
50
+ function nonEmpty(value) {
51
+ return value === undefined || value.trim() === "" ? undefined : value;
52
+ }
53
+
54
+ function parseComponents(value, label) {
55
+ const raw = requireRecord(value ?? {}, label);
56
+ return {
57
+ skills: readStringList(raw, "skills"),
58
+ commands: readStringList(raw, "commands"),
59
+ hooks: readStringList(raw, "hooks"),
60
+ mcpServers: readStringList(raw, "mcp_servers")
61
+ };
62
+ }
@@ -0,0 +1,45 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ import { discoverAgentSkillInventory, mergeAgentSkillInventory } from "./agent-inventory.mjs";
4
+ import { textChangePlan } from "./change-plan.mjs";
5
+
6
+ export async function refreshAgentInventory(options = {}) {
7
+ const root = resolve(options.root ?? ".");
8
+ const configPath = resolveUnderRoot(root, options.configPath ?? "skillboard.config.yaml");
9
+ const inventory = await discoverAgentSkillInventory({
10
+ roots: options.roots,
11
+ home: options.home,
12
+ env: options.env
13
+ });
14
+ const current = await readFile(configPath, "utf8");
15
+ const merged = mergeAgentSkillInventory(current, inventory);
16
+ const plan = textChangePlan(current, merged.text);
17
+ const dryRun = options.dryRun === true;
18
+
19
+ if (plan.changed && !dryRun) {
20
+ await writeFile(configPath, merged.text, "utf8");
21
+ }
22
+
23
+ return {
24
+ dryRun,
25
+ configPath,
26
+ changed: plan.changed,
27
+ plan,
28
+ scan: {
29
+ scannedSkills: inventory.skills.length,
30
+ scannedInstallUnits: inventory.installUnits.length,
31
+ 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 ?? []
39
+ }
40
+ };
41
+ }
42
+
43
+ function resolveUnderRoot(root, path) {
44
+ return isAbsolute(path) ? path : resolve(root, path);
45
+ }