agent-skillboard 0.2.18 → 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.
- package/CHANGELOG.md +43 -0
- package/README.md +129 -258
- package/docs/adapters.md +37 -113
- package/docs/ai-skill-routing-goal.md +35 -109
- package/docs/capabilities.md +17 -104
- package/docs/install.md +36 -485
- package/docs/policy-model.md +50 -280
- package/docs/positioning.md +19 -86
- package/docs/profiles.md +21 -153
- package/docs/reference.md +115 -363
- package/docs/rollout-runbook.md +23 -25
- package/docs/routing.md +23 -90
- package/docs/user-flow.md +60 -284
- package/docs/value-proof.md +23 -181
- package/docs/variant-lifecycle.md +47 -67
- package/docs/versioning.md +31 -264
- package/examples/v2-multi-source.config.yaml +35 -0
- package/examples/v2-policy-error.config.yaml +6 -0
- package/package.json +1 -1
- package/src/advisor/actions.mjs +102 -6
- package/src/advisor/application-commands.mjs +10 -9
- package/src/advisor/apply-action.mjs +74 -1
- package/src/advisor/guidance.mjs +24 -16
- package/src/advisor/schema.mjs +17 -6
- package/src/advisor/skills.mjs +18 -5
- package/src/advisor.mjs +27 -9
- package/src/agent-integration-cli.mjs +13 -4
- package/src/agent-integration-content.mjs +21 -11
- package/src/agent-integration-files.mjs +1 -1
- package/src/agent-inventory-platforms.mjs +10 -0
- package/src/agent-inventory.mjs +23 -1
- package/src/agent-skill-import.mjs +2 -2
- package/src/audit-paths.mjs +42 -0
- package/src/brief-cli.mjs +3 -2
- package/src/brief-renderer.mjs +1 -0
- package/src/cli.mjs +498 -232
- package/src/compatibility.mjs +24 -0
- package/src/control/can-use-guard.mjs +21 -1
- package/src/control/config-write.mjs +32 -2
- package/src/control/skill-crud.mjs +5 -0
- package/src/control/v2-guard.mjs +175 -0
- package/src/control/v2-skill-crud.mjs +32 -0
- package/src/control/v2-skill-forget.mjs +38 -0
- package/src/control/variant-status.mjs +47 -1
- package/src/control.mjs +55 -0
- package/src/doctor.mjs +63 -4
- package/src/domain/v2-policy.mjs +111 -0
- package/src/hook-plan.mjs +33 -3
- package/src/impact.mjs +52 -29
- package/src/index.mjs +25 -1
- package/src/init.mjs +50 -34
- package/src/inventory-install-units.mjs +63 -0
- package/src/inventory-json.mjs +279 -0
- package/src/inventory-refresh.mjs +163 -18
- package/src/lifecycle-cli.mjs +40 -12
- package/src/lifecycle-content.mjs +52 -67
- package/src/migration/v1-to-v2.mjs +212 -0
- package/src/migration/v2-files.mjs +211 -0
- package/src/migration/v2-journal.mjs +169 -0
- package/src/migration/v2-projection.mjs +108 -0
- package/src/migration/v2-transaction.mjs +205 -0
- package/src/policy.mjs +3 -0
- package/src/reconcile.mjs +139 -111
- package/src/report.mjs +168 -148
- package/src/review.mjs +2 -0
- package/src/route-advisory.mjs +47 -2
- package/src/route-selection.mjs +38 -2
- package/src/route.mjs +62 -2
- package/src/shared-skill.mjs +301 -0
- package/src/source-digest.mjs +42 -0
- package/src/source-profiles.mjs +171 -144
- package/src/source-verification.mjs +32 -48
- package/src/uninstall.mjs +22 -0
- package/src/user-state-paths.mjs +19 -0
- package/src/user-uninstall.mjs +146 -0
- package/src/workspace.mjs +119 -79
package/src/route-advisory.mjs
CHANGED
|
@@ -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
|
|
package/src/route-selection.mjs
CHANGED
|
@@ -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
|
-
|
|
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> --
|
|
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
|
+
}
|