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.
@@ -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
+ }
@@ -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, agentSkillRoot, findSourceSkillInRoots } from "./agent-skill-import.mjs";
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 root = await agentSkillRoot(agent, options.home, options.env);
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 = await agentSkillRoot(agent, options.home, options.env);
92
- const path = shareTarget(root, options.skillId);
93
- if (await managedShare(path, options.skillId)) managed.push(path);
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 managedShare(path, skillId) {
196
- const value = await readFile(join(path, MARKER), "utf8").then(JSON.parse, () => null);
197
- return value?.version === 1 && value.skill === skillId;
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 marker(skill, sourceAgent, mode, targetAgent = null) {
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);
@@ -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
- guidanceTargets.push(...await setupAgentSkillTargets(agent, home, env));
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(JSON.parse, () => null);
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;