agent-skillboard 0.3.0 → 0.3.2

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,177 @@
1
+ import { constants } from "node:fs";
2
+ import { access, lstat, readFile, realpath } from "node:fs/promises";
3
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
4
+
5
+ const PACKAGE_NAME = "agent-skillboard";
6
+ const POSIX_COMMANDS = ["skillboard", "agent-skillboard"];
7
+ const WINDOWS_COMMANDS = [
8
+ "skillboard.cmd",
9
+ "skillboard.exe",
10
+ "skillboard",
11
+ "agent-skillboard.cmd",
12
+ "agent-skillboard.exe",
13
+ "agent-skillboard"
14
+ ];
15
+
16
+ export async function inspectInstallation(options = {}) {
17
+ const platform = options.platform ?? process.platform;
18
+ const pathDelimiter = options.pathDelimiter ?? delimiter;
19
+ const entrypoint = resolve(options.entrypointPath ?? process.argv[1] ?? "bin/skillboard.mjs");
20
+ const currentRealPath = await resolvedPath(entrypoint);
21
+ const currentPackage = await packageForEntrypoint(currentRealPath);
22
+ const pathCandidates = await findPathCandidates(options.env?.PATH ?? "", {
23
+ pathDelimiter,
24
+ platform,
25
+ currentRealPath,
26
+ currentPackageRoot: currentPackage?.root ?? null
27
+ });
28
+ const pathSelected = pathCandidates.find((candidate) => isPrimaryCommand(candidate.path, platform)) ?? null;
29
+ const installations = uniqueInstallations(pathCandidates);
30
+ const duplicateInstallations = installations.length > 1;
31
+ const shadowed = pathSelected !== null && !pathSelected.current;
32
+ const current = {
33
+ version: options.packageVersion ?? currentPackage?.version ?? null,
34
+ entrypoint,
35
+ realPath: currentRealPath,
36
+ packageRoot: currentPackage?.root ?? null
37
+ };
38
+ const warnings = installWarnings({ current, pathSelected, installations, duplicateInstallations, shadowed });
39
+ return {
40
+ current,
41
+ pathSelected,
42
+ pathCandidates,
43
+ installations,
44
+ duplicateInstallations,
45
+ shadowed,
46
+ warnings
47
+ };
48
+ }
49
+
50
+ async function findPathCandidates(pathValue, options) {
51
+ const commands = options.platform === "win32" ? WINDOWS_COMMANDS : POSIX_COMMANDS;
52
+ const directories = pathValue.split(options.pathDelimiter).filter((entry) => entry.trim() !== "");
53
+ const candidates = [];
54
+ const observedPaths = new Set();
55
+ for (const directory of directories) {
56
+ for (const command of commands) {
57
+ const path = resolve(directory, command);
58
+ if (observedPaths.has(path) || !await isExecutable(path, options.platform)) continue;
59
+ observedPaths.add(path);
60
+ candidates.push(await inspectCandidate(path, options));
61
+ }
62
+ }
63
+ return candidates;
64
+ }
65
+
66
+ async function inspectCandidate(path, options) {
67
+ const realPath = await resolvedPath(path);
68
+ const packageMetadata = await packageForCandidate(path, realPath);
69
+ const packageRoot = packageMetadata?.root ?? null;
70
+ return {
71
+ path,
72
+ realPath,
73
+ packageRoot,
74
+ version: packageMetadata?.version ?? null,
75
+ current: realPath === options.currentRealPath
76
+ || (packageRoot !== null && packageRoot === options.currentPackageRoot)
77
+ };
78
+ }
79
+
80
+ async function packageForCandidate(path, realPath) {
81
+ const fromTarget = await packageForEntrypoint(realPath);
82
+ if (fromTarget !== null) return fromTarget;
83
+ const commandDirectory = dirname(path);
84
+ for (const root of [
85
+ join(commandDirectory, "node_modules", PACKAGE_NAME),
86
+ resolve(commandDirectory, "..", PACKAGE_NAME)
87
+ ]) {
88
+ const metadata = await readPackage(root);
89
+ if (metadata !== null) return metadata;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ async function packageForEntrypoint(path) {
95
+ let directory = dirname(path);
96
+ for (let depth = 0; depth < 6; depth += 1) {
97
+ const metadata = await readPackage(directory);
98
+ if (metadata !== null) return metadata;
99
+ const parent = dirname(directory);
100
+ if (parent === directory) break;
101
+ directory = parent;
102
+ }
103
+ return null;
104
+ }
105
+
106
+ async function readPackage(root) {
107
+ try {
108
+ const parsed = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
109
+ if (parsed?.name !== PACKAGE_NAME || typeof parsed.version !== "string") return null;
110
+ return { root: await realpath(root), version: parsed.version };
111
+ } catch (error) {
112
+ if (error?.code === "ENOENT" || error instanceof SyntaxError) return null;
113
+ throw error;
114
+ }
115
+ }
116
+
117
+ async function isExecutable(path, platform) {
118
+ try {
119
+ const stats = await lstat(path);
120
+ if (!stats.isFile() && !stats.isSymbolicLink()) return false;
121
+ await access(path, platform === "win32" ? constants.F_OK : constants.X_OK);
122
+ return true;
123
+ } catch (error) {
124
+ if (["EACCES", "ELOOP", "ENOENT", "ENOTDIR"].includes(error?.code)) return false;
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ async function resolvedPath(path) {
130
+ try {
131
+ return await realpath(path);
132
+ } catch (error) {
133
+ if (error?.code === "ENOENT") return resolve(path);
134
+ throw error;
135
+ }
136
+ }
137
+
138
+ function uniqueInstallations(candidates) {
139
+ const installations = [];
140
+ const observed = new Set();
141
+ for (const candidate of candidates) {
142
+ if (candidate.packageRoot === null || observed.has(candidate.packageRoot)) continue;
143
+ observed.add(candidate.packageRoot);
144
+ installations.push(candidate);
145
+ }
146
+ return installations;
147
+ }
148
+
149
+ function isPrimaryCommand(path, platform) {
150
+ const command = basename(path).toLowerCase();
151
+ return platform === "win32"
152
+ ? ["skillboard", "skillboard.cmd", "skillboard.exe"].includes(command)
153
+ : command === "skillboard";
154
+ }
155
+
156
+ function installWarnings(result) {
157
+ const warnings = [];
158
+ if (result.shadowed) {
159
+ warnings.push(
160
+ `PATH selects SkillBoard ${displayVersion(result.pathSelected.version)} at ${result.pathSelected.path} instead of this ${displayVersion(result.current.version)} invocation at ${result.current.entrypoint}.`
161
+ );
162
+ }
163
+ if (result.duplicateInstallations) {
164
+ warnings.push(
165
+ `Multiple SkillBoard installations were found on PATH: ${result.installations.map(displayInstallation).join(", ")}. Keep one npm prefix active and uninstall stale copies with the npm installation that owns them.`
166
+ );
167
+ }
168
+ return warnings;
169
+ }
170
+
171
+ function displayVersion(version) {
172
+ return version === null ? "with unknown version" : `version ${version}`;
173
+ }
174
+
175
+ function displayInstallation(candidate) {
176
+ return `${candidate.version ?? "unknown"} at ${candidate.path}`;
177
+ }
@@ -29,7 +29,8 @@ async function refreshLocked(options) {
29
29
  const inventory = options.inventory ?? await discoverAgentSkillInventory({
30
30
  roots: options.roots,
31
31
  home: options.home,
32
- env: options.env
32
+ env: options.env,
33
+ registeredRoots: options.registeredRoots
33
34
  });
34
35
  const configVersion = YAML.parse(current)?.version;
35
36
  const generatedInventory = configVersion === 2
@@ -43,9 +44,12 @@ async function refreshLocked(options) {
43
44
  const previousInventoryText = inventoryText === null
44
45
  ? null
45
46
  : await readFile(inventoryPath, "utf8").catch((error) => error?.code === "ENOENT" ? "" : Promise.reject(error));
46
- const merged = configVersion === 2
47
+ const projected = configVersion === 2
47
48
  ? mergeV2InventoryPolicy(current, inventory)
48
49
  : mergeAgentSkillInventory(current, inventory);
50
+ const merged = configVersion === 1 && options.preserveLegacyPolicy === true
51
+ ? { ...projected, text: current }
52
+ : projected;
49
53
  const plan = textChangePlan(current, merged.text);
50
54
  const dryRun = options.dryRun === true;
51
55
 
@@ -81,6 +85,7 @@ async function refreshLocked(options) {
81
85
  bootstrappedV2,
82
86
  configPath: relativeArtifactPath(root, configPath),
83
87
  inventoryPath: generatedInventory === null ? null : relativeArtifactPath(root, inventoryPath),
88
+ observedSkillIds: inventory.skills.map(({ id }) => id).sort((left, right) => left.localeCompare(right)),
84
89
  inventoryChanged,
85
90
  changed: plan.changed,
86
91
  plan,
@@ -0,0 +1,14 @@
1
+ const SAFE_AMBIGUITY_KIND = "review_only_quarantine";
2
+
3
+ export function canAutomaticallyMigrateV2(report) {
4
+ if (report?.mode !== "preview" || report.changed !== true || report.target_version !== 2) return false;
5
+ if (!Array.isArray(report.ambiguities)) return false;
6
+ return report.ambiguities.every((ambiguity) => (
7
+ ambiguity?.kind === SAFE_AMBIGUITY_KIND
8
+ && ambiguity.mapped_enabled === true
9
+ && ambiguity.requires_grouped_confirmation === true
10
+ && Array.isArray(ambiguity.skill_ids)
11
+ && ambiguity.skill_ids.length > 0
12
+ && ambiguity.skill_ids.every((skillId) => typeof skillId === "string" && skillId.length > 0)
13
+ ));
14
+ }
@@ -103,6 +103,7 @@ export function unchangedMigrationResult(inputBytes) {
103
103
  ambiguities: [],
104
104
  grouped_decision: null,
105
105
  backup: null,
106
- manifest: null
106
+ manifest: null,
107
+ inventory_backup: null
107
108
  };
108
109
  }
@@ -46,6 +46,9 @@ export async function migrateV2(options) {
46
46
 
47
47
  async function migrateForward(configPath, inventoryPath, options) {
48
48
  const inputBytes = await readFile(configPath);
49
+ if (options.expectedInputSha256 !== undefined && sha256(inputBytes) !== options.expectedInputSha256) {
50
+ throw new Error("Config changed after migration preview; no files were changed.");
51
+ }
49
52
  const inputText = inputBytes.toString("utf8");
50
53
  const { document, parsed } = parseMigrationConfig(inputText);
51
54
  const version = parsed.version ?? 1;
@@ -85,6 +88,7 @@ async function migrateForward(configPath, inventoryPath, options) {
85
88
  changed: true,
86
89
  backup: basename(backup.configBackupPath),
87
90
  manifest: basename(backup.manifestPath),
91
+ inventory_backup: backup.inventoryBackupPath === null ? null : basename(backup.inventoryBackupPath),
88
92
  ...report
89
93
  };
90
94
  } catch (error) {
@@ -0,0 +1,43 @@
1
+ import { dirname, join, resolve } from "node:path";
2
+ import { canAutomaticallyMigrateV2 } from "./migration/automatic-v2.mjs";
3
+ import { migrateV2 } from "./migration/v2-transaction.mjs";
4
+
5
+ export async function upgradeLegacyUserPolicy(options) {
6
+ if (options.inventoryPath !== null) {
7
+ return { status: "current", inventoryPath: options.inventoryPath, artifacts: [] };
8
+ }
9
+
10
+ const inventoryPath = resolve(options.home, ".skillboard", "inventory.json");
11
+ const migrationOptions = {
12
+ configPath: options.configPath,
13
+ inventoryPath,
14
+ failpoint: options.failpoint
15
+ };
16
+ const preview = await migrateV2({ ...migrationOptions, apply: false });
17
+ const observed = new Set(options.observedSkillIds);
18
+ const unobservedSkillIds = preview.skills.map(({ id }) => id).filter((id) => !observed.has(id));
19
+ if (!canAutomaticallyMigrateV2(preview) || unobservedSkillIds.length > 0) {
20
+ return {
21
+ status: "decision-required",
22
+ inventoryPath: null,
23
+ artifacts: [],
24
+ preview,
25
+ unobservedSkillIds
26
+ };
27
+ }
28
+
29
+ const applied = await migrateV2({
30
+ ...migrationOptions,
31
+ apply: true,
32
+ expectedInputSha256: preview.input_sha256
33
+ });
34
+ const directory = dirname(options.configPath);
35
+ const artifactNames = [applied.backup, applied.manifest, applied.inventory_backup].filter(Boolean);
36
+ return {
37
+ status: "upgraded",
38
+ inventoryPath,
39
+ backupPath: join(directory, applied.backup),
40
+ artifacts: artifactNames.map((name) => join(directory, name)),
41
+ report: applied
42
+ };
43
+ }
@@ -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;