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.
- package/CHANGELOG.md +62 -0
- package/README.md +52 -2
- package/bin/postinstall.mjs +2 -1
- package/docs/ai-skill-routing-goal.md +12 -0
- package/docs/install.md +60 -3
- package/docs/policy-model.md +7 -0
- package/docs/reference.md +25 -1
- package/docs/user-flow.md +18 -1
- package/docs/value-proof.md +2 -1
- package/docs/versioning.md +21 -3
- package/package.json +1 -1
- package/src/agent-integration-cli.mjs +97 -40
- package/src/agent-integration-command.mjs +38 -0
- 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 +7 -2
- package/src/migration/automatic-v2.mjs +14 -0
- package/src/migration/v2-projection.mjs +2 -1
- package/src/migration/v2-transaction.mjs +4 -0
- package/src/setup-policy-migration.mjs +43 -0
- 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,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
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
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;
|