agent-skillboard 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +37 -2
- package/bin/postinstall.mjs +2 -1
- package/docs/ai-skill-routing-goal.md +7 -0
- package/docs/install.md +48 -2
- package/docs/reference.md +20 -1
- package/docs/user-flow.md +13 -0
- package/docs/versioning.md +16 -3
- package/package.json +1 -1
- package/src/agent-integration-cli.mjs +87 -13
- package/src/agent-integration-home.mjs +14 -1
- package/src/agent-inventory-platforms.mjs +13 -10
- package/src/agent-inventory.mjs +21 -15
- package/src/agent-root-registry.mjs +127 -0
- package/src/agent-skill-roots.mjs +70 -13
- package/src/cli.mjs +24 -4
- package/src/doctor.mjs +8 -1
- package/src/install-health.mjs +177 -0
- package/src/inventory-refresh.mjs +6 -2
- package/src/shared-skill-reconcile.mjs +97 -0
- package/src/shared-skill.mjs +40 -16
- package/src/user-uninstall.mjs +18 -3
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { lstat, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { analyzeAgentCompatibility } from "./agent-skill-import.mjs";
|
|
4
|
+
import {
|
|
5
|
+
assertShareTargetAvailable,
|
|
6
|
+
copyManaged,
|
|
7
|
+
managedShareMetadata,
|
|
8
|
+
marker,
|
|
9
|
+
shareTarget
|
|
10
|
+
} from "./shared-skill.mjs";
|
|
11
|
+
import { loadWorkspace } from "./workspace.mjs";
|
|
12
|
+
|
|
13
|
+
export async function reconcileSharedSkills(options) {
|
|
14
|
+
const workspace = await loadWorkspace({
|
|
15
|
+
configPath: options.configPath,
|
|
16
|
+
inventoryPath: options.inventoryPath
|
|
17
|
+
});
|
|
18
|
+
const targets = uniqueTargets(options.targets ?? []);
|
|
19
|
+
const created = [];
|
|
20
|
+
const unchanged = [];
|
|
21
|
+
const preserved = [];
|
|
22
|
+
const blocked = [];
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
for (const policy of workspace.skills.filter((skill) => skill.shared)) {
|
|
26
|
+
const source = shareTarget(join(options.home, ".agents", "shared-skills"), policy.id);
|
|
27
|
+
const sourceMarker = await managedShareMetadata(source, policy.id);
|
|
28
|
+
if (sourceMarker?.mode !== "shared-source") {
|
|
29
|
+
blocked.push({ skill: policy.id, reason: "managed shared source is missing" });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const content = await readFile(join(source, "SKILL.md"), "utf8");
|
|
33
|
+
for (const target of targets) {
|
|
34
|
+
const path = shareTarget(target.root, policy.id);
|
|
35
|
+
const existing = await pathStats(path);
|
|
36
|
+
if (existing !== null) {
|
|
37
|
+
if (await managedShareMetadata(path, policy.id) !== null) {
|
|
38
|
+
unchanged.push({ agent: target.agent, skill: policy.id, path });
|
|
39
|
+
} else {
|
|
40
|
+
preserved.push({ agent: target.agent, skill: policy.id, path });
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (target.agent !== sourceMarker.source_agent) {
|
|
45
|
+
const compatibility = analyzeAgentCompatibility(content, {
|
|
46
|
+
sourceAgent: sourceMarker.source_agent,
|
|
47
|
+
targetAgent: target.agent
|
|
48
|
+
});
|
|
49
|
+
if (!compatibility.compatible) {
|
|
50
|
+
blocked.push({
|
|
51
|
+
agent: target.agent,
|
|
52
|
+
skill: policy.id,
|
|
53
|
+
path,
|
|
54
|
+
reason: compatibility.reasons.join("; ")
|
|
55
|
+
});
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
await assertShareTargetAvailable(target.root, path, options.home, policy.id);
|
|
60
|
+
if (await copyManaged(source, path, marker(
|
|
61
|
+
policy.id,
|
|
62
|
+
sourceMarker.source_agent,
|
|
63
|
+
"agent-copy",
|
|
64
|
+
target.agent
|
|
65
|
+
), options)) {
|
|
66
|
+
created.push({ agent: target.agent, skill: policy.id, path });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const rollbackErrors = [];
|
|
72
|
+
for (const entry of created.reverse()) {
|
|
73
|
+
await rm(entry.path, { recursive: true, force: true }).catch((rollbackError) => rollbackErrors.push(rollbackError));
|
|
74
|
+
}
|
|
75
|
+
if (rollbackErrors.length === 0) throw error;
|
|
76
|
+
const original = error instanceof Error ? error.message : String(error);
|
|
77
|
+
const rollback = rollbackErrors.map((entry) => entry instanceof Error ? entry.message : String(entry)).join("; ");
|
|
78
|
+
throw new Error(`${original} Reconcile rollback also failed: ${rollback}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { created, unchanged, preserved, blocked };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function uniqueTargets(targets) {
|
|
85
|
+
const byRoot = new Map();
|
|
86
|
+
for (const target of targets) {
|
|
87
|
+
byRoot.set(resolve(target.root), { agent: target.agent, root: resolve(target.root) });
|
|
88
|
+
}
|
|
89
|
+
return [...byRoot.values()].sort((left, right) => left.root.localeCompare(right.root));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function pathStats(path) {
|
|
93
|
+
return await lstat(path).catch((error) => {
|
|
94
|
+
if (error?.code === "ENOENT") return null;
|
|
95
|
+
throw error;
|
|
96
|
+
});
|
|
97
|
+
}
|
package/src/shared-skill.mjs
CHANGED
|
@@ -2,8 +2,8 @@ import { constants as fsConstants } from "node:fs";
|
|
|
2
2
|
import { copyFile, lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
-
import { analyzeAgentCompatibility,
|
|
6
|
-
import { detectedAgentSkillRoots, supportedAgentNames } from "./agent-skill-roots.mjs";
|
|
5
|
+
import { analyzeAgentCompatibility, findSourceSkillInRoots } from "./agent-skill-import.mjs";
|
|
6
|
+
import { agentSkillRootCandidates, detectedAgentSkillRoots, supportedAgentNames } from "./agent-skill-roots.mjs";
|
|
7
7
|
import { setV2SkillShared } from "./control/v2-skill-crud.mjs";
|
|
8
8
|
import { refreshAgentInventory } from "./inventory-refresh.mjs";
|
|
9
9
|
import { loadWorkspace } from "./workspace.mjs";
|
|
@@ -43,10 +43,10 @@ async function shareSkill(options, workspace, policy) {
|
|
|
43
43
|
}
|
|
44
44
|
const sharedRoot = join(options.home, ".agents", "shared-skills");
|
|
45
45
|
const sharedDir = shareTarget(sharedRoot, options.skillId);
|
|
46
|
-
const targets = await Promise.all(targetAgents.map(async (agent) => {
|
|
47
|
-
const
|
|
48
|
-
return { agent, root, path: shareTarget(root, options.skillId) };
|
|
49
|
-
}));
|
|
46
|
+
const targets = uniqueTargets((await Promise.all(targetAgents.map(async (agent) => {
|
|
47
|
+
const roots = await detectedAgentSkillRoots(agent, options.home, options.env, { includeFallback: true });
|
|
48
|
+
return roots.map((root) => ({ agent, root: root.skillRoot, path: shareTarget(root.skillRoot, options.skillId) }));
|
|
49
|
+
}))).flat());
|
|
50
50
|
await assertShareTargetAvailable(sharedRoot, sharedDir, options.home, options.skillId);
|
|
51
51
|
for (const target of targets) {
|
|
52
52
|
await assertShareTargetAvailable(target.root, target.path, options.home, options.skillId);
|
|
@@ -88,9 +88,10 @@ async function shareSkill(options, workspace, policy) {
|
|
|
88
88
|
async function unshareSkill(options, policy) {
|
|
89
89
|
const managed = [];
|
|
90
90
|
for (const agent of supportedAgentNames()) {
|
|
91
|
-
const root
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
for (const root of await agentSkillRootCandidates(agent, options.home, options.env)) {
|
|
92
|
+
const path = shareTarget(root.skillRoot, options.skillId);
|
|
93
|
+
if (await managedShare(path, options.skillId)) managed.push(path);
|
|
94
|
+
}
|
|
94
95
|
}
|
|
95
96
|
const sharedDir = shareTarget(join(options.home, ".agents", "shared-skills"), options.skillId);
|
|
96
97
|
if (await managedShare(sharedDir, options.skillId)) managed.push(sharedDir);
|
|
@@ -146,7 +147,7 @@ function refreshedInventoryAgents(workspace, options) {
|
|
|
146
147
|
return agents(workspace.inventory.skills.find((skill) => skill.id === options.skillId)?.installed_on);
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
async function copyManaged(source, target, metadata, options) {
|
|
150
|
+
export async function copyManaged(source, target, metadata, options = {}) {
|
|
150
151
|
if (await exists(target)) {
|
|
151
152
|
if (!await managedShare(target, metadata.skill)) {
|
|
152
153
|
throw new Error(`Share target already exists and is not managed by SkillBoard: ${target}`);
|
|
@@ -192,12 +193,29 @@ async function copyDirectory(source, target) {
|
|
|
192
193
|
}
|
|
193
194
|
}
|
|
194
195
|
|
|
195
|
-
async function
|
|
196
|
-
const
|
|
197
|
-
|
|
196
|
+
export async function managedShareMetadata(path, skillId) {
|
|
197
|
+
const directory = await pathStats(path);
|
|
198
|
+
if (directory === null || directory.isSymbolicLink() || !directory.isDirectory()) return null;
|
|
199
|
+
const markerPath = join(path, MARKER);
|
|
200
|
+
const markerStats = await pathStats(markerPath);
|
|
201
|
+
if (markerStats === null || markerStats.isSymbolicLink() || !markerStats.isFile()) return null;
|
|
202
|
+
const value = await readFile(markerPath, "utf8").then(parseJsonOrNull, () => null);
|
|
203
|
+
return value?.version === 1 && value.managed_by === "skillboard" && value.skill === skillId ? value : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function parseJsonOrNull(text) {
|
|
207
|
+
try {
|
|
208
|
+
return JSON.parse(text);
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
198
212
|
}
|
|
199
213
|
|
|
200
|
-
function
|
|
214
|
+
export async function managedShare(path, skillId) {
|
|
215
|
+
return await managedShareMetadata(path, skillId) !== null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function marker(skill, sourceAgent, mode, targetAgent = null) {
|
|
201
219
|
return { version: 1, managed_by: "skillboard", mode, skill, source_agent: sourceAgent, target_agent: targetAgent };
|
|
202
220
|
}
|
|
203
221
|
|
|
@@ -235,7 +253,7 @@ async function exists(path) {
|
|
|
235
253
|
return lstat(path).then(() => true, () => false);
|
|
236
254
|
}
|
|
237
255
|
|
|
238
|
-
function shareTarget(root, skillId) {
|
|
256
|
+
export function shareTarget(root, skillId) {
|
|
239
257
|
const value = String(skillId).replace(/\\/g, "/");
|
|
240
258
|
if (value.trim() === "" || value.includes("\0") || value.startsWith("/") || /^[A-Za-z]:\//.test(value)) {
|
|
241
259
|
throw new Error("skill id must identify a relative skill directory");
|
|
@@ -249,7 +267,7 @@ function shareTarget(root, skillId) {
|
|
|
249
267
|
return target;
|
|
250
268
|
}
|
|
251
269
|
|
|
252
|
-
async function assertShareTargetAvailable(root, target, home, skillId) {
|
|
270
|
+
export async function assertShareTargetAvailable(root, target, home, skillId) {
|
|
253
271
|
const resolvedRoot = resolve(root);
|
|
254
272
|
const resolvedHome = resolve(home);
|
|
255
273
|
const boundary = resolvedRoot === resolvedHome || isInside(resolvedRoot, resolvedHome)
|
|
@@ -274,6 +292,12 @@ async function assertShareTargetAvailable(root, target, home, skillId) {
|
|
|
274
292
|
}
|
|
275
293
|
}
|
|
276
294
|
|
|
295
|
+
function uniqueTargets(targets) {
|
|
296
|
+
const byPath = new Map();
|
|
297
|
+
for (const target of targets) byPath.set(resolve(target.path), target);
|
|
298
|
+
return [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path));
|
|
299
|
+
}
|
|
300
|
+
|
|
277
301
|
function directoryComponents(boundary, targetDir) {
|
|
278
302
|
const components = [];
|
|
279
303
|
let current = resolve(targetDir);
|
package/src/user-uninstall.mjs
CHANGED
|
@@ -4,7 +4,6 @@ import { uninstallAgentIntegration } from "./agent-integration-files.mjs";
|
|
|
4
4
|
import { resolveSetupHome } from "./agent-integration-home.mjs";
|
|
5
5
|
import {
|
|
6
6
|
agentSkillRootCandidates,
|
|
7
|
-
setupAgentSkillTargets,
|
|
8
7
|
supportedAgentNames
|
|
9
8
|
} from "./agent-skill-roots.mjs";
|
|
10
9
|
|
|
@@ -56,7 +55,15 @@ async function planUserUninstall(home, env) {
|
|
|
56
55
|
|
|
57
56
|
const guidanceTargets = [];
|
|
58
57
|
for (const agent of supportedAgentNames()) {
|
|
59
|
-
|
|
58
|
+
for (const candidate of await agentSkillRootCandidates(agent, home, env)) {
|
|
59
|
+
guidanceTargets.push({
|
|
60
|
+
agent,
|
|
61
|
+
home,
|
|
62
|
+
skillPath: join(candidate.skillRoot, "skillboard", "SKILL.md"),
|
|
63
|
+
root: candidate.skillRoot,
|
|
64
|
+
source: candidate.source
|
|
65
|
+
});
|
|
66
|
+
}
|
|
60
67
|
}
|
|
61
68
|
return {
|
|
62
69
|
managedCopies: managedCopies.sort((left, right) => left.path.localeCompare(right.path)),
|
|
@@ -88,13 +95,21 @@ async function readManagedMarker(path, skill, agent) {
|
|
|
88
95
|
const markerPath = join(path, SHARE_MARKER);
|
|
89
96
|
const stats = await pathStats(markerPath);
|
|
90
97
|
if (stats === null || stats.isSymbolicLink() || !stats.isFile()) return null;
|
|
91
|
-
const value = await readFile(markerPath, "utf8").then(
|
|
98
|
+
const value = await readFile(markerPath, "utf8").then(parseJsonOrNull, () => null);
|
|
92
99
|
if (value?.version !== 1 || value.managed_by !== "skillboard" || value.skill !== skill) return null;
|
|
93
100
|
if (agent === null && value.mode === "shared-source") return value;
|
|
94
101
|
if (agent !== null && value.mode === "agent-copy" && value.target_agent === agent) return value;
|
|
95
102
|
return null;
|
|
96
103
|
}
|
|
97
104
|
|
|
105
|
+
function parseJsonOrNull(text) {
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(text);
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
98
113
|
async function removeManagedCopy(copy) {
|
|
99
114
|
const stats = await pathStats(copy.path);
|
|
100
115
|
if (stats === null) return true;
|