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
@@ -119,6 +119,50 @@ export function postUsePolicySuggestionForCapabilityRoute({ matchedCapability, r
119
119
  });
120
120
  }
121
121
 
122
+ export function policyMemoryForV2Route({ intent, selected, candidates, workflowName }) {
123
+ if (selected.explicit || selected.matchedIntents.length === 0 || candidates.length < 2) {
124
+ return null;
125
+ }
126
+ const alternatives = candidates.slice(1).map((candidate) => candidate.skill.id);
127
+ const alternativeText = alternatives.join(", ");
128
+ const context = workflowName === undefined ? "for the current agent" : `in legacy workflow ${workflowName}`;
129
+ return {
130
+ status: "applied",
131
+ mode: "remembered-or-configured-preference",
132
+ selected_skill: selected.skill.id,
133
+ available_alternatives: alternatives,
134
+ summary: `Remembered or configured policy selected ${selected.skill.id} for ${intent} ${context}; other allowed skills were also available: ${alternativeText}.`,
135
+ finish_disclosure: `I used ${selected.skill.id} for this request because SkillBoard has a remembered or configured preference for it; other allowed skills were also available: ${alternativeText}.`
136
+ };
137
+ }
138
+
139
+ export function postUsePolicySuggestionForV2Route({ intent, selected, candidates, workflowName, options, policyMemory }) {
140
+ if (selected.explicit || policyMemory !== null || candidates.length < 2) {
141
+ return null;
142
+ }
143
+ const context = workflowName === undefined ? "" : ` in ${workflowName}`;
144
+ return {
145
+ timing: "after_use",
146
+ mode: "ask_after_use",
147
+ reason: `SkillBoard found multiple allowed skills for ${intent} and selected ${selected.skill.id}. After completing the task, ask whether to remember ${selected.skill.id} for similar requests${context}.`,
148
+ question: `Should I remember ${selected.skill.id} as the preferred skill for similar ${intent} requests${context}?`,
149
+ requires_confirmation: true,
150
+ suggested_policy: {
151
+ kind: "prefer-skill",
152
+ skill: selected.skill.id,
153
+ workflow: workflowName ?? null,
154
+ intent,
155
+ command_hint: command([
156
+ "skillboard", "skill", "preference", selected.skill.id,
157
+ "--intent", intent,
158
+ "--priority", "100",
159
+ "--config", routeConfigPath(options),
160
+ ...(options.skillsRoot === undefined ? [] : ["--skills", routeSkillsRoot(options)])
161
+ ]).display
162
+ }
163
+ };
164
+ }
165
+
122
166
  function postUsePolicySuggestionForDeniedPreferredFallback({ matchedCapability, recommended, routedSkills, workflowName, options }) {
123
167
  if (recommended === null || recommended.role !== "fallback" || recommended.guard.allowed !== true) {
124
168
  return null;
@@ -199,9 +243,10 @@ export function recommendationReason({ match, matchedFields, matchedTerms, skill
199
243
  export function guardCommand(skillId, workflowName, options) {
200
244
  return command([
201
245
  "skillboard", "guard", "use", skillId,
202
- "--workflow", workflowName,
246
+ ...(workflowName === undefined ? [] : ["--workflow", workflowName]),
247
+ ...(options.agent === undefined ? [] : ["--agent", options.agent]),
203
248
  "--config", routeConfigPath(options),
204
- "--skills", routeSkillsRoot(options)
249
+ ...(options.skillsRoot === undefined && workflowName === undefined ? [] : ["--skills", routeSkillsRoot(options)])
205
250
  ]).display;
206
251
  }
207
252
 
@@ -24,6 +24,38 @@ export function selectRoute(workspace, workflow, intent) {
24
24
  };
25
25
  }
26
26
 
27
+ export function selectV2Route(workspace, intent, agentName) {
28
+ const intentKey = phraseKey(intent);
29
+ const intentTokens = tokensFor(intent);
30
+ const candidates = workspace.skills
31
+ .map((skill) => {
32
+ const guard = canUseSkill(workspace, skill.id, undefined, agentName);
33
+ const explicit = exactSkillIdMention(intentKey, skill.id);
34
+ const preference = skill.preference;
35
+ const matchedIntents = preference?.intents.filter((term) => matchesIntentTerm(intentTokens, term)) ?? [];
36
+ const installed = installedSkillFor(workspace, skill.id);
37
+ const metadataTokens = tokensFor(`${installed?.name ?? ""} ${installed?.description ?? ""}`);
38
+ const metadataMatches = [...metadataTokens].filter((token) => intentTokens.has(token));
39
+ const hasMatch = explicit || matchedIntents.length > 0 || metadataMatches.length > 0;
40
+ const preferenceScore = matchedIntents.length > 0 ? (preference?.priority ?? 0) : 0;
41
+ const rawScore = (explicit ? 1_000_000 : 0)
42
+ + matchedIntents.length * 100
43
+ + preferenceScore
44
+ + metadataMatches.length;
45
+ const score = hasMatch ? Math.max(1, rawScore) : 0;
46
+ return { skill, guard, explicit, score, matchedIntents, metadataMatches };
47
+ })
48
+ .filter((candidate) => candidate.guard.allowed)
49
+ .filter((candidate) => candidate.score > 0)
50
+ .sort((left, right) => right.score - left.score || left.skill.id.localeCompare(right.skill.id));
51
+ return { candidates, selected: candidates[0] };
52
+ }
53
+
54
+ function matchesIntentTerm(intentTokens, term) {
55
+ const termTokens = tokensFor(term);
56
+ return termTokens.size > 0 && [...termTokens].every((token) => intentTokens.has(token));
57
+ }
58
+
27
59
  function explicitAllowedSkillRouteCandidate(intent, skillCandidates) {
28
60
  const intentKey = phraseKey(intent);
29
61
  return skillCandidates.find((candidate) => candidate.allowed && exactSkillIdMention(intentKey, candidate.skill_id));
@@ -31,7 +63,10 @@ function explicitAllowedSkillRouteCandidate(intent, skillCandidates) {
31
63
 
32
64
  function exactSkillIdMention(intentKey, skillId) {
33
65
  const skillKey = phraseKey(skillId);
34
- return skillKey.length > 0 && intentKey.includes(skillKey);
66
+ if (skillKey.length === 0) return false;
67
+ const intentTokens = intentKey.split(" ");
68
+ const skillTokens = skillKey.split(" ");
69
+ return intentTokens.some((_, index) => skillTokens.every((token, offset) => intentTokens[index + offset] === token));
35
70
  }
36
71
 
37
72
  function capabilityRouteCandidates(workspace, workflow) {
@@ -173,7 +208,8 @@ function skillMetadataTerms(workspace, skillId, weight) {
173
208
 
174
209
  function installedSkillFor(workspace, skillId) {
175
210
  const skill = workspace.skills.find((candidate) => candidate.id === skillId);
176
- return workspace.installedSkills.find((installed) => installed.id === skillId || installed.path === skill?.path);
211
+ return workspace.installedSkills.find((installed) => installed.id === skillId || installed.path === skill?.path)
212
+ ?? workspace.inventory?.skills?.find((installed) => installed.id === skillId);
177
213
  }
178
214
 
179
215
  function phraseMatchScore(intent, capability) {
package/src/route.mjs CHANGED
@@ -4,18 +4,23 @@ import {
4
4
  guardCommand,
5
5
  overlapResolutionForRoute,
6
6
  policyMemoryForRoute,
7
+ policyMemoryForV2Route,
7
8
  postUsePolicySuggestionForCapabilityRoute,
9
+ postUsePolicySuggestionForV2Route,
8
10
  recommendationReason,
9
11
  routeCandidate,
10
12
  selectedRouteSkill,
11
13
  usageDisclosure
12
14
  } from "./route-advisory.mjs";
13
- import { confidenceFor, possibleWorkflowSkills, selectRoute } from "./route-selection.mjs";
15
+ import { confidenceFor, possibleWorkflowSkills, selectRoute, selectV2Route } from "./route-selection.mjs";
14
16
 
15
17
  export function routeSkill(workspace, options) {
16
18
  const intent = options.intent.trim();
17
19
  if (intent.length === 0) {
18
- throw new Error("Usage: skillboard route <intent> --workflow <name>");
20
+ throw new Error("Usage: skillboard route <intent> [--agent <name>]");
21
+ }
22
+ if (workspace.version === 2) {
23
+ return routeV2Skill(workspace, options, intent);
19
24
  }
20
25
  const workflow = workspace.workflows.find((candidate) => candidate.name === options.workflow);
21
26
  if (workflow === undefined) {
@@ -97,6 +102,61 @@ export function routeSkill(workspace, options) {
97
102
  };
98
103
  }
99
104
 
105
+ function routeV2Skill(workspace, options, intent) {
106
+ const { candidates, selected } = selectV2Route(workspace, intent, options.agent);
107
+ if (selected === undefined) {
108
+ return {
109
+ ok: true, intent, workflow: null, agent: options.agent ?? null,
110
+ matched_capability: null, matched_skill: null, match_source: "none", confidence: "none",
111
+ matched_terms: [], recommendation_reason: "No enabled skill installed for this agent matched this request.",
112
+ recommended_skill: null, fallback_skills: [], route_candidates: [], overlap_resolution: null,
113
+ policy_memory: null, guard_command: null, guard: null, usage_disclosure: null,
114
+ post_use_policy_suggestion: null, possible_skills: [], candidates: []
115
+ };
116
+ }
117
+ const routed = candidates.map((candidate) => ({
118
+ skill: candidate.skill.id,
119
+ role: candidate.explicit ? "matched" : "alternative",
120
+ guard: candidate.guard
121
+ }));
122
+ const matchedTerms = [...new Set([...selected.matchedIntents, ...selected.metadataMatches])];
123
+ const contextName = options.agent === undefined ? "the current agent" : `agent ${options.agent}`;
124
+ const recommended = routed[0];
125
+ const overlapResolution = overlapResolutionForRoute({
126
+ matchedCapability: intent,
127
+ recommended,
128
+ routedSkills: routed,
129
+ workflowName: contextName
130
+ });
131
+ const policyMemory = policyMemoryForV2Route({ intent, selected, candidates, workflowName: undefined });
132
+ const postUsePolicySuggestion = postUsePolicySuggestionForV2Route({
133
+ intent, selected, candidates, workflowName: undefined, options, policyMemory
134
+ });
135
+ return {
136
+ ok: true, intent, workflow: null, agent: options.agent ?? null,
137
+ matched_capability: null,
138
+ matched_skill: selected.skill.id,
139
+ match_source: selected.explicit
140
+ ? "explicit-skill"
141
+ : selected.matchedIntents.length > 0
142
+ ? "skill-preference"
143
+ : "skill-metadata",
144
+ confidence: confidenceFor(selected.score),
145
+ matched_terms: matchedTerms,
146
+ recommendation_reason: `Recommended ${selected.skill.id} because it is enabled, installed for this agent, and best matches the request.`,
147
+ recommended_skill: selected.skill.id,
148
+ fallback_skills: candidates.slice(1).map((candidate) => candidate.skill.id),
149
+ route_candidates: routed.map((entry, index) => routeCandidate(entry, index === 0)),
150
+ overlap_resolution: overlapResolution, policy_memory: policyMemory,
151
+ guard_command: guardCommand(selected.skill.id, undefined, options),
152
+ guard: selected.guard,
153
+ usage_disclosure: usageDisclosure(selected.skill.id, policyMemory),
154
+ post_use_policy_suggestion: postUsePolicySuggestion,
155
+ possible_skills: routed.map((entry) => ({ id: entry.skill, allowed: true })),
156
+ candidates: candidates.map((candidate) => ({ skill_id: candidate.skill.id, score: candidate.score }))
157
+ };
158
+ }
159
+
100
160
  function noRoute({ intent, workflow, candidates, skillCandidates, workspace }) {
101
161
  return {
102
162
  ok: true,
@@ -0,0 +1,301 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { copyFile, lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm, symlink, writeFile } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import { analyzeAgentCompatibility, agentSkillRoot, findSourceSkillInRoots } from "./agent-skill-import.mjs";
6
+ import { detectedAgentSkillRoots, supportedAgentNames } from "./agent-skill-roots.mjs";
7
+ import { setV2SkillShared } from "./control/v2-skill-crud.mjs";
8
+ import { refreshAgentInventory } from "./inventory-refresh.mjs";
9
+ import { loadWorkspace } from "./workspace.mjs";
10
+
11
+ const MARKER = ".skillboard-share.json";
12
+
13
+ export async function setSkillSharing(options) {
14
+ const workspace = await loadWorkspace({ configPath: options.configPath, inventoryPath: options.inventoryPath });
15
+ const policy = workspace.skills.find((skill) => skill.id === options.skillId);
16
+ if (policy === undefined) throw new Error(`Unknown skill: ${options.skillId}`);
17
+ return options.shared
18
+ ? await shareSkill(options, workspace, policy)
19
+ : await unshareSkill(options, policy);
20
+ }
21
+
22
+ async function shareSkill(options, workspace, policy) {
23
+ const observation = workspace.inventory.skills.find((skill) => skill.id === options.skillId);
24
+ const installedOn = agents(observation?.installed_on);
25
+ const sourceAgent = installedOn[0];
26
+ if (sourceAgent === undefined) {
27
+ throw new Error(`Skill ${options.skillId} is not installed in a supported agent skill root.`);
28
+ }
29
+ const sourceRoots = (await detectedAgentSkillRoots(sourceAgent, options.home, options.env, { includeFallback: true }))
30
+ .map((entry) => entry.skillRoot);
31
+ const source = await findSourceSkillInRoots({ roots: sourceRoots, skill: options.skillId });
32
+ const sourceDir = await realpath(source.skillDir);
33
+ if (!(await lstat(sourceDir)).isDirectory()) {
34
+ throw new Error(`Skill source is not a directory: ${source.skillDir}`);
35
+ }
36
+ const content = await readFile(source.skillFile, "utf8");
37
+ const targetAgents = supportedAgentNames().filter((agent) => agent !== sourceAgent && !installedOn.includes(agent));
38
+ for (const targetAgent of targetAgents) {
39
+ const compatibility = analyzeAgentCompatibility(content, { sourceAgent, targetAgent });
40
+ if (!compatibility.compatible) {
41
+ throw new Error(`Skill ${options.skillId} needs adaptation for ${targetAgent}: ${compatibility.reasons.join("; ")}`);
42
+ }
43
+ }
44
+ const sharedRoot = join(options.home, ".agents", "shared-skills");
45
+ const sharedDir = shareTarget(sharedRoot, options.skillId);
46
+ const targets = await Promise.all(targetAgents.map(async (agent) => {
47
+ const root = await agentSkillRoot(agent, options.home, options.env);
48
+ return { agent, root, path: shareTarget(root, options.skillId) };
49
+ }));
50
+ await assertShareTargetAvailable(sharedRoot, sharedDir, options.home, options.skillId);
51
+ for (const target of targets) {
52
+ await assertShareTargetAvailable(target.root, target.path, options.home, options.skillId);
53
+ }
54
+ if (options.dryRun) {
55
+ return sharingResult(options, policy, installedOn, targetAgents, false);
56
+ }
57
+
58
+ const created = [];
59
+ let policyChanged = false;
60
+ try {
61
+ if (await copyManaged(sourceDir, sharedDir, marker(options.skillId, sourceAgent, "shared-source"), options)) {
62
+ created.push(sharedDir);
63
+ }
64
+ for (const target of targets) {
65
+ if (await exists(target.path)) continue;
66
+ if (await copyManaged(sharedDir, target.path, marker(options.skillId, sourceAgent, "agent-copy", target.agent), options)) {
67
+ created.push(target.path);
68
+ }
69
+ }
70
+ await setPolicy(options, true);
71
+ policyChanged = policy.shared !== true;
72
+ failpoint(options, "after-policy-write");
73
+ const refreshed = await refresh(options);
74
+ const current = refreshedInventoryAgents(refreshed, options);
75
+ return sharingResult(options, { ...policy, shared: true }, current, targetAgents, true);
76
+ } catch (error) {
77
+ const rollbackErrors = [];
78
+ await Promise.all(created.reverse().map(async (path) => {
79
+ await rm(path, { recursive: true, force: true }).catch((rollbackError) => rollbackErrors.push(rollbackError));
80
+ }));
81
+ if (policyChanged) {
82
+ await setPolicy(options, policy.shared).catch((rollbackError) => rollbackErrors.push(rollbackError));
83
+ }
84
+ throw rollbackOutcome(error, rollbackErrors);
85
+ }
86
+ }
87
+
88
+ async function unshareSkill(options, policy) {
89
+ const managed = [];
90
+ for (const agent of supportedAgentNames()) {
91
+ const root = await agentSkillRoot(agent, options.home, options.env);
92
+ const path = shareTarget(root, options.skillId);
93
+ if (await managedShare(path, options.skillId)) managed.push(path);
94
+ }
95
+ const sharedDir = shareTarget(join(options.home, ".agents", "shared-skills"), options.skillId);
96
+ if (await managedShare(sharedDir, options.skillId)) managed.push(sharedDir);
97
+ if (options.dryRun) {
98
+ return sharingResult(options, policy, [], [], false);
99
+ }
100
+ const staged = [];
101
+ let policyChanged = false;
102
+ try {
103
+ await setPolicy(options, false);
104
+ policyChanged = policy.shared !== false;
105
+ for (const path of [...new Set(managed)]) {
106
+ const stagedPath = join(
107
+ dirname(dirname(path)),
108
+ `.skillboard-unshare-${basename(path)}-${randomUUID()}`
109
+ );
110
+ await rename(path, stagedPath);
111
+ staged.push({ path, stagedPath });
112
+ }
113
+ failpoint(options, "after-files-staged");
114
+ await refresh(options);
115
+ } catch (error) {
116
+ const rollbackErrors = [];
117
+ for (const entry of staged.reverse()) {
118
+ await rename(entry.stagedPath, entry.path).catch((rollbackError) => rollbackErrors.push(rollbackError));
119
+ }
120
+ if (policyChanged) {
121
+ await setPolicy(options, policy.shared).catch((rollbackError) => rollbackErrors.push(rollbackError));
122
+ }
123
+ throw rollbackOutcome(error, rollbackErrors);
124
+ }
125
+ await Promise.all(staged.map((entry) => rm(entry.stagedPath, { recursive: true, force: true })));
126
+ const workspace = await loadWorkspace({ configPath: options.configPath, inventoryPath: options.inventoryPath });
127
+ const observation = workspace.inventory.skills.find((skill) => skill.id === options.skillId);
128
+ return sharingResult(options, { ...policy, shared: false }, agents(observation?.installed_on), [], true);
129
+ }
130
+
131
+ async function setPolicy(options, shared) {
132
+ await setV2SkillShared({ skillId: options.skillId, shared, configPath: options.configPath, dryRun: false });
133
+ }
134
+
135
+ async function refresh(options) {
136
+ await refreshAgentInventory({
137
+ root: options.home,
138
+ configPath: options.configPath,
139
+ home: options.home,
140
+ env: options.env
141
+ });
142
+ return await loadWorkspace({ configPath: options.configPath, inventoryPath: options.inventoryPath });
143
+ }
144
+
145
+ function refreshedInventoryAgents(workspace, options) {
146
+ return agents(workspace.inventory.skills.find((skill) => skill.id === options.skillId)?.installed_on);
147
+ }
148
+
149
+ async function copyManaged(source, target, metadata, options) {
150
+ if (await exists(target)) {
151
+ if (!await managedShare(target, metadata.skill)) {
152
+ throw new Error(`Share target already exists and is not managed by SkillBoard: ${target}`);
153
+ }
154
+ return false;
155
+ }
156
+ await mkdir(dirname(target), { recursive: true });
157
+ try {
158
+ await mkdir(target);
159
+ } catch (error) {
160
+ if (error?.code === "EEXIST") {
161
+ if (await managedShare(target, metadata.skill)) return false;
162
+ throw new Error(`Share target already exists and is not managed by SkillBoard: ${target}`);
163
+ }
164
+ throw error;
165
+ }
166
+ try {
167
+ failpoint(options, "after-copy-target-created");
168
+ await copyDirectory(source, target);
169
+ await writeFile(join(target, MARKER), `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
170
+ return true;
171
+ } catch (error) {
172
+ const rollbackErrors = [];
173
+ await rm(target, { recursive: true, force: true }).catch((rollbackError) => rollbackErrors.push(rollbackError));
174
+ throw rollbackOutcome(error, rollbackErrors);
175
+ }
176
+ }
177
+
178
+ async function copyDirectory(source, target) {
179
+ for (const entry of await readdir(source, { withFileTypes: true })) {
180
+ const sourcePath = join(source, entry.name);
181
+ const targetPath = join(target, entry.name);
182
+ if (entry.isDirectory()) {
183
+ await mkdir(targetPath);
184
+ await copyDirectory(sourcePath, targetPath);
185
+ } else if (entry.isFile()) {
186
+ await copyFile(sourcePath, targetPath, fsConstants.COPYFILE_EXCL);
187
+ } else if (entry.isSymbolicLink()) {
188
+ await symlink(await readlink(sourcePath), targetPath);
189
+ } else {
190
+ throw new Error(`Unsupported skill entry type: ${sourcePath}`);
191
+ }
192
+ }
193
+ }
194
+
195
+ async function managedShare(path, skillId) {
196
+ const value = await readFile(join(path, MARKER), "utf8").then(JSON.parse, () => null);
197
+ return value?.version === 1 && value.skill === skillId;
198
+ }
199
+
200
+ function marker(skill, sourceAgent, mode, targetAgent = null) {
201
+ return { version: 1, managed_by: "skillboard", mode, skill, source_agent: sourceAgent, target_agent: targetAgent };
202
+ }
203
+
204
+ function agents(value) {
205
+ const supported = new Set(supportedAgentNames());
206
+ return [...new Set((Array.isArray(value) ? value : []).filter((agent) => supported.has(agent)))].sort();
207
+ }
208
+
209
+ function sharingResult(options, policy, installedOn, targets, changed) {
210
+ return {
211
+ ok: true,
212
+ skill: options.skillId,
213
+ shared: options.dryRun ? options.shared : policy.shared,
214
+ changed,
215
+ dry_run: options.dryRun,
216
+ installed_on: agents(installedOn),
217
+ target_agents: [...targets].sort()
218
+ };
219
+ }
220
+
221
+ function failpoint(options, value) {
222
+ if (options.env?.SKILLBOARD_SHARE_FAILPOINT === value) {
223
+ throw new Error(`Injected sharing failure ${value}.`);
224
+ }
225
+ }
226
+
227
+ function rollbackOutcome(error, rollbackErrors) {
228
+ if (rollbackErrors.length === 0) return error;
229
+ const original = error instanceof Error ? error.message : String(error);
230
+ const rollback = rollbackErrors.map((entry) => entry instanceof Error ? entry.message : String(entry)).join("; ");
231
+ return new Error(`${original} Rollback also failed: ${rollback}`);
232
+ }
233
+
234
+ async function exists(path) {
235
+ return lstat(path).then(() => true, () => false);
236
+ }
237
+
238
+ function shareTarget(root, skillId) {
239
+ const value = String(skillId).replace(/\\/g, "/");
240
+ if (value.trim() === "" || value.includes("\0") || value.startsWith("/") || /^[A-Za-z]:\//.test(value)) {
241
+ throw new Error("skill id must identify a relative skill directory");
242
+ }
243
+ const resolvedRoot = resolve(root);
244
+ const target = resolve(resolvedRoot, value);
245
+ const rel = relative(resolvedRoot, target);
246
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
247
+ throw new Error(`skill id must stay under the skill root: ${skillId}`);
248
+ }
249
+ return target;
250
+ }
251
+
252
+ async function assertShareTargetAvailable(root, target, home, skillId) {
253
+ const resolvedRoot = resolve(root);
254
+ const resolvedHome = resolve(home);
255
+ const boundary = resolvedRoot === resolvedHome || isInside(resolvedRoot, resolvedHome)
256
+ ? resolvedHome
257
+ : resolvedRoot;
258
+ for (const component of directoryComponents(boundary, dirname(target))) {
259
+ const stats = await pathStats(component);
260
+ if (stats?.isSymbolicLink()) {
261
+ throw new Error(`Share target contains a symbolic link: ${component}`);
262
+ }
263
+ if (stats !== null && !stats.isDirectory()) {
264
+ throw new Error(`Share target directory component is not a directory: ${component}`);
265
+ }
266
+ }
267
+ const stats = await pathStats(target);
268
+ if (stats === null) return;
269
+ if (stats.isSymbolicLink()) {
270
+ throw new Error(`Share target is a symbolic link: ${target}`);
271
+ }
272
+ if (!stats.isDirectory() || !await managedShare(target, skillId)) {
273
+ throw new Error(`Share target already exists and is not managed by SkillBoard: ${target}`);
274
+ }
275
+ }
276
+
277
+ function directoryComponents(boundary, targetDir) {
278
+ const components = [];
279
+ let current = resolve(targetDir);
280
+ const root = resolve(boundary);
281
+ while (current === root || isInside(current, root)) {
282
+ components.push(current);
283
+ if (current === root) break;
284
+ const parent = dirname(current);
285
+ if (parent === current) break;
286
+ current = parent;
287
+ }
288
+ return components.reverse();
289
+ }
290
+
291
+ function isInside(path, parent) {
292
+ const rel = relative(parent, path);
293
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
294
+ }
295
+
296
+ async function pathStats(path) {
297
+ return lstat(path).catch((error) => {
298
+ if (error?.code === "ENOENT") return null;
299
+ throw error;
300
+ });
301
+ }
@@ -0,0 +1,42 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile, readdir, readlink } from "node:fs/promises";
3
+ import { join, relative } from "node:path";
4
+
5
+ const DIGEST_PREFIX = "sha256:";
6
+
7
+ export async function sourceDigest(path) {
8
+ const hash = createHash("sha256");
9
+ hash.update("skillboard-source-digest-v1\n");
10
+ await addPathDigest(hash, path, path);
11
+ return `${DIGEST_PREFIX}${hash.digest("hex")}`;
12
+ }
13
+
14
+ export async function skillContentDigest(skillFilePath) {
15
+ const hash = createHash("sha256");
16
+ hash.update(await readFile(skillFilePath));
17
+ return `${DIGEST_PREFIX}${hash.digest("hex")}`;
18
+ }
19
+
20
+ async function addPathDigest(hash, root, path) {
21
+ const stats = await lstat(path);
22
+ const rel = relative(root, path).replace(/\\/g, "/") || ".";
23
+ if (stats.isSymbolicLink()) {
24
+ hash.update(`symlink\0${rel}\0${await readlink(path)}\n`);
25
+ return;
26
+ }
27
+ if (stats.isDirectory()) {
28
+ hash.update(`dir\0${rel}\n`);
29
+ const entries = (await readdir(path, { withFileTypes: true }))
30
+ .filter((entry) => entry.name !== ".git" && entry.name !== "node_modules")
31
+ .sort((left, right) => left.name.localeCompare(right.name));
32
+ for (const entry of entries) await addPathDigest(hash, root, join(path, entry.name));
33
+ return;
34
+ }
35
+ if (!stats.isFile()) {
36
+ hash.update(`other\0${rel}\n`);
37
+ return;
38
+ }
39
+ hash.update(`file\0${rel}\0${stats.size}\0`);
40
+ hash.update(await readFile(path));
41
+ hash.update("\n");
42
+ }
@@ -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,
@@ -89,6 +90,9 @@ export function mergeImportFragment(configText, imported, options = {}) {
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);
@@ -121,6 +125,29 @@ export function mergeImportFragment(configText, imported, options = {}) {
121
125
  };
122
126
  }
123
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: []
148
+ };
149
+ }
150
+
124
151
  function ensureMap(document, key) {
125
152
  const existing = document.get(key, true);
126
153
  if (existing === undefined) {